extension: Add capabilities for the process API (#26224)

Marshall Bowers and Conrad Irwin created

This PR adds support for capabilities for the extension process API.

In order to use the process API, an extension must declare which
commands it wants to use, with arguments:

```toml
[[capabilities]]
kind = "process:exec"
command = "echo"
args = ["hello!"]
```

A `*` can be used to denote a single wildcard in the argument list:

```toml
[[capabilities]]
kind = "process:exec"
command = "echo"
args = ["*"]
```

And `**` can be used to denote a wildcard for the remaining arguments:

```toml
[[capabilities]]
kind = "process:exec"
command = "ls"
args = ["-a", "**"]
```

Release Notes:

- N/A

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/extension/src/extension_manifest.rs              | 156 ++++++++++
crates/extension_host/src/extension_store_test.rs       |   3 
crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs |   2 
extensions/test-extension/extension.toml                |   5 
extensions/test-extension/src/test_extension.rs         |   5 
5 files changed, 170 insertions(+), 1 deletion(-)

Detailed changes

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<Arc<str>, IndexedDocsProviderEntry>,
     #[serde(default)]
     pub snippets: Option<PathBuf>,
+    #[serde(default)]
+    pub capabilities: Vec<ExtensionCapability>,
+}
+
+impl ExtensionManifest {
+    pub fn allow_exec(
+        &self,
+        desired_command: &str,
+        desired_args: &[impl AsRef<str> + 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<String>,
+    },
 }
 
 #[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
     }
 }

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,
         },

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<Result<process::Output, String>> {
         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)

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!"]

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<String> {
+        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());