diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index 3c3c0fec2d..e93767246e 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Context as _, Result}; +use anyhow::{anyhow, bail, Context as _, Result}; use collections::{BTreeMap, HashMap}; use fs::Fs; use language::LanguageName; @@ -85,6 +85,61 @@ pub struct ExtensionManifest { pub indexed_docs_providers: BTreeMap, IndexedDocsProviderEntry>, #[serde(default)] pub snippets: Option, + #[serde(default)] + pub capabilities: Vec, +} + +impl ExtensionManifest { + pub fn allow_exec( + &self, + desired_command: &str, + desired_args: &[impl AsRef + std::fmt::Debug], + ) -> Result<()> { + let is_allowed = self.capabilities.iter().any(|capability| match capability { + ExtensionCapability::ProcessExec { command, args } if command == desired_command => { + for (ix, arg) in args.iter().enumerate() { + if arg == "**" { + return true; + } + + if ix >= desired_args.len() { + return false; + } + + if arg != "*" && arg != desired_args[ix].as_ref() { + return false; + } + } + if args.len() < desired_args.len() { + return false; + } + true + } + _ => false, + }); + + if !is_allowed { + bail!( + "capability for process:exec {desired_command} {desired_args:?} was not listed in the extension manifest", + ); + } + + Ok(()) + } +} + +/// A capability for an extension. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub enum ExtensionCapability { + #[serde(rename = "process:exec")] + ProcessExec { + /// The command to execute. + command: String, + /// The arguments to pass to the command. Use `*` for a single wildcard argument. + /// If the last element is `**`, then any trailing arguments are allowed. + args: Vec, + }, } #[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)] @@ -218,5 +273,104 @@ fn manifest_from_old_manifest( slash_commands: BTreeMap::default(), indexed_docs_providers: BTreeMap::default(), snippets: None, + capabilities: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn extension_manifest() -> ExtensionManifest { + ExtensionManifest { + id: "test".into(), + name: "Test".to_string(), + version: "1.0.0".into(), + schema_version: SchemaVersion::ZERO, + description: None, + repository: None, + authors: vec![], + lib: Default::default(), + themes: vec![], + icon_themes: vec![], + languages: vec![], + grammars: BTreeMap::default(), + language_servers: BTreeMap::default(), + context_servers: BTreeMap::default(), + slash_commands: BTreeMap::default(), + indexed_docs_providers: BTreeMap::default(), + snippets: None, + capabilities: vec![], + } + } + + #[test] + fn test_allow_exact_match() { + let manifest = ExtensionManifest { + capabilities: vec![ExtensionCapability::ProcessExec { + command: "ls".to_string(), + args: vec!["-la".to_string()], + }], + ..extension_manifest() + }; + + assert!(manifest.allow_exec("ls", &["-la"]).is_ok()); + assert!(manifest.allow_exec("ls", &["-l"]).is_err()); + assert!(manifest.allow_exec("pwd", &[] as &[&str]).is_err()); + } + + #[test] + fn test_allow_wildcard_arg() { + let manifest = ExtensionManifest { + capabilities: vec![ExtensionCapability::ProcessExec { + command: "git".to_string(), + args: vec!["*".to_string()], + }], + ..extension_manifest() + }; + + assert!(manifest.allow_exec("git", &["status"]).is_ok()); + assert!(manifest.allow_exec("git", &["commit"]).is_ok()); + assert!(manifest.allow_exec("git", &["status", "-s"]).is_err()); // too many args + assert!(manifest.allow_exec("npm", &["install"]).is_err()); // wrong command + } + + #[test] + fn test_allow_double_wildcard() { + let manifest = ExtensionManifest { + capabilities: vec![ExtensionCapability::ProcessExec { + command: "cargo".to_string(), + args: vec!["test".to_string(), "**".to_string()], + }], + ..extension_manifest() + }; + + assert!(manifest.allow_exec("cargo", &["test"]).is_ok()); + assert!(manifest.allow_exec("cargo", &["test", "--all"]).is_ok()); + assert!(manifest + .allow_exec("cargo", &["test", "--all", "--no-fail-fast"]) + .is_ok()); + assert!(manifest.allow_exec("cargo", &["build"]).is_err()); // wrong first arg + } + + #[test] + fn test_allow_mixed_wildcards() { + let manifest = ExtensionManifest { + capabilities: vec![ExtensionCapability::ProcessExec { + command: "docker".to_string(), + args: vec!["run".to_string(), "*".to_string(), "**".to_string()], + }], + ..extension_manifest() + }; + + assert!(manifest.allow_exec("docker", &["run", "nginx"]).is_ok()); + assert!(manifest.allow_exec("docker", &["run"]).is_err()); + assert!(manifest + .allow_exec("docker", &["run", "ubuntu", "bash"]) + .is_ok()); + assert!(manifest + .allow_exec("docker", &["run", "alpine", "sh", "-c", "echo hello"]) + .is_ok()); + assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg } } diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index c0ff0029b5..b497bbf9af 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -163,6 +163,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { slash_commands: BTreeMap::default(), indexed_docs_providers: BTreeMap::default(), snippets: None, + capabilities: Vec::new(), }), dev: false, }, @@ -191,6 +192,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { slash_commands: BTreeMap::default(), indexed_docs_providers: BTreeMap::default(), snippets: None, + capabilities: Vec::new(), }), dev: false, }, @@ -356,6 +358,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { slash_commands: BTreeMap::default(), indexed_docs_providers: BTreeMap::default(), snippets: None, + capabilities: Vec::new(), }), dev: false, }, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs index b634134a6e..e7bdebfa89 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs @@ -592,6 +592,8 @@ impl process::Host for WasmState { command: process::Command, ) -> wasmtime::Result> { maybe!(async { + self.manifest.allow_exec(&command.command, &command.args)?; + let output = util::command::new_smol_command(command.command.as_str()) .args(&command.args) .envs(command.env) diff --git a/extensions/test-extension/extension.toml b/extensions/test-extension/extension.toml index 6ac0a38731..0ba9eeaadf 100644 --- a/extensions/test-extension/extension.toml +++ b/extensions/test-extension/extension.toml @@ -13,3 +13,8 @@ language = "Gleam" [grammars.gleam] repository = "https://github.com/gleam-lang/tree-sitter-gleam" commit = "8432ffe32ccd360534837256747beb5b1c82fca1" + +[[capabilities]] +kind = "process:exec" +command = "echo" +args = ["hello!"] diff --git a/extensions/test-extension/src/test_extension.rs b/extensions/test-extension/src/test_extension.rs index 514a956958..5b6a3f920a 100644 --- a/extensions/test-extension/src/test_extension.rs +++ b/extensions/test-extension/src/test_extension.rs @@ -1,6 +1,7 @@ use std::fs; use zed::lsp::CompletionKind; use zed::{CodeLabel, CodeLabelSpan, LanguageServerId}; +use zed_extension_api::process::Command; use zed_extension_api::{self as zed, Result}; struct TestExtension { @@ -13,6 +14,10 @@ impl TestExtension { language_server_id: &LanguageServerId, _worktree: &zed::Worktree, ) -> Result { + let echo_output = Command::new("echo").arg("hello!").output()?; + + println!("{}", String::from_utf8_lossy(&echo_output.stdout)); + if let Some(path) = &self.cached_binary_path { if fs::metadata(path).map_or(false, |stat| stat.is_file()) { return Ok(path.clone());