extension_host: Add `npm:install` capability (#35144)

Marshall Bowers created

This PR adds a new `npm:install` capability for installing npm packges
in extensions.

Currently all npm packages are allowed.

Release Notes:

- N/A

Change summary

crates/extension/src/capabilities.rs                                |  4 
crates/extension/src/capabilities/npm_install_package_capability.rs | 39 
crates/extension_host/src/capability_granter.rs                     | 18 
crates/extension_host/src/wasm_host.rs                              |  7 
crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs             |  3 
5 files changed, 69 insertions(+), 2 deletions(-)

Detailed changes

crates/extension/src/capabilities.rs 🔗

@@ -1,7 +1,9 @@
 mod download_file_capability;
+mod npm_install_package_capability;
 mod process_exec_capability;
 
 pub use download_file_capability::*;
+pub use npm_install_package_capability::*;
 pub use process_exec_capability::*;
 
 use serde::{Deserialize, Serialize};
@@ -13,4 +15,6 @@ pub enum ExtensionCapability {
     #[serde(rename = "process:exec")]
     ProcessExec(ProcessExecCapability),
     DownloadFile(DownloadFileCapability),
+    #[serde(rename = "npm:install")]
+    NpmInstallPackage(NpmInstallPackageCapability),
 }

crates/extension/src/capabilities/npm_install_package_capability.rs 🔗

@@ -0,0 +1,39 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct NpmInstallPackageCapability {
+    pub package: String,
+}
+
+impl NpmInstallPackageCapability {
+    /// Returns whether the capability allows installing the given NPM package.
+    pub fn allows(&self, package: &str) -> bool {
+        self.package == "*" || self.package == package
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use pretty_assertions::assert_eq;
+
+    use super::*;
+
+    #[test]
+    fn test_allows() {
+        let capability = NpmInstallPackageCapability {
+            package: "*".to_string(),
+        };
+        assert_eq!(capability.allows("package"), true);
+
+        let capability = NpmInstallPackageCapability {
+            package: "react".to_string(),
+        };
+        assert_eq!(capability.allows("react"), true);
+
+        let capability = NpmInstallPackageCapability {
+            package: "react".to_string(),
+        };
+        assert_eq!(capability.allows("malicious-package"), false);
+    }
+}

crates/extension_host/src/capability_granter.rs 🔗

@@ -63,6 +63,24 @@ impl CapabilityGranter {
 
         Ok(())
     }
+
+    pub fn grant_npm_install_package(&self, package_name: &str) -> Result<()> {
+        let is_allowed = self
+            .granted_capabilities
+            .iter()
+            .any(|capability| match capability {
+                ExtensionCapability::NpmInstallPackage(capability) => {
+                    capability.allows(package_name)
+                }
+                _ => false,
+            });
+
+        if !is_allowed {
+            bail!("capability for npm:install {package_name} is not granted by the extension host",);
+        }
+
+        Ok(())
+    }
 }
 
 #[cfg(test)]

crates/extension_host/src/wasm_host.rs 🔗

@@ -8,8 +8,8 @@ use dap::{DebugRequest, StartDebuggingRequestArgumentsRequest};
 use extension::{
     CodeLabel, Command, Completion, ContextServerConfiguration, DebugAdapterBinary,
     DebugTaskDefinition, DownloadFileCapability, ExtensionCapability, ExtensionHostProxy,
-    KeyValueStoreDelegate, ProcessExecCapability, ProjectDelegate, SlashCommand,
-    SlashCommandArgumentCompletion, SlashCommandOutput, Symbol, WorktreeDelegate,
+    KeyValueStoreDelegate, NpmInstallPackageCapability, ProcessExecCapability, ProjectDelegate,
+    SlashCommand, SlashCommandArgumentCompletion, SlashCommandOutput, Symbol, WorktreeDelegate,
 };
 use fs::{Fs, normalize_path};
 use futures::future::LocalBoxFuture;
@@ -585,6 +585,9 @@ impl WasmHost {
                     host: "*".to_string(),
                     path: vec!["**".to_string()],
                 }),
+                ExtensionCapability::NpmInstallPackage(NpmInstallPackageCapability {
+                    package: "*".to_string(),
+                }),
             ],
             _main_thread_message_task: task,
             main_thread_message_tx: tx,

crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs 🔗

@@ -745,6 +745,9 @@ impl nodejs::Host for WasmState {
         package_name: String,
         version: String,
     ) -> wasmtime::Result<Result<(), String>> {
+        self.capability_granter
+            .grant_npm_install_package(&package_name)?;
+
         self.host
             .node_runtime
             .npm_install_packages(&self.work_dir(), &[(&package_name, &version)])