1//! Sandbox executor: the `--sandbox-exec` entry point.
2//!
3//! When Zed is invoked with `--sandbox-exec <json-config> -- <shell> [args...]`,
4//! this module takes over. It:
5//! 1. Parses the sandbox config from the JSON argument
6//! 2. Filters environment variables to the allowed set
7//! 3. Applies the OS-level sandbox (Seatbelt on macOS, Landlock on Linux)
8//! 4. Execs the real shell (never returns)
9//!
10//! This approach avoids modifying the alacritty fork — alacritty spawns the
11//! Zed binary as the "shell", and the Zed binary applies the sandbox before
12//! exec-ing the real shell. Since both Seatbelt and Landlock sandboxes are
13//! inherited by child processes, the real shell and everything it spawns
14//! are sandboxed.
15//!
16//! Note: passing JSON directly via a CLI argument is safe because
17//! `std::process::Command::arg()` passes arguments to `execve` without
18//! shell interpretation, so no quoting issues arise.
19
20use crate::{ResolvedSystemPaths, SandboxConfig};
21use std::os::unix::process::CommandExt;
22use std::process::Command;
23
24/// Serializable sandbox config for passing between processes via a JSON CLI arg.
25#[derive(serde::Serialize, serde::Deserialize)]
26pub struct SandboxExecConfig {
27 pub project_dir: String,
28 pub executable_paths: Vec<String>,
29 pub read_only_paths: Vec<String>,
30 pub read_write_paths: Vec<String>,
31 pub additional_executable_paths: Vec<String>,
32 pub additional_read_only_paths: Vec<String>,
33 pub additional_read_write_paths: Vec<String>,
34 pub allow_network: bool,
35 pub allowed_env_vars: Vec<String>,
36 /// Optional fingerprint UUID for session tracking (macOS).
37 #[serde(default)]
38 pub fingerprint_uuid: Option<String>,
39 /// Whether this is a tracking-only config (no filesystem restrictions).
40 #[serde(default)]
41 pub tracking_only: bool,
42 /// Optional cgroup path for Linux process tracking.
43 #[serde(default)]
44 pub cgroup_path: Option<String>,
45}
46
47impl SandboxExecConfig {
48 /// Convert from the resolved `SandboxConfig` to the serializable form.
49 pub fn from_sandbox_config(config: &SandboxConfig) -> Self {
50 Self {
51 project_dir: config.project_dir.to_string_lossy().into_owned(),
52 executable_paths: config
53 .system_paths
54 .executable
55 .iter()
56 .map(|p| p.to_string_lossy().into_owned())
57 .collect(),
58 read_only_paths: config
59 .system_paths
60 .read_only
61 .iter()
62 .map(|p| p.to_string_lossy().into_owned())
63 .collect(),
64 read_write_paths: config
65 .system_paths
66 .read_write
67 .iter()
68 .map(|p| p.to_string_lossy().into_owned())
69 .collect(),
70 additional_executable_paths: config
71 .additional_executable_paths
72 .iter()
73 .map(|p| p.to_string_lossy().into_owned())
74 .collect(),
75 additional_read_only_paths: config
76 .additional_read_only_paths
77 .iter()
78 .map(|p| p.to_string_lossy().into_owned())
79 .collect(),
80 additional_read_write_paths: config
81 .additional_read_write_paths
82 .iter()
83 .map(|p| p.to_string_lossy().into_owned())
84 .collect(),
85 allow_network: config.allow_network,
86 allowed_env_vars: config.allowed_env_vars.clone(),
87 fingerprint_uuid: None,
88 tracking_only: false,
89 cgroup_path: None,
90 }
91 }
92
93 /// Convert back to a `SandboxConfig` for the sandbox implementation functions.
94 pub fn to_sandbox_config(&self) -> SandboxConfig {
95 use std::path::PathBuf;
96
97 SandboxConfig {
98 project_dir: PathBuf::from(&self.project_dir),
99 system_paths: ResolvedSystemPaths {
100 executable: self.executable_paths.iter().map(PathBuf::from).collect(),
101 read_only: self.read_only_paths.iter().map(PathBuf::from).collect(),
102 read_write: self.read_write_paths.iter().map(PathBuf::from).collect(),
103 },
104 additional_executable_paths: self
105 .additional_executable_paths
106 .iter()
107 .map(PathBuf::from)
108 .collect(),
109 additional_read_only_paths: self
110 .additional_read_only_paths
111 .iter()
112 .map(PathBuf::from)
113 .collect(),
114 additional_read_write_paths: self
115 .additional_read_write_paths
116 .iter()
117 .map(PathBuf::from)
118 .collect(),
119 allow_network: self.allow_network,
120 allowed_env_vars: self.allowed_env_vars.clone(),
121 }
122 }
123
124 /// Serialize the config to a JSON string for passing via CLI arg.
125 pub fn to_json(&self) -> String {
126 serde_json::to_string(self).expect("SandboxExecConfig is non-cyclic")
127 }
128
129 /// Deserialize a config from a JSON string.
130 pub fn from_json(json: &str) -> Result<Self, String> {
131 serde_json::from_str(json).map_err(|e| format!("invalid sandbox config JSON: {e}"))
132 }
133}
134
135/// Main entry point for `zed --sandbox-exec <json-config> [-- shell args...]`.
136///
137/// This function never returns — it applies the sandbox and execs the real shell.
138/// The `shell_args` are the remaining positional arguments after `--`.
139pub fn sandbox_exec_main(config_json: &str, shell_args: &[String]) -> ! {
140 let config = match SandboxExecConfig::from_json(config_json) {
141 Ok(c) => c,
142 Err(e) => {
143 eprintln!("zed --sandbox-exec: failed to parse config: {e}");
144 std::process::exit(1);
145 }
146 };
147
148 if shell_args.is_empty() {
149 eprintln!("zed --sandbox-exec: no shell command specified");
150 std::process::exit(1);
151 }
152
153 let mut sandbox_config = config.to_sandbox_config();
154 sandbox_config.canonicalize_paths();
155
156 // Step 1: Collect allowed environment variables.
157 let zed_vars = [
158 "ZED_TERM",
159 "TERM_PROGRAM",
160 "TERM",
161 "COLORTERM",
162 "TERM_PROGRAM_VERSION",
163 ];
164 let allowed: std::collections::HashSet<&str> =
165 config.allowed_env_vars.iter().map(|s| s.as_str()).collect();
166
167 let filtered_env: Vec<(String, String)> = std::env::vars()
168 .filter(|(key, _)| allowed.contains(key.as_str()) || zed_vars.contains(&key.as_str()))
169 .collect();
170
171 // Step 2: Apply the OS-level sandbox.
172 #[cfg(target_os = "macos")]
173 {
174 if config.tracking_only {
175 if let Some(ref uuid_str) = config.fingerprint_uuid {
176 let fingerprint =
177 match crate::sandbox_macos::SessionFingerprint::from_uuid_str(uuid_str) {
178 Ok(fp) => fp,
179 Err(e) => {
180 eprintln!("zed --sandbox-exec: invalid fingerprint UUID: {e}");
181 std::process::exit(1);
182 }
183 };
184 if let Err(e) = crate::sandbox_macos::apply_fingerprint_only(&fingerprint) {
185 eprintln!("zed --sandbox-exec: failed to apply fingerprint profile: {e}");
186 std::process::exit(1);
187 }
188 }
189 } else {
190 let result = match config.fingerprint_uuid.as_ref() {
191 Some(uuid_str) => {
192 match crate::sandbox_macos::SessionFingerprint::from_uuid_str(uuid_str) {
193 Ok(fingerprint) => crate::sandbox_macos::apply_sandbox_with_fingerprint(
194 &sandbox_config,
195 &fingerprint,
196 ),
197 Err(e) => {
198 eprintln!("zed --sandbox-exec: invalid fingerprint UUID: {e}");
199 std::process::exit(1);
200 }
201 }
202 }
203 None => crate::sandbox_macos::apply_sandbox(&sandbox_config),
204 };
205 if let Err(e) = result {
206 eprintln!("zed --sandbox-exec: failed to apply macOS sandbox: {e}");
207 std::process::exit(1);
208 }
209 }
210 }
211
212 #[cfg(target_os = "linux")]
213 {
214 // Move into the session cgroup for process tracking
215 if let Some(ref cgroup_path) = config.cgroup_path {
216 let session = crate::cgroup::CgroupSession::from_path(cgroup_path);
217 let pid = unsafe { libc::getpid() };
218 if let Err(e) = session.add_process(pid) {
219 eprintln!("zed --sandbox-exec: failed to join cgroup: {e}");
220 std::process::exit(1);
221 }
222 }
223
224 // Apply Landlock restrictions (only if not tracking-only)
225 if !config.tracking_only {
226 if let Err(e) = crate::sandbox_linux::apply_sandbox(&sandbox_config) {
227 eprintln!("zed --sandbox-exec: failed to apply Linux sandbox: {e}");
228 std::process::exit(1);
229 }
230 }
231 }
232
233 // Step 3: Exec the real shell.
234 let program = &shell_args[0];
235 let args = &shell_args[1..];
236 let err = Command::new(program)
237 .args(args)
238 .env_clear()
239 .envs(filtered_env)
240 .exec();
241
242 eprintln!("zed --sandbox-exec: failed to exec {program}: {err}");
243 std::process::exit(1);
244}