Skip to main content

zerobox_protocol/
protocol.rs

1use std::collections::HashSet;
2use std::ffi::OsStr;
3use std::path::Path;
4use std::path::PathBuf;
5use std::str::FromStr;
6
7use schemars::JsonSchema;
8use serde::Deserialize;
9use serde::Serialize;
10use strum_macros::Display;
11use tracing::error;
12use ts_rs::TS;
13use zerobox_utils_absolute_path::AbsolutePathBuf;
14
15pub use crate::permissions::FileSystemAccessMode;
16pub use crate::permissions::FileSystemPath;
17pub use crate::permissions::FileSystemSandboxEntry;
18pub use crate::permissions::FileSystemSandboxKind;
19pub use crate::permissions::FileSystemSandboxPolicy;
20pub use crate::permissions::FileSystemSpecialPath;
21pub use crate::permissions::NetworkSandboxPolicy;
22
23#[derive(
24    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
25)]
26#[serde(rename_all = "kebab-case")]
27#[strum(serialize_all = "kebab-case")]
28pub enum NetworkAccess {
29    #[default]
30    Restricted,
31    Enabled,
32}
33
34impl NetworkAccess {
35    pub fn is_enabled(self) -> bool {
36        matches!(self, NetworkAccess::Enabled)
37    }
38}
39fn default_include_platform_defaults() -> bool {
40    true
41}
42
43/// Determines how read-only file access is granted inside a restricted
44/// sandbox.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS)]
46#[strum(serialize_all = "kebab-case")]
47#[serde(tag = "type", rename_all = "kebab-case")]
48#[ts(tag = "type")]
49pub enum ReadOnlyAccess {
50    /// Restrict reads to an explicit set of roots.
51    ///
52    /// When `include_platform_defaults` is `true`, platform defaults required
53    /// for basic execution are included in addition to `readable_roots`.
54    Restricted {
55        /// Include built-in platform read roots required for basic process
56        /// execution.
57        #[serde(default = "default_include_platform_defaults")]
58        include_platform_defaults: bool,
59        /// Additional absolute roots that should be readable.
60        #[serde(default, skip_serializing_if = "Vec::is_empty")]
61        readable_roots: Vec<AbsolutePathBuf>,
62    },
63
64    /// Allow unrestricted file reads.
65    #[default]
66    FullAccess,
67}
68
69impl ReadOnlyAccess {
70    pub fn has_full_disk_read_access(&self) -> bool {
71        matches!(self, ReadOnlyAccess::FullAccess)
72    }
73
74    /// Returns true if platform defaults should be included for restricted read access.
75    pub fn include_platform_defaults(&self) -> bool {
76        matches!(
77            self,
78            ReadOnlyAccess::Restricted {
79                include_platform_defaults: true,
80                ..
81            }
82        )
83    }
84
85    /// Returns the readable roots for restricted read access.
86    ///
87    /// For [`ReadOnlyAccess::FullAccess`], returns an empty list because
88    /// callers should grant blanket read access instead.
89    pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
90        let mut roots: Vec<AbsolutePathBuf> = match self {
91            ReadOnlyAccess::FullAccess => return Vec::new(),
92            ReadOnlyAccess::Restricted { readable_roots, .. } => {
93                let mut roots = readable_roots.clone();
94                match AbsolutePathBuf::from_absolute_path(cwd) {
95                    Ok(cwd_root) => roots.push(cwd_root),
96                    Err(err) => {
97                        error!("Ignoring invalid cwd {cwd:?} for sandbox readable root: {err}");
98                    }
99                }
100                roots
101            }
102        };
103
104        let mut seen = HashSet::new();
105        roots.retain(|root| seen.insert(root.to_path_buf()));
106        roots
107    }
108}
109
110/// Execution restrictions for sandboxed commands.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)]
112#[strum(serialize_all = "kebab-case")]
113#[serde(tag = "type", rename_all = "kebab-case")]
114pub enum SandboxPolicy {
115    /// No restrictions whatsoever. Use with caution.
116    #[serde(rename = "danger-full-access")]
117    DangerFullAccess,
118
119    /// Read-only access configuration.
120    #[serde(rename = "read-only")]
121    ReadOnly {
122        /// Read access granted while running under this policy.
123        #[serde(
124            default,
125            skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access"
126        )]
127        access: ReadOnlyAccess,
128
129        /// When set to `true`, outbound network access is allowed. `false` by
130        /// default.
131        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
132        network_access: bool,
133    },
134
135    /// Indicates the process is already in an external sandbox. Allows full
136    /// disk access while honoring the provided network setting.
137    #[serde(rename = "external-sandbox")]
138    ExternalSandbox {
139        /// Whether the external sandbox permits outbound network traffic.
140        #[serde(default)]
141        network_access: NetworkAccess,
142    },
143
144    /// Same as `ReadOnly` but additionally grants write access to the current
145    /// working directory ("workspace").
146    #[serde(rename = "workspace-write")]
147    WorkspaceWrite {
148        /// Additional folders (beyond cwd and possibly TMPDIR) that should be
149        /// writable from within the sandbox.
150        #[serde(default, skip_serializing_if = "Vec::is_empty")]
151        writable_roots: Vec<AbsolutePathBuf>,
152
153        /// Read access granted while running under this policy.
154        #[serde(
155            default,
156            skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access"
157        )]
158        read_only_access: ReadOnlyAccess,
159
160        /// When set to `true`, outbound network access is allowed. `false` by
161        /// default.
162        #[serde(default)]
163        network_access: bool,
164
165        /// When set to `true`, will NOT include the per-user `TMPDIR`
166        /// environment variable among the default writable roots. Defaults to
167        /// `false`.
168        #[serde(default)]
169        exclude_tmpdir_env_var: bool,
170
171        /// When set to `true`, will NOT include the `/tmp` among the default
172        /// writable roots on UNIX. Defaults to `false`.
173        #[serde(default)]
174        exclude_slash_tmp: bool,
175    },
176}
177
178/// A writable root path accompanied by a list of subpaths that should remain
179/// read-only even when the root is writable.
180#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
181pub struct WritableRoot {
182    pub root: AbsolutePathBuf,
183
184    /// By construction, these subpaths are all under `root`.
185    pub read_only_subpaths: Vec<AbsolutePathBuf>,
186}
187
188impl WritableRoot {
189    pub fn is_path_writable(&self, path: &Path) -> bool {
190        // Check if the path is under the root.
191        if !path.starts_with(&self.root) {
192            return false;
193        }
194
195        // Check if the path is under any of the read-only subpaths.
196        for subpath in &self.read_only_subpaths {
197            if path.starts_with(subpath) {
198                return false;
199            }
200        }
201
202        true
203    }
204}
205
206impl FromStr for SandboxPolicy {
207    type Err = serde_json::Error;
208
209    fn from_str(s: &str) -> Result<Self, Self::Err> {
210        serde_json::from_str(s)
211    }
212}
213
214impl FromStr for FileSystemSandboxPolicy {
215    type Err = serde_json::Error;
216
217    fn from_str(s: &str) -> Result<Self, Self::Err> {
218        serde_json::from_str(s)
219    }
220}
221
222impl FromStr for NetworkSandboxPolicy {
223    type Err = serde_json::Error;
224
225    fn from_str(s: &str) -> Result<Self, Self::Err> {
226        serde_json::from_str(s)
227    }
228}
229
230impl SandboxPolicy {
231    /// Returns a policy with read-only disk access and no network.
232    pub fn new_read_only_policy() -> Self {
233        SandboxPolicy::ReadOnly {
234            access: ReadOnlyAccess::FullAccess,
235            network_access: false,
236        }
237    }
238
239    /// Returns a policy that can read the entire disk, but can only write to
240    /// the current working directory and the per-user tmp dir on macOS. It does
241    /// not allow network access.
242    pub fn new_workspace_write_policy() -> Self {
243        SandboxPolicy::WorkspaceWrite {
244            writable_roots: vec![],
245            read_only_access: ReadOnlyAccess::FullAccess,
246            network_access: false,
247            exclude_tmpdir_env_var: false,
248            exclude_slash_tmp: false,
249        }
250    }
251
252    pub fn has_full_disk_read_access(&self) -> bool {
253        match self {
254            SandboxPolicy::DangerFullAccess => true,
255            SandboxPolicy::ExternalSandbox { .. } => true,
256            SandboxPolicy::ReadOnly { access, .. } => access.has_full_disk_read_access(),
257            SandboxPolicy::WorkspaceWrite {
258                read_only_access, ..
259            } => read_only_access.has_full_disk_read_access(),
260        }
261    }
262
263    pub fn has_full_disk_write_access(&self) -> bool {
264        match self {
265            SandboxPolicy::DangerFullAccess => true,
266            SandboxPolicy::ExternalSandbox { .. } => true,
267            SandboxPolicy::ReadOnly { .. } => false,
268            SandboxPolicy::WorkspaceWrite { .. } => false,
269        }
270    }
271
272    pub fn has_full_network_access(&self) -> bool {
273        match self {
274            SandboxPolicy::DangerFullAccess => true,
275            SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(),
276            SandboxPolicy::ReadOnly { network_access, .. } => *network_access,
277            SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
278        }
279    }
280
281    /// Returns true if platform defaults should be included for restricted read access.
282    pub fn include_platform_defaults(&self) -> bool {
283        if self.has_full_disk_read_access() {
284            return false;
285        }
286        match self {
287            SandboxPolicy::ReadOnly { access, .. } => access.include_platform_defaults(),
288            SandboxPolicy::WorkspaceWrite {
289                read_only_access, ..
290            } => read_only_access.include_platform_defaults(),
291            SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => false,
292        }
293    }
294
295    /// Returns the list of readable roots (tailored to the current working
296    /// directory) when read access is restricted.
297    ///
298    /// For policies with full read access, this returns an empty list because
299    /// callers should grant blanket reads.
300    pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
301        let mut roots = match self {
302            SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
303            SandboxPolicy::ReadOnly { access, .. } => access.get_readable_roots_with_cwd(cwd),
304            SandboxPolicy::WorkspaceWrite {
305                read_only_access, ..
306            } => {
307                let mut roots = read_only_access.get_readable_roots_with_cwd(cwd);
308                roots.extend(
309                    self.get_writable_roots_with_cwd(cwd)
310                        .into_iter()
311                        .map(|root| root.root),
312                );
313                roots
314            }
315        };
316        let mut seen = HashSet::new();
317        roots.retain(|root| seen.insert(root.to_path_buf()));
318        roots
319    }
320
321    /// Returns the list of writable roots (tailored to the current working
322    /// directory) together with subpaths that should remain read-only under
323    /// each writable root.
324    pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
325        match self {
326            SandboxPolicy::DangerFullAccess => Vec::new(),
327            SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
328            SandboxPolicy::ReadOnly { .. } => Vec::new(),
329            SandboxPolicy::WorkspaceWrite {
330                writable_roots,
331                read_only_access: _,
332                exclude_tmpdir_env_var,
333                exclude_slash_tmp,
334                network_access: _,
335            } => {
336                let mut roots: Vec<AbsolutePathBuf> = writable_roots.clone();
337
338                let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd);
339                match cwd_absolute {
340                    Ok(cwd) => {
341                        roots.push(cwd);
342                    }
343                    Err(e) => {
344                        error!(
345                            "Ignoring invalid cwd {:?} for sandbox writable root: {}",
346                            cwd, e
347                        );
348                    }
349                }
350
351                if cfg!(unix) && !exclude_slash_tmp {
352                    #[allow(clippy::expect_used)]
353                    let slash_tmp =
354                        AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
355                    if slash_tmp.as_path().is_dir() {
356                        roots.push(slash_tmp);
357                    }
358                }
359
360                if !exclude_tmpdir_env_var
361                    && let Some(tmpdir) = std::env::var_os("TMPDIR")
362                    && !tmpdir.is_empty()
363                {
364                    match AbsolutePathBuf::from_absolute_path(PathBuf::from(&tmpdir)) {
365                        Ok(tmpdir_path) => {
366                            roots.push(tmpdir_path);
367                        }
368                        Err(e) => {
369                            error!(
370                                "Ignoring invalid TMPDIR value {tmpdir:?} for sandbox writable root: {e}",
371                            );
372                        }
373                    }
374                }
375
376                let cwd_root = AbsolutePathBuf::from_absolute_path(cwd).ok();
377                roots
378                    .into_iter()
379                    .map(|writable_root| {
380                        let protect_missing_dot_codex = cwd_root
381                            .as_ref()
382                            .is_some_and(|cwd_root| cwd_root == &writable_root);
383                        WritableRoot {
384                            read_only_subpaths: default_read_only_subpaths_for_writable_root(
385                                &writable_root,
386                                protect_missing_dot_codex,
387                            ),
388                            root: writable_root,
389                        }
390                    })
391                    .collect()
392            }
393        }
394    }
395}
396
397fn default_read_only_subpaths_for_writable_root(
398    writable_root: &AbsolutePathBuf,
399    protect_missing_dot_codex: bool,
400) -> Vec<AbsolutePathBuf> {
401    let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
402    #[allow(clippy::expect_used)]
403    let top_level_git = writable_root
404        .join(".git")
405        .expect(".git is a valid relative path");
406    let top_level_git_is_file = top_level_git.as_path().is_file();
407    let top_level_git_is_dir = top_level_git.as_path().is_dir();
408    if top_level_git_is_dir || top_level_git_is_file {
409        if top_level_git_is_file
410            && is_git_pointer_file(&top_level_git)
411            && let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
412        {
413            subpaths.push(gitdir);
414        }
415        subpaths.push(top_level_git);
416    }
417
418    #[allow(clippy::expect_used)]
419    let top_level_agents = writable_root.join(".agents").expect("valid relative path");
420    if top_level_agents.as_path().is_dir() {
421        subpaths.push(top_level_agents);
422    }
423
424    #[allow(clippy::expect_used)]
425    let top_level_codex = writable_root.join(".codex").expect("valid relative path");
426    if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
427        subpaths.push(top_level_codex);
428    }
429
430    let mut deduped = Vec::with_capacity(subpaths.len());
431    let mut seen = HashSet::new();
432    for path in subpaths {
433        if seen.insert(path.to_path_buf()) {
434            deduped.push(path);
435        }
436    }
437    deduped
438}
439
440fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
441    path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
442}
443
444fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
445    let contents = match std::fs::read_to_string(dot_git.as_path()) {
446        Ok(contents) => contents,
447        Err(err) => {
448            error!(
449                "Failed to read {path} for gitdir pointer: {err}",
450                path = dot_git.as_path().display()
451            );
452            return None;
453        }
454    };
455
456    let trimmed = contents.trim();
457    let (_, gitdir_raw) = match trimmed.split_once(':') {
458        Some(parts) => parts,
459        None => {
460            error!(
461                "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
462                path = dot_git.as_path().display()
463            );
464            return None;
465        }
466    };
467    let gitdir_raw = gitdir_raw.trim();
468    if gitdir_raw.is_empty() {
469        error!(
470            "Expected {path} to contain a gitdir pointer, but it was empty.",
471            path = dot_git.as_path().display()
472        );
473        return None;
474    }
475    let base = match dot_git.as_path().parent() {
476        Some(base) => base,
477        None => {
478            error!(
479                "Unable to resolve parent directory for {path}.",
480                path = dot_git.as_path().display()
481            );
482            return None;
483        }
484    };
485    let gitdir_path = match AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base) {
486        Ok(path) => path,
487        Err(err) => {
488            error!(
489                "Failed to resolve gitdir path {gitdir_raw} from {path}: {err}",
490                path = dot_git.as_path().display()
491            );
492            return None;
493        }
494    };
495    if !gitdir_path.as_path().exists() {
496        error!(
497            "Resolved gitdir path {path} does not exist.",
498            path = gitdir_path.as_path().display()
499        );
500        return None;
501    }
502    Some(gitdir_path)
503}