feat(plugins): ask at first connect to a host the manifest does not list (net:ask) (#2417)

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>
This commit is contained in:
Vadim Lazuk
2026-11-10 12:31:45 +08:00
committed by GitHub
co-authored by Inf1mum
parent bfe99c06e2
commit 11594437b8
28 changed files with 894 additions and 23 deletions
+32
View File
@@ -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:<host>`.
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::<Capability>().unwrap().covers(&cap));
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
}
}
#[cfg(test)]
mod database_tests {
use super::*;
+62
View File
@@ -53,6 +53,9 @@ pub struct Grant {
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Exact hosts approved through `net:ask`, separate from manifest grants.
#[serde(default)]
pub net_hosts: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
@@ -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<String, BinaryGrant>,
) {
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();
+24
View File
@@ -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}");
}
}
}
}
}
+153 -2
View File
@@ -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<HashMap<u64, PromptEntry>>,
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
pub(super) prompts_blocked: Mutex<BTreeSet<String>>,
/// One network question per plugin; refusals throttle all hosts for 30 seconds.
net_prompts: Mutex<HashMap<String, Option<Instant>>>,
pub net_hosts_session_only: AtomicBool,
session_net_hosts: Mutex<HashMap<String, BTreeSet<String>>>,
pub(super) next_prompt: AtomicU64,
pub(super) run_configurations: super::run_detectors::DynamicConfigurations,
pub(super) run_targets: Mutex<super::run_targets::Targets>,
@@ -518,6 +523,9 @@ pub(super) struct Shared {
pub secrets: RwLock<Arc<dyn SecretStore>>,
/// (plugin, `process:` capability) → the approved program.
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
/// 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<Mutex<Ledger>>,
pub project_pins: ProjectPins,
pub event_editors: RwLock<HashMap<String, ide_plugin::EditorContext>>,
/// Validated UI declarations (settings schemas, declared views).
@@ -542,6 +550,7 @@ impl Shared {
data_dir: PathBuf,
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
ui: Arc<ide_extension::UiStore>,
ledger: Arc<Mutex<Ledger>>,
) -> Arc<Self> {
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>, 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<Option<bool>, 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<dyn PluginEventSink> = 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) }));
+43 -7
View File
@@ -380,7 +380,7 @@ pub struct PluginHost {
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
ledger: Arc<Mutex<Ledger>>,
plugins: RwLock<BTreeMap<String, Arc<LoadedPlugin>>>,
state: Mutex<PersistedState>,
/// 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<PluginInfo> {
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<PluginInfo> {
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(),
@@ -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() }));
+3
View File
@@ -72,6 +72,9 @@ pub struct PluginInfo {
pub has_code: bool,
/// Declared capabilities with risk and grant state.
pub capabilities: Vec<CapabilityInfo>,
/// 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<String>,
/// What it contributes while live (point + label).
pub contributions: Vec<ContributionInfo>,
/// `activation` events of the manifest.
+1
View File
@@ -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"
+152
View File
@@ -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:<host>`, 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::<String>::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::<String>::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<Option<String>, 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());
+26
View File
@@ -419,6 +419,21 @@ pub trait HostApi: Send + Sync {
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError>;
/// Performs `request` (its host is granted), filling in secret placeholders.
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
/// 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<Option<bool>, 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<Capability>,
pub(crate) subject: ide_extension::ledger::Subject,
pub(crate) host: Arc<dyn HostApi>,
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<String>,
/// 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<Capability>,
/// 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<dyn HostApi>,
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) {
+24 -1
View File
@@ -94,6 +94,10 @@ pub enum Prompt {
Confirm { title: String, message: String },
#[serde(rename_all = "camelCase")]
PickDirectory { title: String, initial: Option<String> },
/// 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:<kind>`).
@@ -437,7 +441,13 @@ pub fn net_host(url: &str) -> Result<String, String> {
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());
}
}
+38 -3
View File
@@ -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<Option<bool>, 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:<host>`.
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
Binary file not shown.
@@ -1 +1 @@
023542d758cd359aa4d1282b95ddd2edca9fcf746862700705dd637b3e524571
daecb28adad6f82f932ef70e042f520959d3429e9e2a23cda7df7ec05ba5f259
+2 -2
View File
@@ -1,13 +1,13 @@
id = "api14"
name = "API 1.4 host tests"
version = "0.1.0"
engines.openidea = "^1.4"
engines.openidea = "^1.5"
wasm = "dist/plugin.wasm"
activation = ["on-command:*", "on-event:file-closed", "on-event:settings-changed", "on-event:input-mode-changed"]
[capabilities]
required = ["storage"]
optional = ["ide.commands", "project.read", "vcs.read", "events", "events.input", "editor.read", "secrets", "net:localhost", "net.http:example.com", "ipc:$TMPDIR/api14-*"]
optional = ["ide.commands", "project.read", "vcs.read", "events", "events.input", "editor.read", "secrets", "net:localhost", "net.http:example.com", "net:ask", "ipc:$TMPDIR/api14-*"]
[[contributes.secrets]]
name = "token"
+27
View File
@@ -306,6 +306,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| `events.input` | medium | `action-performed` and `input-mode-changed` events |
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | 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:<host>` | medium | Unencrypted HTTP to that host; API 1.4, same wildcard rules as `net:` |
| `ipc:<path>` | high | Local Unix sockets or Windows named pipes on the project machine; API 1.4 |
| `secrets` | medium | `{{secret:<name>}}` 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:<host>`,
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:<host>`. A
manifest using `net:ask` declares `engines.openidea = "^1.5"`.
A host answer covers all ports of that host, like `net:<host>`, 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:<path>` grants a local byte stream: an absolute Unix socket path (the host
expands `~/`, `$TMPDIR/` and `$XDG_RUNTIME_DIR/`) or `\\.\pipe\<name>` on Windows.
Only a grant's last path segment may contain `*`, for example
+1
View File
@@ -501,6 +501,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+8
View File
@@ -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<PluginInfo> {
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]
+13 -1
View File
@@ -1108,6 +1108,8 @@ export const commands = {
pluginGrant: (id: string, consent: Consent) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_grant", { id, consent })),
/** Grants or revokes one declared capability. */
pluginSetCapability: (id: string, capability: string, granted: boolean) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_set_capability", { id, capability, granted })),
/** Revokes one host remembered through net:ask. */
pluginRevokeNetHost: (id: string, host: string) => typedError<PluginInfo, IdeError>(__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<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_link", { path })),
pluginUnlink: (id: string) => typedError<null, IdeError>(__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";
+25 -1
View File
@@ -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 (
<div className={styles.muted}>
<T>No special permissions.</T>
@@ -402,10 +403,33 @@ function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean })
/>
</CapabilityRow>
))}
{plugin.netHosts.map((host) => (
<NetHostRow key={host} plugin={plugin} host={host} disabled={busy || !confirmed} />
))}
</ul>
);
}
/** 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 (
<li className={styles.capabilityItem}>
<div className={styles.capabilityHeader}>
<span className={styles.mono}>{`net:${host}`}</span>
<Badge tone={RISK_LABEL.medium.tone}>{RISK_LABEL.medium.label}</Badge>
</div>
<p className={styles.capabilityDescription}>
{tx("Allowed when the plugin asked at first use. Remove it to be asked again.")}
</p>
<div className={styles.programPermission}>
<Button disabled={disabled} onClick={() => void revokePluginNetHost(plugin.id, host)}>
{tx("Remove")}
</Button>
</div>
</li>
);
}
function Homepage({ value }: { value: string }) {
let isWebUrl = false;
try {
@@ -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<PluginInfo> & Pick<PluginInfo, "id">): 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(<MessagesHost />);
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(<MessagesHost />);
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(<MessagesHost />);
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(<MessagesHost />);
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(<PluginsPage />);
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" }),
+10
View File
@@ -58,6 +58,7 @@ export interface PluginsBackend {
program(pluginId: string, program: string, scope: "global" | "project"): Promise<string>;
pickProgram(): Promise<string | null>;
setCapability(id: string, capability: string, granted: boolean): Promise<PluginInfo>;
revokeNetHost(id: string, host: string): Promise<PluginInfo>;
/** 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 })),
+5
View File
@@ -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)`;
+59
View File
@@ -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<string[] | null> {
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);
}
}
+10 -3
View File
@@ -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<string[] |
return (await confirm({ title: prompt.title, message: prompt.message, ok: tx("OK"), signal, verbatim: true }))
? []
: null;
case "netPermission":
// The host's own question: answered before this (`answerPrompt`).
return null;
}
}
async function answerPrompt(promptId: string, prompt: Prompt) {
async function answerPrompt(promptId: string, prompt: Prompt, pluginId: string) {
const controller = new AbortController();
openPrompts.set(promptId, controller);
const answer = await askUser(prompt, controller.signal);
const answer =
prompt.kind === "netPermission"
? await askNetPermission(pluginId, prompt.host, prompt.sessionOnly, controller.signal)
: await askUser(prompt, controller.signal);
openPrompts.delete(promptId);
if (controller.signal.aborted) return;
await pluginsBackend()
@@ -599,7 +606,7 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
void openExternalUrl(e.url);
return true;
case "promptRequested":
void answerPrompt(e.promptId, e.prompt);
void answerPrompt(e.promptId, e.prompt, e.pluginId);
return true;
case "promptClosed":
openPrompts.get(e.promptId)?.abort();
+6
View File
@@ -1,5 +1,6 @@
/** Installed plugins, their UI contributions, the selected plugin theme, and the operations on them. */
import { create } from "zustand";
import { tx } from "@/platform/i18n";
import type {
ActiveTheme,
CapabilityInfo,
@@ -191,6 +192,11 @@ export function setPluginCapability(id: string, capability: string, granted: boo
);
}
export function revokePluginNetHost(id: string, host: string): Promise<boolean> {
const name = nameOf(id);
return withBusy(id, tx`Could not revoke ${host} for ${name}`, () => pluginsBackend().revokeNetHost(id, host));
}
export async function refreshCatalog(): Promise<void> {
try {
usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null });
+15 -1
View File
@@ -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."
}
+14 -1
View File
@@ -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},直到本次远程会话结束。"
}