extension_host: Refactor capability checks (#35139)

This PR refactors the extension capability checks to be centralized in
the `CapabilityGranter`.

Release Notes:

- N/A
This commit is contained in:
Marshall Bowers 2025-07-26 16:53:19 -04:00 committed by GitHub
parent 290f84a9e1
commit d7b403e981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 193 additions and 41 deletions

View file

@ -100,26 +100,9 @@ impl ExtensionManifest {
desired_args: &[impl AsRef<str> + std::fmt::Debug], desired_args: &[impl AsRef<str> + std::fmt::Debug],
) -> Result<()> { ) -> Result<()> {
let is_allowed = self.capabilities.iter().any(|capability| match capability { let is_allowed = self.capabilities.iter().any(|capability| match capability {
ExtensionCapability::ProcessExec { command, args } if command == desired_command => { ExtensionCapability::ProcessExec(capability) => {
for (ix, arg) in args.iter().enumerate() { capability.allows(desired_command, desired_args)
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 { if !is_allowed {
@ -153,13 +136,50 @@ pub fn build_debug_adapter_schema_path(
#[serde(tag = "kind")] #[serde(tag = "kind")]
pub enum ExtensionCapability { pub enum ExtensionCapability {
#[serde(rename = "process:exec")] #[serde(rename = "process:exec")]
ProcessExec { ProcessExec(ProcessExecCapability),
/// The command to execute. }
command: String,
/// The arguments to pass to the command. Use `*` for a single wildcard argument. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
/// If the last element is `**`, then any trailing arguments are allowed. #[serde(rename_all = "snake_case")]
args: Vec<String>, pub struct ProcessExecCapability {
}, /// The command to execute.
pub 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.
pub args: Vec<String>,
}
impl ProcessExecCapability {
/// Returns whether the capability allows the given command and arguments.
pub fn allows(
&self,
desired_command: &str,
desired_args: &[impl AsRef<str> + std::fmt::Debug],
) -> bool {
if self.command != desired_command && self.command != "*" {
return false;
}
for (ix, arg) in self.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 self.args.len() < desired_args.len() {
return false;
}
true
}
} }
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)] #[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
@ -362,10 +382,10 @@ mod tests {
#[test] #[test]
fn test_allow_exact_match() { fn test_allow_exact_match() {
let manifest = ExtensionManifest { let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec { capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "ls".to_string(), command: "ls".to_string(),
args: vec!["-la".to_string()], args: vec!["-la".to_string()],
}], })],
..extension_manifest() ..extension_manifest()
}; };
@ -377,10 +397,10 @@ mod tests {
#[test] #[test]
fn test_allow_wildcard_arg() { fn test_allow_wildcard_arg() {
let manifest = ExtensionManifest { let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec { capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "git".to_string(), command: "git".to_string(),
args: vec!["*".to_string()], args: vec!["*".to_string()],
}], })],
..extension_manifest() ..extension_manifest()
}; };
@ -393,10 +413,10 @@ mod tests {
#[test] #[test]
fn test_allow_double_wildcard() { fn test_allow_double_wildcard() {
let manifest = ExtensionManifest { let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec { capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "cargo".to_string(), command: "cargo".to_string(),
args: vec!["test".to_string(), "**".to_string()], args: vec!["test".to_string(), "**".to_string()],
}], })],
..extension_manifest() ..extension_manifest()
}; };
@ -413,10 +433,10 @@ mod tests {
#[test] #[test]
fn test_allow_mixed_wildcards() { fn test_allow_mixed_wildcards() {
let manifest = ExtensionManifest { let manifest = ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec { capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "docker".to_string(), command: "docker".to_string(),
args: vec!["run".to_string(), "*".to_string(), "**".to_string()], args: vec!["run".to_string(), "*".to_string(), "**".to_string()],
}], })],
..extension_manifest() ..extension_manifest()
}; };

View file

@ -134,10 +134,12 @@ fn manifest() -> ExtensionManifest {
slash_commands: BTreeMap::default(), slash_commands: BTreeMap::default(),
indexed_docs_providers: BTreeMap::default(), indexed_docs_providers: BTreeMap::default(),
snippets: None, snippets: None,
capabilities: vec![ExtensionCapability::ProcessExec { capabilities: vec![ExtensionCapability::ProcessExec(
command: "echo".into(), extension::ProcessExecCapability {
args: vec!["hello!".into()], command: "echo".into(),
}], args: vec!["hello!".into()],
},
)],
debug_adapters: Default::default(), debug_adapters: Default::default(),
debug_locators: Default::default(), debug_locators: Default::default(),
} }

View file

@ -0,0 +1,115 @@
use std::sync::Arc;
use anyhow::{Result, bail};
use extension::{ExtensionCapability, ExtensionManifest};
pub struct CapabilityGranter {
granted_capabilities: Vec<ExtensionCapability>,
manifest: Arc<ExtensionManifest>,
}
impl CapabilityGranter {
pub fn new(
granted_capabilities: Vec<ExtensionCapability>,
manifest: Arc<ExtensionManifest>,
) -> Self {
Self {
granted_capabilities,
manifest,
}
}
pub fn grant_exec(
&self,
desired_command: &str,
desired_args: &[impl AsRef<str> + std::fmt::Debug],
) -> Result<()> {
self.manifest.allow_exec(desired_command, desired_args)?;
let is_allowed = self
.granted_capabilities
.iter()
.any(|capability| match capability {
ExtensionCapability::ProcessExec(capability) => {
capability.allows(desired_command, desired_args)
}
});
if !is_allowed {
bail!(
"capability for process:exec {desired_command} {desired_args:?} is not granted by the extension host",
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use extension::{ProcessExecCapability, SchemaVersion};
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![],
debug_adapters: Default::default(),
debug_locators: Default::default(),
}
}
#[test]
fn test_grant_exec() {
let manifest = Arc::new(ExtensionManifest {
capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "ls".to_string(),
args: vec!["-la".to_string()],
})],
..extension_manifest()
});
// It returns an error when the extension host has no granted capabilities.
let granter = CapabilityGranter::new(Vec::new(), manifest.clone());
assert!(granter.grant_exec("ls", &["-la"]).is_err());
// It succeeds when the extension host has the exact capability.
let granter = CapabilityGranter::new(
vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "ls".to_string(),
args: vec!["-la".to_string()],
})],
manifest.clone(),
);
assert!(granter.grant_exec("ls", &["-la"]).is_ok());
// It succeeds when the extension host has a wildcard capability.
let granter = CapabilityGranter::new(
vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "*".to_string(),
args: vec!["**".to_string()],
})],
manifest.clone(),
);
assert!(granter.grant_exec("ls", &["-la"]).is_ok());
}
}

View file

@ -1,3 +1,4 @@
mod capability_granter;
pub mod extension_settings; pub mod extension_settings;
pub mod headless_host; pub mod headless_host;
pub mod wasm_host; pub mod wasm_host;

View file

@ -1,13 +1,15 @@
pub mod wit; pub mod wit;
use crate::ExtensionManifest; use crate::ExtensionManifest;
use crate::capability_granter::CapabilityGranter;
use anyhow::{Context as _, Result, anyhow, bail}; use anyhow::{Context as _, Result, anyhow, bail};
use async_trait::async_trait; use async_trait::async_trait;
use dap::{DebugRequest, StartDebuggingRequestArgumentsRequest}; use dap::{DebugRequest, StartDebuggingRequestArgumentsRequest};
use extension::{ use extension::{
CodeLabel, Command, Completion, ContextServerConfiguration, DebugAdapterBinary, CodeLabel, Command, Completion, ContextServerConfiguration, DebugAdapterBinary,
DebugTaskDefinition, ExtensionHostProxy, KeyValueStoreDelegate, ProjectDelegate, SlashCommand, DebugTaskDefinition, ExtensionCapability, ExtensionHostProxy, KeyValueStoreDelegate,
SlashCommandArgumentCompletion, SlashCommandOutput, Symbol, WorktreeDelegate, ProcessExecCapability, ProjectDelegate, SlashCommand, SlashCommandArgumentCompletion,
SlashCommandOutput, Symbol, WorktreeDelegate,
}; };
use fs::{Fs, normalize_path}; use fs::{Fs, normalize_path};
use futures::future::LocalBoxFuture; use futures::future::LocalBoxFuture;
@ -50,6 +52,8 @@ pub struct WasmHost {
pub(crate) proxy: Arc<ExtensionHostProxy>, pub(crate) proxy: Arc<ExtensionHostProxy>,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
pub work_dir: PathBuf, pub work_dir: PathBuf,
/// The capabilities granted to extensions running on the host.
pub(crate) granted_capabilities: Vec<ExtensionCapability>,
_main_thread_message_task: Task<()>, _main_thread_message_task: Task<()>,
main_thread_message_tx: mpsc::UnboundedSender<MainThreadCall>, main_thread_message_tx: mpsc::UnboundedSender<MainThreadCall>,
} }
@ -486,6 +490,7 @@ pub struct WasmState {
pub table: ResourceTable, pub table: ResourceTable,
ctx: wasi::WasiCtx, ctx: wasi::WasiCtx,
pub host: Arc<WasmHost>, pub host: Arc<WasmHost>,
pub(crate) capability_granter: CapabilityGranter,
} }
type MainThreadCall = Box<dyn Send + for<'a> FnOnce(&'a mut AsyncApp) -> LocalBoxFuture<'a, ()>>; type MainThreadCall = Box<dyn Send + for<'a> FnOnce(&'a mut AsyncApp) -> LocalBoxFuture<'a, ()>>;
@ -571,6 +576,10 @@ impl WasmHost {
node_runtime, node_runtime,
proxy, proxy,
release_channel: ReleaseChannel::global(cx), release_channel: ReleaseChannel::global(cx),
granted_capabilities: vec![ExtensionCapability::ProcessExec(ProcessExecCapability {
command: "*".to_string(),
args: vec!["**".to_string()],
})],
_main_thread_message_task: task, _main_thread_message_task: task,
main_thread_message_tx: tx, main_thread_message_tx: tx,
}) })
@ -597,6 +606,10 @@ impl WasmHost {
manifest: manifest.clone(), manifest: manifest.clone(),
table: ResourceTable::new(), table: ResourceTable::new(),
host: this.clone(), host: this.clone(),
capability_granter: CapabilityGranter::new(
this.granted_capabilities.clone(),
manifest.clone(),
),
}, },
); );
// Store will yield after 1 tick, and get a new deadline of 1 tick after each yield. // Store will yield after 1 tick, and get a new deadline of 1 tick after each yield.

View file

@ -847,7 +847,8 @@ impl process::Host for WasmState {
command: process::Command, command: process::Command,
) -> wasmtime::Result<Result<process::Output, String>> { ) -> wasmtime::Result<Result<process::Output, String>> {
maybe!(async { maybe!(async {
self.manifest.allow_exec(&command.command, &command.args)?; self.capability_granter
.grant_exec(&command.command, &command.args)?;
let output = util::command::new_smol_command(command.command.as_str()) let output = util::command::new_smol_command(command.command.as_str())
.args(&command.args) .args(&command.args)