From 11594437b80ea23e00a690005aa82c0a1b4d2d06 Mon Sep 17 00:00:00 2001 From: Vadim Lazuk Date: Tue, 10 Nov 2026 07:31:45 +0300 Subject: [PATCH] feat(plugins): ask at first connect to a host the manifest does not list (net:ask) (#2417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins that declare net:ask can reach an HTTPS or loopback host their manifest does not list after the user allows it: Allow once, Always allow (remembered in the grant ledger, revocable in Settings › Plugins) or Deny. Unattended calls never prompt and are denied until the host is allowed. In remote development the dialog offers Allow for this session instead; the approval stays in memory on the server and ends with the connection, so no grant exists that the desktop cannot list or revoke. Closes #2165 Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com> --- crates/ide-extension/src/capability.rs | 32 ++++ crates/ide-extension/src/ledger.rs | 62 +++++++ crates/ide-extension/src/manifest.rs | 24 +++ crates/ide-host/src/plugins/api.rs | 155 +++++++++++++++++- crates/ide-host/src/plugins/mod.rs | 50 +++++- crates/ide-host/src/plugins/remote_host.rs | 1 + crates/ide-host/src/plugins/types.rs | 3 + crates/ide-host/src/trust.rs | 1 + crates/ide-host/tests/plugins.rs | 152 +++++++++++++++++ crates/ide-plugin/src/runtime.rs | 26 +++ crates/ide-plugin/src/types.rs | 25 ++- crates/ide-plugin/src/wit_host.rs | 41 ++++- .../tests/fixtures/api14/dist/plugin.wasm | Bin 244860 -> 244872 bytes .../fixtures/api14/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/api14/plugin.toml | 4 +- plugins/README.md | 27 +++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/plugin.rs | 8 + src/bindings.ts | 14 +- src/features/plugins/PluginsPage.tsx | 26 ++- .../plugins/__tests__/plugins.test.tsx | 139 ++++++++++++++++ src/features/plugins/backend.ts | 10 ++ src/features/plugins/consent.tsx | 5 + src/features/plugins/netAsk.tsx | 59 +++++++ src/features/plugins/runtimeUi.tsx | 13 +- src/features/plugins/store.ts | 6 + src/platform/locales/text/en.json | 16 +- src/platform/locales/text/zh.json | 15 +- 28 files changed, 894 insertions(+), 23 deletions(-) create mode 100644 src/features/plugins/netAsk.tsx diff --git a/crates/ide-extension/src/capability.rs b/crates/ide-extension/src/capability.rs index 00a79d417..c17930adb 100644 --- a/crates/ide-extension/src/capability.rs +++ b/crates/ide-extension/src/capability.rs @@ -38,6 +38,10 @@ pub enum Capability { Clipboard, /// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains). Net(String), + /// Ask the user when the first request goes to an HTTPS (or loopback) host + /// the manifest does not list; each answer applies to one host. Plain + /// HTTP elsewhere always needs `net.http:`. + NetAsk, /// Unencrypted HTTP to one host. NetHttp(String), /// A local socket or named pipe on the project machine. @@ -71,6 +75,7 @@ impl Capability { | Self::DatabaseSources | Self::VcsWrite | Self::Net(_) + | Self::NetAsk | Self::NetHttp(_) | Self::Secrets | Self::InputEvents @@ -104,6 +109,10 @@ impl Capability { "Copy to the clipboard from its commands, and read and write it from its web views".into() } Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"), + Self::NetAsk => { + "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones" + .into() + } Self::NetHttp(host) => { format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)") } @@ -230,6 +239,7 @@ impl fmt::Display for Capability { Self::InputEvents => f.write_str("events.input"), Self::Clipboard => f.write_str("clipboard"), Self::Net(h) => write!(f, "net:{h}"), + Self::NetAsk => f.write_str("net:ask"), Self::NetHttp(h) => write!(f, "net.http:{h}"), Self::Ipc(p) => write!(f, "ipc:{p}"), Self::Process(b) => write!(f, "process:{b}"), @@ -251,6 +261,9 @@ impl FromStr for Capability { } return match kind { "net" | "net.http" => { + if kind == "net" && arg == "ask" { + return Ok(Self::NetAsk); + } let host = arg.strip_prefix("*.").unwrap_or(arg); let valid = !host.is_empty() && host.len() <= 253 @@ -451,6 +464,25 @@ mod command_tests { } } +#[cfg(test)] +mod net_ask_tests { + use super::*; + #[test] + fn net_ask_is_its_own_medium_risk_grant_covering_no_host() { + let cap: Capability = "net:ask".parse().unwrap(); + assert_eq!(cap, Capability::NetAsk); + assert_eq!(cap.to_string(), "net:ask"); + assert_eq!(cap.risk(), Risk::Medium); + assert!(!cap.auto_migrates()); + // It asks; it does not itself connect anywhere or pre-approve a host. + assert!(!cap.covers(&"net:api.example.com".parse().unwrap())); + assert!(!cap.covers(&Capability::NetHttp("example.com".into()))); + assert!(!"net:api.example.com".parse::().unwrap().covers(&cap)); + assert_ne!("net:ask.com".parse::().unwrap(), Capability::NetAsk); + assert!("net:ask".parse::().unwrap().describe().contains("host")); + } +} + #[cfg(test)] mod database_tests { use super::*; diff --git a/crates/ide-extension/src/ledger.rs b/crates/ide-extension/src/ledger.rs index 95c17cd50..2cb772520 100644 --- a/crates/ide-extension/src/ledger.rs +++ b/crates/ide-extension/src/ledger.rs @@ -53,6 +53,9 @@ pub struct Grant { /// Granted capabilities (`project.read`, `net:x`, …). #[serde(default)] pub granted: BTreeSet, + /// Exact hosts approved through `net:ask`, separate from manifest grants. + #[serde(default)] + pub net_hosts: BTreeSet, /// Optional capabilities the user declined (not asked again until re-confirmed). #[serde(default)] pub declined: BTreeSet, @@ -201,6 +204,9 @@ impl Ledger { let Some(grant) = self.grants.get_mut(&req.subject) else { return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true }; }; + if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) { + grant.net_hosts.clear(); + } if !grant.confirmed { return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true }; } @@ -225,6 +231,9 @@ impl Ledger { binaries: BTreeMap, ) { let grant = self.grants.entry(req.subject.clone()).or_default(); + if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) { + grant.net_hosts.clear(); + } grant.plugin_id = req.plugin_id.to_owned(); grant.version = req.version.to_owned(); grant.package_hash = req.package_hash.to_owned(); @@ -252,6 +261,12 @@ impl Ledger { grant.declined.remove(&s); grant.granted.insert(s) } else { + if let Capability::Net(host) = cap { + return grant.net_hosts.remove(host) | grant.granted.remove(&s); + } + if cap == &Capability::NetAsk { + grant.net_hosts.clear(); + } if let Capability::Process(_) = cap { grant.binaries.remove(&s); for binaries in grant.project_binaries.values_mut() { @@ -262,6 +277,19 @@ impl Ledger { } } + /// Remembers one exact host without granting a manifest capability. + pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool { + let Some(grant) = self.grants.get_mut(subject) else { return false }; + if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') { + return false; + } + grant.net_hosts.insert(host.to_owned()) + } + + pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool { + self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host)) + } + /// Records the program approved for a `process:` grant. pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) { if let Some(grant) = self.grants.get_mut(subject) { @@ -510,6 +538,40 @@ mod tests { assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. })); } + #[test] + fn net_ask_hosts_persist_revoke_and_drop_with_the_capability() { + let tmp = tempfile::tempdir().unwrap(); + let mut l = Ledger::load(tmp.path()); + let r = req("1.0.0", "h", &["net:ask"], &[]); + l.confirm(&r, &[], BTreeMap::new()); + let host = "api.example.com"; + assert!(l.remember_net_host(&r.subject, host)); + assert!(!l.remember_net_host(&r.subject, "*.example.com")); + assert!(!l.get(&r.subject).unwrap().has(&Capability::Net(host.into()))); + l.save().unwrap(); + l = Ledger::load(tmp.path()); + assert!(l.get(&r.subject).unwrap().net_hosts.contains(host)); + l.confirm(&r, &[], BTreeMap::new()); + assert!(l.get(&r.subject).unwrap().net_hosts.contains(host)); + assert!(l.set(&r.subject, &Capability::Net(host.into()), false)); + assert!(l.get(&r.subject).unwrap().net_hosts.is_empty()); + + for confirm in [false, true] { + l.remember_net_host(&r.subject, host); + // A newly declared host must not inherit an ask grant after net:ask is removed. + let update = req("2.0.0", "h2", &["project.read", "net:api.example.com"], &[]); + if confirm { + l.confirm(&update, &[], BTreeMap::new()); + } else { + assert!(matches!(l.decide(&update), Decision::NeedsConfirmation { .. })); + } + assert!(l.get(&r.subject).unwrap().net_hosts.is_empty()); + } + l.remember_net_host(&r.subject, host); + l.set(&r.subject, &Capability::NetAsk, false); + assert!(l.get(&r.subject).unwrap().net_hosts.is_empty()); + } + #[test] fn migration_grants_low_risk_only() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs index 132aba1e7..711db8caf 100644 --- a/crates/ide-extension/src/manifest.rs +++ b/crates/ide-extension/src/manifest.rs @@ -338,6 +338,9 @@ impl Manifest { if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) { self.needs_api(&format!("capability `{cap}`"), 3)?; } + if matches!(cap, Capability::NetAsk) { + self.needs_api(&format!("capability `{cap}`"), 5)?; + } } if let Some(dark) = &self.icon_dark { if self.icon.is_none() { @@ -718,3 +721,24 @@ mod commands_api_tests { assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok()); } } + +#[cfg(test)] +mod net_ask_api_tests { + use super::*; + #[test] + fn net_ask_requires_api_1_5() { + for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] { + for engine in ["^1.4", "^1.5"] { + let manifest = format!( + "id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n" + ); + let parsed = Manifest::parse(&manifest); + if engine == "^1.5" { + parsed.unwrap(); + } else { + assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}"); + } + } + } + } +} diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index ded0d79a0..ac9aedc2c 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -6,6 +6,7 @@ use ide_core::ipc_path::to_ipc; use ide_core::{CancellationToken, TextEdit}; use ide_extension::Capability; use ide_extension::Package; +use ide_extension::ledger::{Ledger, Subject}; use ide_language::ProgramPin; use ide_plugin::{ CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, @@ -17,7 +18,7 @@ use std::io::Read; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command as StdCommand, Stdio}; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::{Duration, Instant}; /// Largest storage of one plugin (keys + values). @@ -495,6 +496,10 @@ pub(super) struct Shared { pub(super) prompts: Mutex>, /// Plugins being stopped: their calls may not open new prompts (taken before `prompts`). pub(super) prompts_blocked: Mutex>, + /// One network question per plugin; refusals throttle all hosts for 30 seconds. + net_prompts: Mutex>>, + pub net_hosts_session_only: AtomicBool, + session_net_hosts: Mutex>>, pub(super) next_prompt: AtomicU64, pub(super) run_configurations: super::run_detectors::DynamicConfigurations, pub(super) run_targets: Mutex, @@ -518,6 +523,9 @@ pub(super) struct Shared { pub secrets: RwLock>, /// (plugin, `process:` capability) → the approved program. pub pins: Arc>>, + /// The grant ledger, shared with the [`super::PluginHost`]: `net:ask` + /// looks up and records "always allow" hosts right where a request runs. + pub ledger: Arc>, pub project_pins: ProjectPins, pub event_editors: RwLock>, /// Validated UI declarations (settings schemas, declared views). @@ -542,6 +550,7 @@ impl Shared { data_dir: PathBuf, pins: Arc>>, ui: Arc, + ledger: Arc>, ) -> Arc { Arc::new(Self { ui, @@ -550,6 +559,7 @@ impl Shared { workspace: RwLock::new(None), secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })), pins, + ledger, project_pins: Arc::default(), event_editors: RwLock::default(), data_dir, @@ -562,6 +572,9 @@ impl Shared { process_shutdowns: Mutex::default(), prompts: Mutex::default(), prompts_blocked: Mutex::default(), + net_prompts: Mutex::default(), + net_hosts_session_only: AtomicBool::new(false), + session_net_hosts: Mutex::default(), next_prompt: AtomicU64::new(1), run_configurations: Arc::default(), run_targets: Mutex::default(), @@ -793,6 +806,8 @@ impl Shared { self.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: plugin.into(), sets: Vec::new() }); } self.withdraw_prompts(plugin); + self.net_prompts.lock().remove(plugin); + self.session_net_hosts.lock().remove(plugin); let removed: Vec<(Option, String)> = { let mut contexts = self.contexts.lock(); let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect(); @@ -2216,6 +2231,90 @@ impl HostApi for PluginApi { }) } + /// The `net:ask` answer: a host the user allowed with "always" is + /// remembered in the ledger (no dialog again, background included); a new + /// host is asked about in `client`'s window when there is one. Dismissal + /// and refusal both deny and throttle further questions. + fn net_ask( + &self, + subject: &Subject, + client: Option<&str>, + host: &str, + cancel: &CancellationToken, + ) -> Result, HostError> { + if cancel.is_cancelled() { + return Err(HostError::Cancelled); + } + if !self.pkg.manifest.declares(&Capability::NetAsk) { + return Ok(None); + } + let host = host.trim_end_matches('.').to_ascii_lowercase(); + { + let ledger = self.shared.ledger.lock(); + if let Some(grant) = ledger.get(subject) + && grant.confirmed + && grant.has(&Capability::NetAsk) + && grant.net_hosts.contains(&host) + { + return Ok(Some(true)); + } + } + if self.shared.session_net_hosts.lock().get(self.id()).is_some_and(|hosts| hosts.contains(&host)) { + return Ok(Some(true)); + } + let Some(client) = client else { return Ok(None) }; + { + let closed = self.shared.closed_clients.lock(); + if closed.contains(client) { + return Ok(None); + } + } + if self.shared.prompts_blocked.lock().contains(self.id()) { + return Ok(None); + } + { + let mut prompts = self.shared.net_prompts.lock(); + if prompts.get(self.id()).is_some_and(|until| until.is_none_or(|until| until > Instant::now())) { + return Ok(None); + } + prompts.insert(self.id().to_owned(), None); + } + let session_only = self.shared.net_hosts_session_only.load(Ordering::Relaxed); + let answer = self.ask(Some(client), Prompt::NetPermission { host: host.clone(), session_only }, cancel); + let result = match answer { + Ok(Some(keys)) if keys == ["session"] || keys == ["always"] => { + let mut ledger = self.shared.ledger.lock(); + if cancel.is_cancelled() { + Err(HostError::Cancelled) + } else if ledger.get(subject).is_some_and(|g| g.confirmed && g.has(&Capability::NetAsk)) { + if session_only || keys == ["session"] { + self.shared.session_net_hosts.lock().entry(self.id().into()).or_default().insert(host); + } else { + ledger.remember_net_host(subject, &host); + if let Err(e) = ledger.save() { + tracing::warn!("saving plugin grants: {e}"); + } + } + drop(ledger); + self.shared.sink.emit(None, PluginEvent::CommandsChanged); + Ok(Some(true)) + } else { + Ok(None) + } + } + Ok(Some(keys)) if keys == ["once"] => Ok(Some(false)), + Ok(_) => Ok(None), + Err(e) => Err(e), + }; + let mut prompts = self.shared.net_prompts.lock(); + if matches!(result, Ok(Some(_))) { + prompts.remove(self.id()); + } else { + prompts.insert(self.id().to_owned(), Some(Instant::now() + Duration::from_secs(30))); + } + result + } + fn secret_is_set(&self, name: &str) -> bool { self.shared.secrets.read().get(self.id(), name).is_some() } @@ -3118,10 +3217,61 @@ mod tests { ) .unwrap(); let sink: Arc = Arc::new(|_: Option<&str>, _: PluginEvent| {}); - let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new()); + let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new(), Arc::default()); PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared } } + #[test] + fn net_questions_are_exclusive_throttled_and_cancelled() { + let dir = tempfile::tempdir().unwrap(); + let mut api = api(dir.path(), Arc::default()); + api.pkg = Package::new( + ide_extension::Manifest::parse( + "id = \"acme.lint\"\nname = \"Lint\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n[capabilities]\nrequired = [\"net:ask\"]\n", + ).unwrap(), + dir.path().to_path_buf(), + ); + let subject = Subject::Installed(api.id().into()); + api.shared.ledger.lock().confirm( + &ide_extension::ledger::Request { + subject: subject.clone(), + plugin_id: api.id(), + version: "1.0.0", + package_hash: "h", + required: vec![Capability::NetAsk], + optional: vec![], + }, + &[], + BTreeMap::new(), + ); + for answer in [Some(vec!["always".into(), "once".into()]), None] { + api.shared.net_prompts.lock().clear(); + let cancel = CancellationToken::new(); + std::thread::scope(|scope| { + let run = scope.spawn(|| api.net_ask(&subject, Some("main"), "::1", &cancel)); + let deadline = Instant::now() + Duration::from_secs(5); + let id = loop { + if let Some(id) = api.shared.prompts.lock().keys().next().copied() { + break id; + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(api.net_ask(&subject, Some("other"), "other.example", &cancel).unwrap(), None); + if let Some(answer) = answer { + assert!(api.shared.answer_prompt(id, Some(answer))); + assert_eq!(run.join().unwrap().unwrap(), None); + } else { + cancel.cancel(); + assert!(matches!(run.join().unwrap(), Err(HostError::Cancelled))); + } + }); + assert!(api.shared.prompts.lock().is_empty()); + assert!(api.shared.ledger.lock().get(&subject).unwrap().net_hosts.is_empty()); + assert_eq!(api.net_ask(&subject, Some("main"), "new.example", &CancellationToken::new()).unwrap(), None); + } + } + #[cfg(unix)] #[test] fn writes_recheck_approved_paths_after_symlink_substitution() { @@ -3801,6 +3951,7 @@ mod workbench_tests { root.join(".data"), Arc::default(), ide_extension::UiStore::new(), + Arc::default(), ); *shared.workspace.write() = Some(Arc::new(Workspace { root: root.into(), trusted, text: text.map(str::to_owned) })); diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index bdfae2b43..38dc83b27 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -380,7 +380,7 @@ pub struct PluginHost { diagnostic_projects: Mutex>, /// Validated UI declarations of installed packages (start-up reuses them). index: Arc, - ledger: Mutex, + ledger: Arc>, plugins: RwLock>>, state: Mutex, /// What every plugin's [`api::PluginApi`] shares: sink, registry, @@ -452,7 +452,9 @@ impl PluginHost { languages.set_process_policy(Arc::new(move |plugin, cap| { policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned() })); - let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone()); + let ledger = Arc::new(Mutex::new(Ledger::load(&plugins_dir))); + let shared = + api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone(), ledger.clone()); let official_keys = market::built_in_official_keys(); let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys)); let host = Self { @@ -463,7 +465,7 @@ impl PluginHost { updates: Mutex::default(), project_policy: RwLock::new(Arc::new(|_| false)), engine: PluginEngine::new().map_err(IdeError::internal)?, - ledger: Mutex::new(Ledger::load(&plugins_dir)), + ledger, plugins_dir, sink, limits, @@ -806,6 +808,11 @@ impl PluginHost { Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect()) } + /// Keeps hosts allowed through `net:ask` in memory for this host's lifetime. + pub fn set_net_hosts_session_only(&self) { + self.shared.net_hosts_session_only.store(true, std::sync::atomic::Ordering::Relaxed); + } + /// The UI language windows run in, as `locale()` reports it to plugin code. pub fn set_ui_locale(&self, tag: &str) { *self.shared.locale.write() = tag.to_owned(); @@ -1079,19 +1086,39 @@ impl PluginHost { self.info_of(id) } - /// Grants or revokes one capability (Settings › Plugins › Permissions). + /// Revokes one exact host remembered through `net:ask`. + pub fn revoke_net_host(&self, id: &str, host: &str) -> IdeResult { + let _guard = self.lifecycle.lock(); + let plugin = self.get(id)?; + self.check_not_uninstalling(&plugin)?; + { + let mut ledger = self.ledger.lock(); + if !ledger.forget_net_host(&plugin.subject(), host) { + return Err(IdeError::invalid("host is not remembered for this plugin")); + } + self.save_ledger(&ledger); + } + self.stop(&plugin); + if !self.state.lock().disabled.contains(id) { + self.start(&plugin); + } + self.changed(); + self.info_of(id) + } + + /// Grants or revokes one declared capability (Settings › Plugins › Permissions). /// Revoking a required one stops the plugin until consent is given again. pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult { let _guard = self.lifecycle.lock(); let plugin = self.get(id)?; self.check_not_uninstalling(&plugin)?; let cap: Capability = capability.parse().map_err(IdeError::invalid)?; - if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) { - return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}"))); - } { let mut ledger = self.ledger.lock(); let subject = plugin.subject(); + if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) { + return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}"))); + } if ledger.get(&subject).is_none() { return Err(IdeError::invalid(format!("plugin {id:?} has not been confirmed yet"))); } @@ -2537,6 +2564,7 @@ impl PluginHost { plugin_id: plugin.id().to_owned(), version: plugin.manifest().version.clone(), granted, + subject: plugin.subject(), host: api, limits: self.limits, }; @@ -2914,6 +2942,13 @@ impl PluginHost { capabilities.push(CapabilityInfo::new(extra, true, granted(extra))); } } + // Hosts remembered through `net:ask` are grants of their own: listed + // separately so the user can revoke them one by one. + let net_hosts = grant + .as_ref() + .filter(|g| g.confirmed && g.has(&Capability::NetAsk) && m.declares(&Capability::NetAsk)) + .map(|g| g.net_hosts.iter().cloned().collect()) + .unwrap_or_default(); PluginInfo { id: m.id.clone(), name: m.name.clone(), @@ -2930,6 +2965,7 @@ impl PluginHost { error: meta.error.clone(), has_code: m.wasm.is_some(), capabilities, + net_hosts, contributions: self.registry.contributions(&m.id), activation: m.activation.iter().map(ToString::to_string).collect(), warnings: meta.warnings.clone(), diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 7f86c7012..2f19b95f8 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -292,6 +292,7 @@ impl RemotePlugins { } }; let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?); + host.set_net_hosts_session_only(); projects.set_plugin_host(&host); host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects))))); host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() })); diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index a17a148fe..111e3b11f 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -72,6 +72,9 @@ pub struct PluginInfo { pub has_code: bool, /// Declared capabilities with risk and grant state. pub capabilities: Vec, + /// HTTPS hosts the user allowed through `net:ask` ("always allow"), + /// none of them declared by the manifest; each can be revoked. + pub net_hosts: Vec, /// What it contributes while live (point + label). pub contributions: Vec, /// `activation` events of the manifest. diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index b9d22e247..c5fa67e8d 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -420,6 +420,7 @@ pub fn projectless_command_allowed(cmd: &str) -> bool { | "plugin_set_enabled" | "plugin_grant" | "plugin_set_capability" + | "plugin_revoke_net_host" | "plugin_choose_program" | "plugin_program" | "plugin_roll_back" diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 0acdaec82..d5a2fb569 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -5753,6 +5753,158 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() { assert_eq!(f.host.declared_secret("api14", "token"), None); } +/// `net:ask`: a first request to a host outside the manifest asks the user +/// from an invoking window ("once" allows this request, "always" remembers +/// the host, refusal and dismissal deny). Background calls are denied without +/// a dialog, plain HTTP still needs `net.http:`, and a remembered host +/// is listed in the permissions, where it can be revoked. +#[test] +fn net_ask_asks_once_remembers_always_and_revokes() { + use ide_plugin::Prompt; + let f = fixture(Limits::default()); + f.host.install_from_dir(&sample("api14"), &Consent::default()).unwrap(); + let (port, seen) = http_server("relay ok", |_| Duration::ZERO); + let url = format!("http://127.0.0.1:{port}/"); + + // Merely declaring an optional capability is not enough: the user must grant it. + let err = f.run("api14", "fetch", Some(&url)).unwrap_err(); + assert!(err.contains("not granted"), "{err}"); + assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. }))); + f.host.set_capability("api14", "net:ask", true).unwrap(); + + // Plain HTTP elsewhere stays out of the ask flow entirely. + let err = f.run("api14", "fetch", Some("http://example.com/")).unwrap_err(); + assert!(err.contains("net.http:example.com") && err.contains("not granted"), "{err}"); + assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. }))); + + // Background (no invoking window): denied like before, no dialog. + let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err(); + assert!(err.message.contains("net:127.0.0.1") && err.message.contains("not granted"), "{err:?}"); + assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. }))); + assert!(seen.lock().is_empty(), "nothing connected"); + + // From a window: the question names the full host. "Allow once" lets + // this request through and remembers nothing. + let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&["once"])); + assert_eq!(result.unwrap().as_deref(), Some("relay ok")); + assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false }); + assert_eq!(f.host.info_of("api14").unwrap().net_hosts, Vec::::new()); + assert_eq!(seen.lock().len(), 1); + + // The next request asks again; "Always allow" remembers the host. + let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&["always"])); + assert_eq!(result.unwrap().as_deref(), Some("relay ok")); + assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false }); + assert_eq!(f.host.info_of("api14").unwrap().net_hosts, ["127.0.0.1".to_owned()]); + + // Remembered: connecting without a dialog from a window and in the background. + let result = f.host.run_command("api14", "fetch", Some(&url), f.inv()).map_err(|e| e.message).unwrap(); + assert_eq!(result.as_deref(), Some("relay ok")); + let result = + f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).map_err(|e| e.message).unwrap(); + assert_eq!(result.as_deref(), Some("relay ok")); + assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. }))); + assert_eq!(seen.lock().len(), 4); + + // The permissions list the host; revoking it asks again, and refusing denies. + f.host.revoke_net_host("api14", "127.0.0.1").unwrap(); + assert_eq!(f.host.info_of("api14").unwrap().net_hosts, Vec::::new()); + let (result, prompt) = net_ask_run_and_answer(&f, &url, None); + assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false }); + let err = result.unwrap_err(); + assert!(err.contains("not granted"), "{err}"); + // Refusal throttles even a different host, without making any connection. + let err = f.run("api14", "fetch", Some("https://other.example/")).unwrap_err(); + assert!(err.contains("not granted"), "{err}"); + assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. }))); + assert_eq!(seen.lock().len(), 4); + + f.host.uninstall("api14").unwrap(); + let ledger = ide_extension::ledger::Ledger::load(&f.plugins_dir); + assert!(ledger.get(&ide_extension::ledger::Subject::Installed("api14".into())).is_none()); +} + +#[test] +fn net_ask_session_hosts_are_ephemeral_and_cleared_with_plugin_grants() { + use ide_extension::ledger::{Ledger, Subject}; + use ide_plugin::Prompt; + let f = fixture(Limits::default()); + f.host.set_net_hosts_session_only(); + f.host.install_from_dir(&sample("api14"), &Consent::all()).unwrap(); + let (port, seen) = http_server("session ok", |_| Duration::ZERO); + let url = format!("http://127.0.0.1:{port}/"); + let subject = Subject::Installed("api14".into()); + + for (answer, reset) in [("session", "disable"), ("always", "revoke"), ("session", "uninstall")] { + let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err(); + assert!(err.message.contains("not granted"), "{err:?}"); + assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. }))); + + let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&[answer])); + assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: true }); + assert_eq!(result.unwrap().as_deref(), Some("session ok")); + for invocation in [f.inv(), Invocation::default()] { + let result = f.host.run_command("api14", "fetch", Some(&url), invocation).unwrap(); + assert_eq!(result.as_deref(), Some("session ok")); + } + let err = + f.host.run_command("api14", "fetch", Some("https://other.example/"), Invocation::default()).unwrap_err(); + assert!(err.message.contains("not granted"), "{err:?}"); + assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. }))); + assert!(f.host.info_of("api14").unwrap().net_hosts.is_empty()); + assert!(Ledger::load(&f.plugins_dir).get(&subject).unwrap().net_hosts.is_empty()); + + match reset { + "disable" => { + f.host.set_enabled("api14", false).unwrap(); + f.host.set_enabled("api14", true).unwrap(); + } + "revoke" => { + f.host.set_capability("api14", "net:ask", false).unwrap(); + f.host.set_capability("api14", "net:ask", true).unwrap(); + } + "uninstall" => { + f.host.uninstall("api14").unwrap(); + assert!(Ledger::load(&f.plugins_dir).get(&subject).is_none()); + f.host.install_from_dir(&sample("api14"), &Consent::all()).unwrap(); + } + _ => unreachable!(), + } + } + let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err(); + assert!(err.message.contains("not granted"), "{err:?}"); + assert_eq!(seen.lock().len(), 9); +} + +/// Runs api14's `fetch` in its window; once the `net:ask` question arrives, +/// answers it (`None`: dismisses). Returns the command's result and the prompt. +fn net_ask_run_and_answer( + f: &Fixture, + url: &str, + answer: Option<&[&str]>, +) -> (Result, String>, ide_plugin::Prompt) { + std::thread::scope(|scope| { + let running = scope.spawn(|| f.run("api14", "fetch", Some(url))); + let deadline = Instant::now() + Duration::from_secs(10); + let (client, prompt_id, prompt) = loop { + let asked = f.take_events().into_iter().find_map(|(client, e)| match e { + PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)), + _ => None, + }); + if let Some(asked) = asked { + break asked; + } + assert!(Instant::now() < deadline && !running.is_finished(), "the fetch never asked"); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(client.as_deref(), Some("main"), "asks in the window that ran the command"); + let answer = answer.map(|a| a.iter().map(|s| (*s).to_owned()).collect()); + assert!(f.host.answer_prompt(&prompt_id, answer)); + assert!(!f.host.answer_prompt(&prompt_id, None), "an answer counts once"); + (running.join().unwrap(), prompt) + }) +} + #[test] fn api14_window_status_overrides_fall_back_and_are_withdrawn() { let f = fixture(Limits::default()); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index d4437e6f5..6bd5a8052 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -419,6 +419,21 @@ pub trait HostApi: Send + Sync { fn publish_diagnostics(&self, path: &Path, items: Vec) -> Result<(), HostError>; /// Performs `request` (its host is granted), filling in secret placeholders. fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result; + /// The `net:ask` answer for a request to HTTPS host `host` of this plugin + /// (`subject`): `Some(true)` allowed and remembered (a host the user + /// already allowed connects without asking again, also in the background), + /// `Some(false)` allowed for this request only, `None` refused — also when + /// nobody could be asked (`client` is `None` outside commands and native view actions). + fn net_ask( + &self, + subject: &ide_extension::ledger::Subject, + client: Option<&str>, + host: &str, + cancel: &CancellationToken, + ) -> Result, HostError> { + let _ = (subject, client, host, cancel); + Ok(None) + } fn secret_is_set(&self, name: &str) -> bool; /// Stores or deletes a secret declared by this plugin; never returns its value. @@ -796,10 +811,12 @@ pub struct StoreData { stderr_seen: usize, pub(crate) plugin_id: String, pub(crate) granted: Vec, + pub(crate) subject: ide_extension::ledger::Subject, pub(crate) host: Arc, pub(crate) invocation: Invocation, /// The running call is a command or an event. pub(crate) interactive: bool, + pub(crate) net_interactive: bool, /// Editor window for pick, pick-many, input and confirm during action resolution. pub(crate) action_prompt_client: Option, /// Tasks begun by the running call and not ended yet. @@ -913,6 +930,9 @@ pub struct RuntimeSetup { pub version: String, /// Capabilities granted to the plugin. pub granted: Vec, + /// Whose grant this is (installed id, or the package directory of a + /// linked or project plugin): what `net:ask` answers are remembered for. + pub subject: ide_extension::ledger::Subject, pub host: Arc, pub limits: Limits, } @@ -1022,9 +1042,11 @@ impl Runtime { stderr_seen: 0, plugin_id: setup.plugin_id, granted: setup.granted, + subject: setup.subject, host: setup.host, invocation: Invocation::default(), interactive: false, + net_interactive: false, action_prompt_client: None, open_tasks: Vec::new(), last_report: None, @@ -1637,6 +1659,7 @@ impl Runtime { let result = self.call(invocation, self.limits.timeout, |e, store| { let ctx = store.data_mut().new_invocation()?; store.data_mut().interactive = true; + store.data_mut().net_interactive = true; let r = e.plugin.openidea_plugin_commands().call_run( &mut *store, id, @@ -1762,10 +1785,12 @@ impl Runtime { if self.exports.views.is_none() { return Err(CallError::missing_export("view-provider")); } + let net_interactive = !matches!(&event, ViewEvent::Message { .. }); let event = event_to_wit_view(event); let result = self.call(invocation, self.limits.timeout, |e, store| { let ctx = store.data_mut().new_invocation()?; store.data_mut().interactive = true; + store.data_mut().net_interactive = net_interactive; let views = e.views.as_ref().expect("checked"); let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep())); store.data_mut().drop_invocation(ctx); @@ -1817,6 +1842,7 @@ impl Runtime { let data = self.store.data_mut(); data.invocation = Invocation::default(); data.interactive = false; + data.net_interactive = false; data.action_prompt_client = None; // Tasks don't outlive the call that began them. for id in std::mem::take(&mut data.open_tasks) { diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index c32a11eef..4cd3732e8 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -94,6 +94,10 @@ pub enum Prompt { Confirm { title: String, message: String }, #[serde(rename_all = "camelCase")] PickDirectory { title: String, initial: Option }, + /// The host's own question under `net:ask` (never plugin-initiated): the + /// plugin reached an HTTPS host its manifest does not list. + #[serde(rename_all = "camelCase")] + NetPermission { host: String, session_only: bool }, } /// Kinds of workbench events a plugin can subscribe to (`on-event:`). @@ -437,7 +441,13 @@ pub fn net_host(url: &str) -> Result { return Err(format!("unsupported scheme {:?}", parsed.scheme())); } match parsed.host() { - Some(url::Host::Domain(host)) => Ok(host.trim_end_matches('.').to_ascii_lowercase()), + Some(url::Host::Domain(host)) => { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if host.is_empty() || host.contains('*') { + return Err("URL must name one exact host".into()); + } + Ok(host) + } Some(url::Host::Ipv4(host)) => Ok(host.to_string()), Some(url::Host::Ipv6(host)) => Ok(host.to_string()), None => Err("URL has no host".into()), @@ -479,8 +489,21 @@ mod tests { net_capability("http://example.com/").unwrap(), ide_extension::Capability::NetHttp("example.com".into()) ); + for (url, host) in [ + ("https://EXAMPLE.com.:8443/", "example.com"), + ("https://bücher.example/", "xn--bcher-kva.example"), + ("https://xn--bcher-kva.example/", "xn--bcher-kva.example"), + ("https://127.1/", "127.0.0.1"), + ("https://[0:0:0:0:0:0:0:1]:8443/", "::1"), + ("https://sub.example.com/", "sub.example.com"), + ] { + assert_eq!(net_host(url).unwrap(), host); + } + assert_ne!(net_host("https://аpi.example/").unwrap(), net_host("https://api.example/").unwrap()); assert!(net_host("file:///etc/passwd").is_err()); assert!(net_host("example.com").is_err()); + assert!(net_host("https://*.example.com/").is_err()); + assert!(net_host("https://./").is_err()); } } diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index c5780167e..05806b083 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -322,6 +322,23 @@ impl StoreData { answer.map_err(to_wit) } + /// The user's answer for a request to `host` under `net:ask`: + /// `Some(_)` allows it (`true`: the host is remembered). `None` when + /// nobody could be asked or the user refused. Commands and view actions pass + /// their window; background and scheduled calls ask nobody, so only an + /// already-allowed host connects there. + fn ask_net(&mut self, host: &str) -> Result, wit::Error> { + let client = self.net_interactive.then(|| self.invocation.client.clone()).flatten(); + let subject = self.subject.clone(); + let started = Instant::now(); + let answer = self.host.net_ask(&subject, client.as_deref(), host, &self.cancel.token()); + // The wait doesn't count against the call's wall-clock budget. + let waited = started.elapsed(); + self.deadline += waited; + self.call_started += waited; + answer.map_err(to_wit) + } + fn pick_prompt( &mut self, title: String, @@ -1269,8 +1286,26 @@ impl net::Host for StoreData { Ok(h) => h, Err(e) => return Ok(Err(wit::Error::Invalid(e))), }; - if let Err(e) = self.require(capability) { - return Ok(Err(e)); + let mut grants = self.granted.clone(); + if let Err(e) = self.require(capability.clone()) { + // `net:ask`: an HTTPS (or loopback) host the manifest does not list + // may ask the user — commands and view actions from a window only. Plain + // HTTP elsewhere always needs `net.http:`. + let askable = + matches!(&capability, Capability::Net(_)) && self.granted.iter().any(|g| g.covers(&Capability::NetAsk)); + let allowed = match &capability { + Capability::Net(host) if askable => match self.ask_net(host) { + Ok(Some(_)) => true, + Ok(None) => false, + // A cancelled or failed question is not a refusal. + Err(e) => return Ok(Err(e)), + }, + _ => false, + }; + if !allowed { + return Ok(Err(e)); + } + grants.push(capability.clone()); } if req.headers.iter().any(|h| h.value.contains("{{secret:")) && let Err(e) = self.require(Capability::Secrets) @@ -1287,7 +1322,7 @@ impl net::Host for StoreData { headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(), body: req.body, timeout: wanted.min(self.remaining()), - grants: self.granted.clone(), + grants, }; let token = self.cancel.token(); Ok(self diff --git a/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm index 8588b517c6c125f2949021d95156ae6b52ea4cab..daa7037b052b02a3d165e382d30178d8f6e92495 100644 GIT binary patch delta 1017 zcma)&eN4=89LMkd``r~f-Ay4E?Wctq+G@D|QO<1{uEYq{2#J24y6Z^oj@xMJAH!!l z4>hLDq>TH{8XMIgo@U0S$U~fm$l5$~J<@~UuVk%1%wOO4zMt3U{ds>rJzf6!-TqhO zCgCBApES*XJfP8l=K=5tP;wecaByBT@)MoaMqb5AXH4}8%Bg~-k9Xi6T)2Tsq~64B zTt+T6&S}Oeb~ilA2Op?^?iJmkmqjOkv=mjxXaA;#c7amZdjdEl6ql3C* zP3UB?i`Q`yaBfKoB)WI08SQNMvRy8IM_iPNYji*@%Ujz5G?F=Kg{oQ3N4lgiYDtQe{z8&hpqVn0t$0RFvS#{}i7M*e zItx$Pv~5j5EBT2w)U$l?F~&WY+e7)W3V{<~P>b3I%Glw+OR|;NY3t4qypR>+cTM2% zn&~n(fW>c206cVd(*wMxjLk3bk&J2KXkwPMX1S1@9tjUSlD-{OVhz%I?75C!TDG^r zzhCy%_({%6QQJ=u*>RJHT4( zCHp=m&56i~l?Q({>lo#@`O$r{iNG7`$lzW`74`u1u^M~n7`&tO{0o|aFlv>bM403> zx`O3_gXX-!I+TXjej$57sC1lj1%+ySVEV!+;4RB^cJcVcGS1uw-cV$5G3wZpViTli z%GvcwykfSJrJBEokz#rd*18VYx=>l`;>MY@$~nFP8?qda%FB%2=@nU)%C^O7S`d4ArOKid59JkR(0JfH99^L;+gQ~WjbduM1x z$}rUM)M2AT!^6}}2Y`v8(?OENDuMRLhS8B&wKDjz*yy0h!WwMuk!0M07Z*{D^h>yk zGjLK%Ts!vgn-da((B_`!ML4~VA45~8*dx1k9lCL(GZ3@gD2!52Pq zx}TxLYu>Cu2gymB5TM>93qEtptVK+mm(N)Y0hW^O13t;JtkOnMYPykolIwv0UzcJ5 zKGL<+q4>z_Q%@_&*0c>G=}y}R{kNYL>JZ}qbA!sJ|HjMa2f#f4f3G}tfVnSRLKM|z z^fdtgx|N-o!%p>Kt<9K7h6S_xwSclSO;owy5gzgV3*P`Q$Yq^}S5#-Uig;X>5*KCV zqm@1=F;83^q2W!oG%%m+wnre0OsgyLkOHe4(Li-;CZLh`tZ4^)^m6TF_;^%q4sgQV zdRe@6?$U5H^Bqf{gZ0W(@nR-jSQZaIf3>U*Lmn#*MN5NJMrMRrYty&Uo#ir~Qt}E9 zo`~5+&Qn^RyK*4IbG~ua5?~A+EgU*n?^7y`Ds!amS4i5tk#x?!gvaG2GBT~7L|yqY z+P)|n@9>HaTOCni?s8P(J5{W2F!U%yx)4F7w&{N-bj=Y?a)E}b3gROEaE$-0B#;R< zeLIaPG%~$UEXl$aN^=em9woLFHd>o+r9Fk`h0Zi*l5*N)kK%`&cO`U_-IaxIUh6tO z5DzG?_=N7KrdAVTl*|^<0mYQeHT=?)%=v#bB_UW+5-q$vTT-e;Jx?r60Pgc5cc+9V z?(p0O?$PwJGPLmKGVv>0_}IhQYCKas` | high | `Project::run_process`, language servers, debug adapters, runs | | `net:` | medium | `host::fetch` over HTTPS (also loopback HTTP); `*.example.com` matches subdomains | +| `net:ask` | medium | Ask the user at the first request to an HTTPS (or loopback) host that is not declared; one answer per host, revocable; API 1.5 | | `net.http:` | medium | Unencrypted HTTP to that host; API 1.4, same wildcard rules as `net:` | | `ipc:` | high | Local Unix sockets or Windows named pipes on the project machine; API 1.4 | | `secrets` | medium | `{{secret:}}` in request headers, `host::secret_is_set`, `secret_set`, `secret_delete` | @@ -373,6 +374,9 @@ working. New in 1.5: - [Notifications for a project](#notifications-with-commands): `Project::notify` shows a notification in a window of that project only, also from scheduled or background work. +- [Permissions](#capabilities): `net:ask` lets a plugin connect to HTTPS hosts + one at a time, asking at first use instead of listing every host + (see _[Network and secrets](#network-and-secrets)_). ### API 1.4 @@ -1211,6 +1215,29 @@ connecting to the destination; HTTPS → HTTP also requires `net.http:`, even on loopback. Responses are limited to 8 MiB. Requests run on the project machine. A manifest using `net.http:` or `ipc:` declares `engines.openidea = "^1.4"`. +A plugin with `net:ask` (API 1.5) may reach an HTTPS (or loopback) host that is +not in the manifest: the first request asks the user, naming the plugin and the +full host, with **Allow once**, **Always allow** and **Deny**. "Always" is +remembered per exact, normalized host in the grant ledger and never asks again; the allowed +hosts are listed in the plugin's permissions (Settings › Plugins), where each +can be removed to ask again. Remote sessions offer **Allow for this session** +and **Deny** instead: approval is kept only in memory until the remote connection ends. +Unattended calls to a host that is not yet allowed return `denied` without a dialog, +so the plugin should tell the user to run the action once interactively. Plain HTTP +to other hosts never goes through this flow: declare `net.http:`. A +manifest using `net:ask` declares `engines.openidea = "^1.5"`. + +A host answer covers all ports of that host, like `net:`, and never +covers subdomains or wildcard patterns. IDNs are shown in ASCII (punycode); +IP literals use their canonical form. Redirects are checked against the +request's grants and cannot open another permission dialog. Deny is the +default button; dismissal refuses the request. Only one network question per +plugin may be pending; a refusal or dismissal suppresses further questions +for 30 seconds, even for other hosts. Hidden windows refuse new questions and +close pending ones. Automatic event handlers and providers do not ask. +Removing `net:ask` during an update or revoking it clears remembered hosts; +uninstalling removes the plugin's ledger entry. + `ipc:` grants a local byte stream: an absolute Unix socket path (the host expands `~/`, `$TMPDIR/` and `$XDG_RUNTIME_DIR/`) or `\\.\pipe\` on Windows. Only a grant's last path segment may contain `*`, for example diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d4c302fe6..34f8d4b88 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -501,6 +501,7 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_install_dir, plugin::plugin_grant, plugin::plugin_set_capability, + plugin::plugin_revoke_net_host, plugin::plugin_link, plugin::plugin_unlink, plugin::plugin_assets, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 6c14a3f44..9cd543ff2 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -235,6 +235,14 @@ pub async fn plugin_set_capability( blocking(move || host.set_capability(&id, &capability, granted)).await } +/// Revokes one host remembered through net:ask. +#[tauri::command] +#[specta::specta] +pub async fn plugin_revoke_net_host(state: State<'_, AppState>, id: String, host: String) -> IdeResult { + let plugins = state.plugins.clone(); + blocking(move || plugins.revoke_net_host(&id, &host)).await +} + /// Links a development plugin folder (`openidea-plugin link` does the same from a terminal). #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index ccfc220a7..72c1dbb53 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1108,6 +1108,8 @@ export const commands = { pluginGrant: (id: string, consent: Consent) => typedError(__TAURI_INVOKE("plugin_grant", { id, consent })), /** Grants or revokes one declared capability. */ pluginSetCapability: (id: string, capability: string, granted: boolean) => typedError(__TAURI_INVOKE("plugin_set_capability", { id, capability, granted })), + /** Revokes one host remembered through net:ask. */ + pluginRevokeNetHost: (id: string, host: string) => typedError(__TAURI_INVOKE("plugin_revoke_net_host", { id, host })), /** Links a development plugin folder (`openidea-plugin link` does the same from a terminal). */ pluginLink: (path: string) => typedError(__TAURI_INVOKE("plugin_link", { path })), pluginUnlink: (id: string) => typedError(__TAURI_INVOKE("plugin_unlink", { id })), @@ -6693,6 +6695,11 @@ export type PluginInfo = { hasCode: boolean, /** Declared capabilities with risk and grant state. */ capabilities: CapabilityInfo[], + /** + * HTTPS hosts the user allowed through `net:ask` ("always allow"), + * none of them declared by the manifest; each can be revoked. + */ + netHosts: string[], /** What it contributes while live (point + label). */ contributions: ContributionInfo[], /** `activation` events of the manifest. */ @@ -7112,7 +7119,12 @@ export type ProjectStructureSettings = { * What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, * `ui.input-multiline`, `ui.confirm`). */ -export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string } | { kind: "pickDirectory"; title: string; initial: string | null }; +export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string } | { kind: "pickDirectory"; title: string; initial: string | null } | +/** + * The host's own question under `net:ask` (never plugin-initiated): the + * plugin reached an HTTPS host its manifest does not list. + */ +{ kind: "netPermission"; host: string; sessionOnly: boolean }; export type ProxyKind = "http" | "socks"; diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 4ad771cbc..ff46e32ae 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -49,6 +49,7 @@ import { reloadPlugin, setAllInstalledEnabled, setPluginCapability, + revokePluginNetHost, setPluginEnabled, uninstallPlugin, updateAllPlugins, @@ -376,7 +377,7 @@ function ProgramPermission({ plugin, cap, disabled }: { plugin: PluginInfo; cap: } function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) { - if (plugin.capabilities.length === 0) { + if (plugin.capabilities.length === 0 && plugin.netHosts.length === 0) { return (
No special permissions. @@ -402,10 +403,33 @@ function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) /> ))} + {plugin.netHosts.map((host) => ( + + ))} ); } +/** A host the user allowed through `net:ask` ("always"); removing it asks again next time. */ +function NetHostRow({ plugin, host, disabled }: { plugin: PluginInfo; host: string; disabled: boolean }) { + return ( +
  • +
    + {`net:${host}`} + {RISK_LABEL.medium.label} +
    +

    + {tx("Allowed when the plugin asked at first use. Remove it to be asked again.")} +

    +
    + +
    +
  • + ); +} + function Homepage({ value }: { value: string }) { let isWebUrl = false; try { diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index cab548ace..15bd47ad8 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -10,6 +10,7 @@ import { useProject } from "@/platform/project"; import { pluginThemeRef, useContributedIconThemes } from "@/platform/themes"; import { ContextMenuHost, hideContextMenu } from "@/ui"; import { InputDialogsHost } from "@/workbench/inputDialogs"; +import { MessagesHost } from "@/workbench/messages"; import { onEndpointsChanged, provideNotificationService, @@ -74,6 +75,7 @@ function plugin(partial: Partial & Pick): PluginIn error: null, hasCode: true, capabilities: [], + netHosts: [], contributions: [], activation: [], warnings: [], @@ -640,6 +642,115 @@ describe("plugin prompts", () => { await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("2", null)); }); + it("asks about a net:ask host with allow once, always allow and deny", async () => { + const user = userEvent.setup(); + const answerPrompt = vi.fn().mockResolvedValue(undefined); + setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt }); + usePlugins.setState({ plugins: [plugin({ id: "p", name: "Relay" })] }); + render(); + + const ask = (promptId: string) => + act(() => { + handlePluginEvent({ + type: "promptRequested", + pluginId: "p", + promptId, + prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: false }, + }); + }); + ask("9"); + expect(screen.getByText("Allow Relay to connect to api.example.com?")).toBeDefined(); + expect(screen.getByText(/The host is not in the plugin's declared permissions\./)).toBeDefined(); + expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Always allow" })); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("9", ["always"])); + + ask("10"); + await user.click(await screen.findByRole("button", { name: "Allow once" })); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("10", ["once"])); + + ask("11"); + await user.click(await screen.findByRole("button", { name: "Deny" })); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("11", null)); + }); + + it("offers session permission and deny for a remote net:ask prompt", async () => { + const user = userEvent.setup(); + const answerPrompt = vi.fn().mockResolvedValue(undefined); + setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt }); + render(); + const ask = (promptId: string) => + act(() => { + handlePluginEvent({ + type: "promptRequested", + pluginId: "p", + promptId, + prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: true }, + }); + }); + ask("session-net"); + expect(screen.queryByRole("button", { name: "Always allow" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Allow once" })).toBeNull(); + expect(screen.getAllByRole("button")).toHaveLength(2); + expect(screen.getByText(/This allows api.example.com until the remote session ends\./)).toBeDefined(); + expect(screen.queryByText(/revoke it any time/)).toBeNull(); + expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Allow for this session" })); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("session-net", ["session"])); + + ask("denied-session-net"); + await user.click(await screen.findByRole("button", { name: "Deny" })); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("denied-session-net", null)); + }); + + it("refuses network prompts in a hidden window and closes them when hidden", async () => { + const answerPrompt = vi.fn().mockResolvedValue(undefined); + setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt }); + render(); + const visibility = vi.spyOn(document, "visibilityState", "get"); + const ask = (promptId: string) => + act(() => { + handlePluginEvent({ + type: "promptRequested", + pluginId: "p", + promptId, + prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: false }, + }); + }); + try { + visibility.mockReturnValue("hidden"); + ask("hidden"); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("hidden", null)); + expect(screen.queryByRole("dialog")).toBeNull(); + visibility.mockReturnValue("visible"); + ask("visible"); + expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus(); + visibility.mockReturnValue("hidden"); + act(() => document.dispatchEvent(new Event("visibilitychange"))); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("visible", null)); + expect(screen.queryByRole("dialog")).toBeNull(); + } finally { + visibility.mockRestore(); + } + }); + + it("dismisses a network prompt without allowing the request", async () => { + const user = userEvent.setup(); + const answerPrompt = vi.fn().mockResolvedValue(undefined); + setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt }); + render(); + act(() => { + handlePluginEvent({ + type: "promptRequested", + pluginId: "p", + promptId: "dismissed-net", + prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: false }, + }); + }); + await user.keyboard("{Escape}"); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("dismissed-net", null)); + }); + it("asks multi-line text and confirms on Ctrl+Enter", async () => { const user = userEvent.setup(); const answerPrompt = vi.fn().mockResolvedValue(undefined); @@ -1232,6 +1343,34 @@ describe("marketplace", () => { }); }); + it.each(["api.example.com", "::1", "ask"])( + "revokes remembered host %s separately from capabilities", + async (host) => { + const user = userEvent.setup(); + const base = createMemoryPluginsBackend([ + plugin({ + id: "p", + name: "P", + netHosts: [host, "relay.example.org"], + capabilities: [ + { id: "net:ask", risk: "medium", description: "Ask to connect", optional: true, granted: true }, + ], + }), + ]); + const revokeNetHost = vi.fn((id: string, host: string) => base.revokeNetHost(id, host)); + setPluginsBackend({ ...base, revokeNetHost }); + render(); + await screen.findByText("net:relay.example.org"); + expect(screen.getAllByText(`net:${host}`)).toHaveLength(host === "ask" ? 2 : 1); + expect(screen.getByText("net:relay.example.org")).toBeInTheDocument(); + const rows = screen.getAllByRole("button", { name: "Remove" }); + await user.click(rows[0]!); + await waitFor(() => expect(revokeNetHost).toHaveBeenCalledWith("p", host)); + await waitFor(() => expect(screen.queryAllByText(`net:${host}`)).toHaveLength(host === "ask" ? 1 : 0)); + expect(screen.getByText("net:relay.example.org")).toBeInTheDocument(); + }, + ); + it("offers to roll back an update", async () => { const base = createMemoryPluginsBackend([ plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }), diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index 12824a7a7..1839e778b 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -58,6 +58,7 @@ export interface PluginsBackend { program(pluginId: string, program: string, scope: "global" | "project"): Promise; pickProgram(): Promise; setCapability(id: string, capability: string, granted: boolean): Promise; + revokeNetHost(id: string, host: string): Promise; /** Validated assets (icon theme mapping and SVGs) whose id starts with `prefix`. */ assets(pluginId: string, prefix: string): Promise<[string, string][]>; /** `language` / `file` / `view` activation events. */ @@ -216,6 +217,7 @@ export const tauriPluginsBackend: PluginsBackend = { return (Array.isArray(picked) ? picked[0] : picked) ?? null; }, setCapability: (id, capability, granted) => call(commands.pluginSetCapability(id, capability, granted)), + revokeNetHost: (id, host) => call(commands.pluginRevokeNetHost(id, host)), assets: (pluginId, prefix) => call(commands.pluginAssets(pluginId, prefix)), activationEvent: async (kind, value) => { await call(commands.pluginActivationEvent(kind, value)); @@ -420,6 +422,7 @@ function samplePlugins(): PluginInfo[] { enabled: true, error: null, capabilities: [], + netHosts: [], contributions: [], activation: [], warnings: [], @@ -588,10 +591,17 @@ export function createMemoryPluginsBackend( setCapability: async (id, capability, granted) => { const p = find(id); p.capabilities = p.capabilities.map((c) => (c.id === capability ? { ...c, granted } : c)); + if (!granted && capability === "net:ask") p.netHosts = []; if (!granted && p.capabilities.some((c) => c.id === capability && !c.optional)) p.status = "needsConsent"; emit({ type: "commandsChanged" }); return { ...p }; }, + revokeNetHost: async (id, host) => { + const p = find(id); + p.netHosts = p.netHosts.filter((h) => h !== host); + emit({ type: "commandsChanged" }); + return { ...p }; + }, assets: async () => [], activationEvent: async () => {}, catalog: async () => catalog.map((c) => ({ ...c })), diff --git a/src/features/plugins/consent.tsx b/src/features/plugins/consent.tsx index 8daf92b00..65f481916 100644 --- a/src/features/plugins/consent.tsx +++ b/src/features/plugins/consent.tsx @@ -39,6 +39,11 @@ export function capabilityDescription(cap: CapabilityInfo): string { "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing", ); } + if (cap.id === "net:ask") { + return tx( + "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones", + ); + } if (cap.id.startsWith("net.http:")) { const host = cap.id.slice(9); return tx`Connect to ${host} over unencrypted HTTP (from the machine the project is on)`; diff --git a/src/features/plugins/netAsk.tsx b/src/features/plugins/netAsk.tsx new file mode 100644 index 000000000..d792c8cdd --- /dev/null +++ b/src/features/plugins/netAsk.tsx @@ -0,0 +1,59 @@ +/** + * The host's own permission question (`net:ask`): a plugin with the + * capability reached an HTTPS host its manifest does not list. One dialog, + * one host: allow for this request, remember it, or refuse. Remembered + * local hosts appear in the plugin's permissions, where each can be revoked. + */ +import { tx } from "@/platform/i18n"; +import { ask } from "@/workbench/messages"; +import { usePlugins } from "./store"; + +/** Resolves with the host's answer key, or null when refused. */ +export async function askNetPermission( + pluginId: string, + host: string, + sessionOnly: boolean, + signal: AbortSignal, +): Promise { + if (document.visibilityState === "hidden") return null; + const controller = new AbortController(); + const abort = () => controller.abort(); + const visibilityChanged = () => { + if (document.visibilityState === "hidden") controller.abort(); + }; + signal.addEventListener("abort", abort); + document.addEventListener("visibilitychange", visibilityChanged); + if (signal.aborted) controller.abort(); + const name = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId; + try { + const r = await ask({ + title: tx`Allow ${name} to connect to ${host}?`, + body: [ + tx("The host is not in the plugin's declared permissions."), + " ", + tx("This allows this exact host on any port; subdomains need separate permission."), + " ", + sessionOnly + ? tx`This allows ${host} until the remote session ends.` + : tx`"Always allow" remembers ${host}; revoke it any time in the plugin's permissions.`, + ], + buttons: sessionOnly + ? [ + { id: "session", label: tx("Allow for this session") }, + { id: "deny", label: tx("Deny"), primary: true }, + ] + : [ + { id: "once", label: tx("Allow once") }, + { id: "always", label: tx("Always allow") }, + { id: "deny", label: tx("Deny"), primary: true }, + ], + cancel: false, + icon: "question", + signal: controller.signal, + }); + return r.button === "session" || r.button === "once" || r.button === "always" ? [r.button] : null; + } finally { + signal.removeEventListener("abort", abort); + document.removeEventListener("visibilitychange", visibilityChanged); + } +} diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index 062951d40..601e13a63 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -45,6 +45,7 @@ import { confirm } from "@/workbench/messages"; import { removeTask, upsertTask, useTasks } from "@/workbench/tasks"; import { editorContext } from "./editorContext"; import { pluginsBackend } from "./backend"; +import { askNetPermission } from "./netAsk"; import { usePluginT } from "./pluginText"; import { setPluginOutput } from "./views/outputStore"; import { PLUGIN_LOG_EDITOR_KIND } from "./views/logEditor"; @@ -408,13 +409,19 @@ async function askUser(prompt: Prompt, signal: AbortSignal): Promise { + const name = nameOf(id); + return withBusy(id, tx`Could not revoke ${host} for ${name}`, () => pluginsBackend().revokeNetHost(id, host)); +} + export async function refreshCatalog(): Promise { try { usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null }); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 8d7bdeae5..ac2298235 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -814,5 +814,19 @@ "Set Docker Compose File Detection": "Set Docker Compose File Detection", "Could not save Docker Compose file detection": "Could not save Docker Compose file detection", "Compose Up": "Compose Up", - "Start the service at the caret, or all services in the active Docker Compose file": "Start the service at the caret, or all services in the active Docker Compose file" + "Start the service at the caret, or all services in the active Docker Compose file": "Start the service at the caret, or all services in the active Docker Compose file", + "Allow {name} to connect to {host}?": "Allow {name} to connect to {host}?", + "The host is not in the plugin's declared permissions.": "The host is not in the plugin's declared permissions.", + "\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.", + "Allow once": "Allow once", + "Always allow": "Always allow", + "Remove": "Remove", + "Allowed when the plugin asked at first use. Remove it to be asked again.": "Allowed when the plugin asked at first use. Remove it to be asked again.", + "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones", + "This allows this exact host on any port; subdomains need separate permission.": "This allows this exact host on any port; subdomains need separate permission.", + "Could not revoke {host} for {name}": "Could not revoke {host} for {name}", + "host is not remembered for this plugin": "host is not remembered for this plugin", + "URL must name one exact host": "URL must name one exact host", + "Allow for this session": "Allow for this session", + "This allows {host} until the remote session ends.": "This allows {host} until the remote session ends." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index cb9c8cc2d..aa1959ee4 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7148,5 +7148,18 @@ "Set Docker Compose File Detection": "设置 Docker Compose 文件识别", "Could not save Docker Compose file detection": "无法保存 Docker Compose 文件识别设置", "Compose Up": "Compose 启动", - "Start the service at the caret, or all services in the active Docker Compose file": "启动光标处的服务,或当前 Docker Compose 文件中的所有服务" + "Start the service at the caret, or all services in the active Docker Compose file": "启动光标处的服务,或当前 Docker Compose 文件中的所有服务", + "Allow {name} to connect to {host}?": "允许 {name} 连接到 {host}?", + "The host is not in the plugin's declared permissions.": "该主机不在插件声明的权限列表中。", + "\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "「一直允许」会记住 {host};可随时在插件权限中撤销。", + "Allow once": "这次允许", + "Always allow": "一直允许", + "Allowed when the plugin asked at first use. Remove it to be asked again.": "在插件首次使用询问时允许。移除后下次会再次询问。", + "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "首次连接清单之外的主机时询问;你可以逐个允许或拒绝,并可撤销已允许的主机", + "This allows this exact host on any port; subdomains need separate permission.": "允许此确切主机的任意端口;子域名需要单独授权。", + "Could not revoke {host} for {name}": "无法撤销 {name} 对 {host} 的授权", + "host is not remembered for this plugin": "此插件没有记住该主机的授权", + "URL must name one exact host": "URL 必须指定一个确切主机", + "Allow for this session": "本次会话内允许", + "This allows {host} until the remote session ends.": "允许连接到 {host},直到本次远程会话结束。" }