From 6d6442ecd2e94b9e945ee85786cf5c30d996762a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:04:26 +0000 Subject: [PATCH 1/8] Initial plan From b2a61ace664bb41f4529399ef7d663405af1ab71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:17:13 +0000 Subject: [PATCH 2/8] Add enableSandbox setting to wrap commands in macOS seatbelt Co-authored-by: arcanis <1037931+arcanis@users.noreply.github.com> --- packages/zpm-config/schema.json | 5 +++ packages/zpm-config/src/fns.rs | 6 +++ packages/zpm/src/script.rs | 75 +++++++++++++++++++++++++++++---- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/zpm-config/schema.json b/packages/zpm-config/schema.json index 463a6670..6b0c36f8 100644 --- a/packages/zpm-config/schema.json +++ b/packages/zpm-config/schema.json @@ -60,6 +60,11 @@ "description": "Whether to show progress bars in the output", "default": "zpm_utils::is_terminal()" }, + "enableSandbox": { + "type": "boolean", + "description": "Whether to wrap commands evaluated in ScriptEnvironment in OSX's seatbelt sandbox. Enabled by default on macOS.", + "default": "crate::is_macos()" + }, "enableScripts": { "type": "boolean", "description": "Whether to run postinstall scripts", diff --git a/packages/zpm-config/src/fns.rs b/packages/zpm-config/src/fns.rs index 756a8dd3..16d39cf3 100644 --- a/packages/zpm-config/src/fns.rs +++ b/packages/zpm-config/src/fns.rs @@ -1,5 +1,11 @@ use crate::ConfigurationContext; +/// Returns true if the current operating system is macOS. +/// Used as default value for the enableSandbox setting. +pub fn is_macos() -> bool { + cfg!(target_os = "macos") +} + pub fn check_tsconfig(context: &ConfigurationContext) -> bool { if let Some(project_cwd) = &context.project_cwd { let root_has_tsconfig = project_cwd diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index 68b56ca6..45cbc98d 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -284,6 +284,7 @@ pub struct ScriptEnvironment { node_args: Vec, shell_forwarding: bool, stdin: Option, + enable_sandbox: bool, } impl ScriptEnvironment { @@ -295,6 +296,7 @@ impl ScriptEnvironment { node_args: Vec::new(), shell_forwarding: false, stdin: None, + enable_sandbox: false, }; if let Ok(val) = std::env::var("YARNSW_DETECTED_ROOT") { @@ -386,6 +388,8 @@ impl ScriptEnvironment { self.env.insert("INIT_CWD".to_string(), Some(project.project_cwd.with_join(&project.shell_cwd).to_file_string())); self.env.insert("CACHE_CWD".to_string(), Some(project.preferred_cache_path().to_file_string())); + self.enable_sandbox = project.config.settings.enable_sandbox.value; + self } @@ -501,14 +505,72 @@ impl ScriptEnvironment { Ok(dir) } - pub async fn run_exec(&mut self, program: &str, args: I) -> Result where I: IntoIterator, S: AsRef { - let mut cmd - = Command::new(program); + /// Generates a sandbox profile for macOS seatbelt. + /// The profile allows network access and restricts file system writes to specific directories. + #[cfg(target_os = "macos")] + fn generate_sandbox_profile(&self, bin_dir: &Path) -> String { + let cwd = self.cwd.to_file_string(); + let bin_dir = bin_dir.to_file_string(); + + // Get home directory for cache access + let home_dir = Path::home_dir() + .ok() + .flatten() + .map(|p| p.to_file_string()) + .unwrap_or_default(); + // Get temp directory + let temp_dir = std::env::temp_dir() + .to_string_lossy() + .to_string(); + + format!(r#"(version 1) +(allow default) +(deny file-write*) +(allow file-write* + (subpath "{cwd}") + (subpath "{bin_dir}") + (subpath "{temp_dir}") + (subpath "{home_dir}/.yarn") + (subpath "{home_dir}/.npm") + (subpath "{home_dir}/.cache") + (subpath "/private/var/folders") + (subpath "/var/folders") +) +(allow process-fork) +(allow process-exec) +"#) + } + + pub async fn run_exec(&mut self, program: &str, args: I) -> Result where I: IntoIterator, S: AsRef { let args = args.into_iter() .map(|arg| arg.as_ref().to_string()) .collect::>(); + let bin_dir + = self.install_binaries()?; + + // On macOS, wrap with sandbox-exec if sandbox is enabled + #[cfg(target_os = "macos")] + let (actual_program, actual_args) = if self.enable_sandbox { + let profile = self.generate_sandbox_profile(&bin_dir); + let mut sandbox_args = vec![ + "-p".to_string(), + profile, + program.to_string(), + ]; + sandbox_args.extend(args); + ("sandbox-exec".to_string(), sandbox_args) + } else { + (program.to_string(), args) + }; + + #[cfg(not(target_os = "macos"))] + let (actual_program, actual_args) = (program.to_string(), args); + + let mut cmd + = Command::new(&actual_program); + cmd.current_dir(self.cwd.to_path_buf()); for (key, value) in &self.env { @@ -523,9 +585,6 @@ impl ScriptEnvironment { }; } - let bin_dir - = self.install_binaries()?; - let env_path = self.env.get("PATH") .cloned() .unwrap_or_else(|| std::env::var("PATH").ok()) @@ -544,7 +603,7 @@ impl ScriptEnvironment { cmd.env("PATH", next_env_path); cmd.env("BERRY_BIN_FOLDER", bin_dir.to_file_string()); - cmd.args(&args); + cmd.args(&actual_args); if self.stdin.is_some() { cmd.stdin(std::process::Stdio::piped()); @@ -557,7 +616,7 @@ impl ScriptEnvironment { let mut child = cmd.spawn() - .map_err(|e| Error::SpawnFailed(program.to_string(), self.cwd.clone(), Arc::new(Box::new(e))))?; + .map_err(|e| Error::SpawnFailed(actual_program.clone(), self.cwd.clone(), Arc::new(Box::new(e))))?; if let Some(stdin) = &self.stdin { if let Some(mut child_stdin) = child.stdin.take() { From c49a3e150661fc7c29d854eeba48a66ef8660da8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:20:50 +0000 Subject: [PATCH 3/8] Address code review: use macOS naming, fix security issue with empty home_dir Co-authored-by: arcanis <1037931+arcanis@users.noreply.github.com> --- packages/zpm-config/schema.json | 2 +- packages/zpm/src/script.rs | 32 ++++++++++++++++++++------------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/zpm-config/schema.json b/packages/zpm-config/schema.json index 6b0c36f8..02994c2f 100644 --- a/packages/zpm-config/schema.json +++ b/packages/zpm-config/schema.json @@ -62,7 +62,7 @@ }, "enableSandbox": { "type": "boolean", - "description": "Whether to wrap commands evaluated in ScriptEnvironment in OSX's seatbelt sandbox. Enabled by default on macOS.", + "description": "Whether to wrap commands evaluated in ScriptEnvironment in macOS's seatbelt sandbox. Enabled by default on macOS.", "default": "crate::is_macos()" }, "enableScripts": { diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index 45cbc98d..f30be42b 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -515,31 +515,39 @@ impl ScriptEnvironment { // Get home directory for cache access let home_dir = Path::home_dir() .ok() - .flatten() - .map(|p| p.to_file_string()) - .unwrap_or_default(); + .and_then(|opt| opt) + .map(|p| p.to_file_string()); // Get temp directory let temp_dir = std::env::temp_dir() .to_string_lossy() .to_string(); + // Build the list of allowed write paths + let mut write_paths = vec![ + format!(" (subpath \"{cwd}\")"), + format!(" (subpath \"{bin_dir}\")"), + format!(" (subpath \"{temp_dir}\")"), + " (subpath \"/private/var/folders\")".to_string(), + " (subpath \"/var/folders\")".to_string(), + ]; + + // Only add home directory paths if home_dir is valid + if let Some(ref home) = home_dir { + write_paths.push(format!(" (subpath \"{home}/.yarn\")")); + write_paths.push(format!(" (subpath \"{home}/.npm\")")); + write_paths.push(format!(" (subpath \"{home}/.cache\")")); + } + format!(r#"(version 1) (allow default) (deny file-write*) (allow file-write* - (subpath "{cwd}") - (subpath "{bin_dir}") - (subpath "{temp_dir}") - (subpath "{home_dir}/.yarn") - (subpath "{home_dir}/.npm") - (subpath "{home_dir}/.cache") - (subpath "/private/var/folders") - (subpath "/var/folders") +{} ) (allow process-fork) (allow process-exec) -"#) +"#, write_paths.join("\n")) } pub async fn run_exec(&mut self, program: &str, args: I) -> Result where I: IntoIterator, S: AsRef { From c4195ede98926610c23e55d75b8fa4273d769527 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 12:28:16 +0000 Subject: [PATCH 4/8] Make sandbox more restrictive: project folder read-write, global folder read-only Co-authored-by: arcanis <1037931+arcanis@users.noreply.github.com> --- packages/zpm/src/script.rs | 83 +++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index f30be42b..a0009c27 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -285,6 +285,8 @@ pub struct ScriptEnvironment { shell_forwarding: bool, stdin: Option, enable_sandbox: bool, + project_cwd: Option, + global_folder: Option, } impl ScriptEnvironment { @@ -297,6 +299,8 @@ impl ScriptEnvironment { shell_forwarding: false, stdin: None, enable_sandbox: false, + project_cwd: None, + global_folder: None, }; if let Ok(val) = std::env::var("YARNSW_DETECTED_ROOT") { @@ -389,6 +393,8 @@ impl ScriptEnvironment { self.env.insert("CACHE_CWD".to_string(), Some(project.preferred_cache_path().to_file_string())); self.enable_sandbox = project.config.settings.enable_sandbox.value; + self.project_cwd = Some(project.project_cwd.clone()); + self.global_folder = Some(project.config.settings.global_folder.value.clone()); self } @@ -506,48 +512,49 @@ impl ScriptEnvironment { } /// Generates a sandbox profile for macOS seatbelt. - /// The profile allows network access and restricts file system writes to specific directories. + /// The profile is restrictive by default: + /// - Project folder (project_cwd) is allowed read-write + /// - Yarn global folder is allowed read-only + /// - All other file operations are denied by default #[cfg(target_os = "macos")] - fn generate_sandbox_profile(&self, bin_dir: &Path) -> String { - let cwd = self.cwd.to_file_string(); - let bin_dir = bin_dir.to_file_string(); - - // Get home directory for cache access - let home_dir = Path::home_dir() - .ok() - .and_then(|opt| opt) - .map(|p| p.to_file_string()); + fn generate_sandbox_profile(&self) -> String { + // Get project_cwd for read-write access + let project_cwd = self.project_cwd + .as_ref() + .map(|p| p.to_file_string()) + .unwrap_or_else(|| self.cwd.to_file_string()); - // Get temp directory - let temp_dir = std::env::temp_dir() - .to_string_lossy() - .to_string(); - - // Build the list of allowed write paths - let mut write_paths = vec![ - format!(" (subpath \"{cwd}\")"), - format!(" (subpath \"{bin_dir}\")"), - format!(" (subpath \"{temp_dir}\")"), - " (subpath \"/private/var/folders\")".to_string(), - " (subpath \"/var/folders\")".to_string(), - ]; - - // Only add home directory paths if home_dir is valid - if let Some(ref home) = home_dir { - write_paths.push(format!(" (subpath \"{home}/.yarn\")")); - write_paths.push(format!(" (subpath \"{home}/.npm\")")); - write_paths.push(format!(" (subpath \"{home}/.cache\")")); - } + // Get global_folder for read-only access + let global_folder = self.global_folder + .as_ref() + .map(|p| p.to_file_string()); - format!(r#"(version 1) -(allow default) -(deny file-write*) -(allow file-write* -{} -) + let mut profile = String::from(r#"(version 1) +(deny default) (allow process-fork) (allow process-exec) -"#, write_paths.join("\n")) +(allow sysctl-read) +(allow mach-lookup) +(allow signal) +(allow ipc-posix*) +"#); + + // Allow read-write access to project folder + profile.push_str(&format!(r#" +; Allow read-write access to project folder +(allow file-read* (subpath "{}")) +(allow file-write* (subpath "{}")) +"#, project_cwd, project_cwd)); + + // Allow read-only access to Yarn global folder + if let Some(ref global) = global_folder { + profile.push_str(&format!(r#" +; Allow read-only access to Yarn global folder +(allow file-read* (subpath "{}")) +"#, global)); + } + + profile } pub async fn run_exec(&mut self, program: &str, args: I) -> Result where I: IntoIterator, S: AsRef { @@ -561,7 +568,7 @@ impl ScriptEnvironment { // On macOS, wrap with sandbox-exec if sandbox is enabled #[cfg(target_os = "macos")] let (actual_program, actual_args) = if self.enable_sandbox { - let profile = self.generate_sandbox_profile(&bin_dir); + let profile = self.generate_sandbox_profile(); let mut sandbox_args = vec![ "-p".to_string(), profile, From 44f8fdd7e0a4a7de5ce3afb2fd624d39180ee7fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 12:30:00 +0000 Subject: [PATCH 5/8] Add path escaping for sandbox profile to handle special characters Co-authored-by: arcanis <1037931+arcanis@users.noreply.github.com> --- packages/zpm/src/script.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index a0009c27..1adcecd5 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -511,6 +511,13 @@ impl ScriptEnvironment { Ok(dir) } + /// Escapes a path for use in a seatbelt sandbox profile. + /// Escapes backslashes and double quotes to prevent profile syntax errors. + #[cfg(target_os = "macos")] + fn escape_sandbox_path(path: &str) -> String { + path.replace('\\', "\\\\").replace('"', "\\\"") + } + /// Generates a sandbox profile for macOS seatbelt. /// The profile is restrictive by default: /// - Project folder (project_cwd) is allowed read-write @@ -519,15 +526,17 @@ impl ScriptEnvironment { #[cfg(target_os = "macos")] fn generate_sandbox_profile(&self) -> String { // Get project_cwd for read-write access + // Falls back to cwd if project_cwd is not set (e.g., when running outside a project context) let project_cwd = self.project_cwd .as_ref() .map(|p| p.to_file_string()) .unwrap_or_else(|| self.cwd.to_file_string()); + let project_cwd = Self::escape_sandbox_path(&project_cwd); // Get global_folder for read-only access let global_folder = self.global_folder .as_ref() - .map(|p| p.to_file_string()); + .map(|p| Self::escape_sandbox_path(&p.to_file_string())); let mut profile = String::from(r#"(version 1) (deny default) From 2332d3bc994f6dfcd89c0e5217f7c6437449c538 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 12:38:27 +0000 Subject: [PATCH 6/8] Add inline documentation for sandbox allowed actions Co-authored-by: arcanis <1037931+arcanis@users.noreply.github.com> --- packages/zpm/src/script.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index 1adcecd5..54ca324a 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -538,14 +538,15 @@ impl ScriptEnvironment { .as_ref() .map(|p| Self::escape_sandbox_path(&p.to_file_string())); + // Base sandbox profile: deny all by default, then allow specific operations needed for script execution let mut profile = String::from(r#"(version 1) (deny default) -(allow process-fork) -(allow process-exec) -(allow sysctl-read) -(allow mach-lookup) -(allow signal) -(allow ipc-posix*) +(allow process-fork) ; Allow forking child processes (required for running scripts) +(allow process-exec) ; Allow executing programs (required for running binaries) +(allow sysctl-read) ; Allow reading system configuration (required by Node.js) +(allow mach-lookup) ; Allow Mach IPC service lookups (required for system services on macOS) +(allow signal) ; Allow sending/receiving POSIX signals between processes +(allow ipc-posix*) ; Allow POSIX IPC: pipes, shared memory, semaphores (required for process communication) "#); // Allow read-write access to project folder From 14766ef9ff8261f8a22662630a1c484cd6abf630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Tue, 2 Dec 2025 00:11:18 +0100 Subject: [PATCH 7/8] Fixes permissions --- packages/zpm/src/script.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index 54ca324a..b0cd598e 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -520,8 +520,10 @@ impl ScriptEnvironment { /// Generates a sandbox profile for macOS seatbelt. /// The profile is restrictive by default: + /// - System directories are allowed read-only (for binaries and libraries) /// - Project folder (project_cwd) is allowed read-write /// - Yarn global folder is allowed read-only + /// - User's .yarn directory is allowed read-only (for wrapper scripts in bin_dir) /// - All other file operations are denied by default #[cfg(target_os = "macos")] fn generate_sandbox_profile(&self) -> String { @@ -538,6 +540,12 @@ impl ScriptEnvironment { .as_ref() .map(|p| Self::escape_sandbox_path(&p.to_file_string())); + // Get home directory for .yarn folder access (bin_dir is under ~/.yarn/zpm/binaries/) + let home_yarn_folder = Path::home_dir() + .ok() + .flatten() + .map(|p| Self::escape_sandbox_path(&p.with_join_str(".yarn").to_file_string())); + // Base sandbox profile: deny all by default, then allow specific operations needed for script execution let mut profile = String::from(r#"(version 1) (deny default) @@ -547,6 +555,24 @@ impl ScriptEnvironment { (allow mach-lookup) ; Allow Mach IPC service lookups (required for system services on macOS) (allow signal) ; Allow sending/receiving POSIX signals between processes (allow ipc-posix*) ; Allow POSIX IPC: pipes, shared memory, semaphores (required for process communication) + +; Allow reading root directory (required for path resolution during process startup) +(allow file-read* (literal "/")) + +; Allow read-only access to system directories (required for binaries, libraries, and shebang processing) +(allow file-read* (subpath "/bin")) +(allow file-read* (subpath "/usr/bin")) +(allow file-read* (subpath "/usr/lib")) +(allow file-read* (subpath "/usr/local")) +(allow file-read* (subpath "/usr/share")) +(allow file-read* (subpath "/System")) +(allow file-read* (subpath "/Library")) +(allow file-read* (subpath "/private/var")) +(allow file-read* (subpath "/var")) +(allow file-read* (subpath "/private/tmp")) +(allow file-read* (subpath "/tmp")) +(allow file-read* (subpath "/dev")) +(allow file-write* (subpath "/dev")) "#); // Allow read-write access to project folder @@ -564,6 +590,14 @@ impl ScriptEnvironment { "#, global)); } + // Allow read-only access to ~/.yarn for wrapper scripts (bin_dir is under ~/.yarn/zpm/binaries/) + if let Some(ref yarn_folder) = home_yarn_folder { + profile.push_str(&format!(r#" +; Allow read-only access to user's .yarn folder (for wrapper scripts in bin_dir) +(allow file-read* (subpath "{}")) +"#, yarn_folder)); + } + profile } From 022e9fea37c1859c9f032d26a0c704f3720aa7af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 13:40:53 +0000 Subject: [PATCH 8/8] Make enableSandbox default to false and add -s,--sandbox flag to yarn run Co-authored-by: arcanis <1037931+arcanis@users.noreply.github.com> --- packages/zpm-config/schema.json | 4 ++-- packages/zpm/src/commands/run.rs | 26 ++++++++++++++++++++------ packages/zpm/src/script.rs | 5 +++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/zpm-config/schema.json b/packages/zpm-config/schema.json index 02994c2f..a6de0bd4 100644 --- a/packages/zpm-config/schema.json +++ b/packages/zpm-config/schema.json @@ -62,8 +62,8 @@ }, "enableSandbox": { "type": "boolean", - "description": "Whether to wrap commands evaluated in ScriptEnvironment in macOS's seatbelt sandbox. Enabled by default on macOS.", - "default": "crate::is_macos()" + "description": "Whether to wrap commands evaluated in ScriptEnvironment in macOS's seatbelt sandbox.", + "default": false }, "enableScripts": { "type": "boolean", diff --git a/packages/zpm/src/commands/run.rs b/packages/zpm/src/commands/run.rs index dbfaf265..a01f567b 100644 --- a/packages/zpm/src/commands/run.rs +++ b/packages/zpm/src/commands/run.rs @@ -55,6 +55,10 @@ pub struct Run { #[cli::option("--require")] require: Option, + /// If set, wrap the command in macOS's seatbelt sandbox (macOS only) + #[cli::option("-s,--sandbox", default = false)] + sandbox: bool, + /// Name of the script or binary to run name: String, @@ -110,12 +114,17 @@ impl Run { = project.find_binary(&self.name); if let Ok(binary) = maybe_binary { - Ok(ScriptEnvironment::new()? + let mut env = ScriptEnvironment::new()? .with_project(&project) .with_package(&project, &project.active_package()?)? .with_node_args(get_node_args()) - .enable_shell_forwarding() - .run_binary(&binary, &self.args) + .enable_shell_forwarding(); + + if self.sandbox { + env = env.enable_sandbox(); + } + + Ok(env.run_binary(&binary, &self.args) .await? .into()) } else if let Err(Error::BinaryNotFound(name)) = maybe_binary { @@ -146,11 +155,16 @@ impl Run { return Err(Error::InvalidRunScriptOptions(node_args)); } - Ok(ScriptEnvironment::new()? + let mut env = ScriptEnvironment::new()? .with_project(&project) .with_package(&project, &locator)? - .enable_shell_forwarding() - .run_script(&script, &self.args) + .enable_shell_forwarding(); + + if self.sandbox { + env = env.enable_sandbox(); + } + + Ok(env.run_script(&script, &self.args) .await? .into()) }, diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index b0cd598e..774d8610 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -372,6 +372,11 @@ impl ScriptEnvironment { self } + pub fn enable_sandbox(mut self) -> Self { + self.enable_sandbox = true; + self + } + pub fn with_stdin(mut self, stdin: Option) -> Self { self.stdin = stdin; self