mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
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:
@@ -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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }));
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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)`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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},直到本次远程会话结束。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user