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}
39
40/// Execution restrictions for sandboxed commands.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)]
42#[strum(serialize_all = "kebab-case")]
43#[serde(tag = "type", rename_all = "kebab-case")]
44pub enum SandboxPolicy {
45    /// No restrictions whatsoever. Use with caution.
46    #[serde(rename = "danger-full-access")]
47    DangerFullAccess,
48
49    /// Read-only access configuration.
50    #[serde(rename = "read-only")]
51    ReadOnly {
52        /// When set to `true`, outbound network access is allowed. `false` by
53        /// default.
54        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
55        network_access: bool,
56    },
57
58    /// Indicates the process is already in an external sandbox. Allows full
59    /// disk access while honoring the provided network setting.
60    #[serde(rename = "external-sandbox")]
61    ExternalSandbox {
62        /// Whether the external sandbox permits outbound network traffic.
63        #[serde(default)]
64        network_access: NetworkAccess,
65    },
66
67    /// Same as `ReadOnly` but additionally grants write access to the current
68    /// working directory ("workspace").
69    #[serde(rename = "workspace-write")]
70    WorkspaceWrite {
71        /// Additional folders (beyond cwd and possibly TMPDIR) that should be
72        /// writable from within the sandbox.
73        #[serde(default, skip_serializing_if = "Vec::is_empty")]
74        writable_roots: Vec<AbsolutePathBuf>,
75
76        /// When set to `true`, outbound network access is allowed. `false` by
77        /// default.
78        #[serde(default)]
79        network_access: bool,
80
81        /// When set to `true`, will NOT include the per-user `TMPDIR`
82        /// environment variable among the default writable roots. Defaults to
83        /// `false`.
84        #[serde(default)]
85        exclude_tmpdir_env_var: bool,
86
87        /// When set to `true`, will NOT include the `/tmp` among the default
88        /// writable roots on UNIX. Defaults to `false`.
89        #[serde(default)]
90        exclude_slash_tmp: bool,
91    },
92}
93
94/// A writable root path accompanied by a list of subpaths that should remain
95/// read-only even when the root is writable.
96#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
97pub struct WritableRoot {
98    pub root: AbsolutePathBuf,
99
100    /// By construction, these subpaths are all under `root`.
101    pub read_only_subpaths: Vec<AbsolutePathBuf>,
102
103    /// Workspace metadata path names that must not be created or replaced under
104    /// `root` unless the policy grants an explicit write rule for that metadata
105    /// path.
106    pub protected_metadata_names: Vec<String>,
107}
108
109impl WritableRoot {
110    pub fn is_path_writable(&self, path: &Path) -> bool {
111        // Check if the path is under the root.
112        if !path.starts_with(&self.root) {
113            return false;
114        }
115
116        // Check if the path is under any of the read-only subpaths.
117        for subpath in &self.read_only_subpaths {
118            if path.starts_with(subpath) {
119                return false;
120            }
121        }
122
123        if self.path_contains_protected_metadata_name(path) {
124            return false;
125        }
126
127        true
128    }
129
130    fn path_contains_protected_metadata_name(&self, path: &Path) -> bool {
131        let Ok(relative_path) = path.strip_prefix(&self.root) else {
132            return false;
133        };
134        let Some(first_component) = relative_path.components().next() else {
135            return false;
136        };
137        self.protected_metadata_names
138            .iter()
139            .any(|name| first_component.as_os_str() == std::ffi::OsStr::new(name))
140    }
141}
142
143impl FromStr for SandboxPolicy {
144    type Err = serde_json::Error;
145
146    fn from_str(s: &str) -> Result<Self, Self::Err> {
147        serde_json::from_str(s)
148    }
149}
150
151impl FromStr for FileSystemSandboxPolicy {
152    type Err = serde_json::Error;
153
154    fn from_str(s: &str) -> Result<Self, Self::Err> {
155        serde_json::from_str(s)
156    }
157}
158
159impl FromStr for NetworkSandboxPolicy {
160    type Err = serde_json::Error;
161
162    fn from_str(s: &str) -> Result<Self, Self::Err> {
163        serde_json::from_str(s)
164    }
165}
166
167impl SandboxPolicy {
168    /// Returns a policy with read-only disk access and no network.
169    pub fn new_read_only_policy() -> Self {
170        SandboxPolicy::ReadOnly {
171            network_access: false,
172        }
173    }
174
175    /// Returns a policy that can read the entire disk, but can only write to
176    /// the current working directory and the per-user tmp dir on macOS. It does
177    /// not allow network access.
178    pub fn new_workspace_write_policy() -> Self {
179        SandboxPolicy::WorkspaceWrite {
180            writable_roots: vec![],
181            network_access: false,
182            exclude_tmpdir_env_var: false,
183            exclude_slash_tmp: false,
184        }
185    }
186
187    pub fn has_full_disk_read_access(&self) -> bool {
188        true
189    }
190
191    pub fn has_full_disk_write_access(&self) -> bool {
192        match self {
193            SandboxPolicy::DangerFullAccess => true,
194            SandboxPolicy::ExternalSandbox { .. } => true,
195            SandboxPolicy::ReadOnly { .. } => false,
196            SandboxPolicy::WorkspaceWrite { .. } => false,
197        }
198    }
199
200    pub fn has_full_network_access(&self) -> bool {
201        match self {
202            SandboxPolicy::DangerFullAccess => true,
203            SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(),
204            SandboxPolicy::ReadOnly { network_access, .. } => *network_access,
205            SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
206        }
207    }
208
209    /// Returns true if platform defaults should be included for restricted read access.
210    pub fn include_platform_defaults(&self) -> bool {
211        false
212    }
213
214    /// Returns the list of readable roots (tailored to the current working
215    /// directory) when read access is restricted.
216    ///
217    /// For policies with full read access, this returns an empty list because
218    /// callers should grant blanket reads.
219    pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec<AbsolutePathBuf> {
220        let mut roots = match self {
221            SandboxPolicy::DangerFullAccess
222            | SandboxPolicy::ExternalSandbox { .. }
223            | SandboxPolicy::ReadOnly { .. } => Vec::new(),
224            SandboxPolicy::WorkspaceWrite { .. } => self
225                .get_writable_roots_with_cwd(cwd)
226                .into_iter()
227                .map(|root| root.root)
228                .collect(),
229        };
230        let mut seen = HashSet::new();
231        roots.retain(|root| seen.insert(root.to_path_buf()));
232        roots
233    }
234
235    /// Returns the list of writable roots (tailored to the current working
236    /// directory) together with subpaths that should remain read-only under
237    /// each writable root.
238    pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
239        match self {
240            SandboxPolicy::DangerFullAccess => Vec::new(),
241            SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
242            SandboxPolicy::ReadOnly { .. } => Vec::new(),
243            SandboxPolicy::WorkspaceWrite {
244                writable_roots,
245                exclude_tmpdir_env_var,
246                exclude_slash_tmp,
247                network_access: _,
248            } => {
249                let mut roots: Vec<AbsolutePathBuf> = writable_roots.clone();
250
251                let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd);
252                match cwd_absolute {
253                    Ok(cwd) => {
254                        roots.push(cwd);
255                    }
256                    Err(e) => {
257                        error!(
258                            "Ignoring invalid cwd {:?} for sandbox writable root: {}",
259                            cwd, e
260                        );
261                    }
262                }
263
264                if cfg!(unix) && !exclude_slash_tmp {
265                    #[allow(clippy::expect_used)]
266                    let slash_tmp =
267                        AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
268                    if slash_tmp.as_path().is_dir() {
269                        roots.push(slash_tmp);
270                    }
271                }
272
273                if !exclude_tmpdir_env_var
274                    && let Some(tmpdir) = std::env::var_os("TMPDIR")
275                    && !tmpdir.is_empty()
276                {
277                    match AbsolutePathBuf::from_absolute_path(PathBuf::from(&tmpdir)) {
278                        Ok(tmpdir_path) => {
279                            roots.push(tmpdir_path);
280                        }
281                        Err(e) => {
282                            error!(
283                                "Ignoring invalid TMPDIR value {tmpdir:?} for sandbox writable root: {e}",
284                            );
285                        }
286                    }
287                }
288
289                let cwd_root = AbsolutePathBuf::from_absolute_path(cwd).ok();
290                roots
291                    .into_iter()
292                    .map(|writable_root| {
293                        let protect_missing_dot_codex = cwd_root
294                            .as_ref()
295                            .is_some_and(|cwd_root| cwd_root == &writable_root);
296                        WritableRoot {
297                            read_only_subpaths: default_read_only_subpaths_for_writable_root(
298                                &writable_root,
299                                protect_missing_dot_codex,
300                            ),
301                            root: writable_root,
302                            protected_metadata_names: Vec::new(),
303                        }
304                    })
305                    .collect()
306            }
307        }
308    }
309}
310
311fn default_read_only_subpaths_for_writable_root(
312    writable_root: &AbsolutePathBuf,
313    protect_missing_dot_codex: bool,
314) -> Vec<AbsolutePathBuf> {
315    let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
316    let top_level_git = writable_root.join(".git");
317    let top_level_git_is_file = top_level_git.as_path().is_file();
318    let top_level_git_is_dir = top_level_git.as_path().is_dir();
319    if top_level_git_is_dir || top_level_git_is_file {
320        if top_level_git_is_file
321            && is_git_pointer_file(&top_level_git)
322            && let Some(gitdir) = resolve_gitdir_from_file(&top_level_git)
323        {
324            subpaths.push(gitdir);
325        }
326        subpaths.push(top_level_git);
327    }
328
329    let top_level_agents = writable_root.join(".agents");
330    if top_level_agents.as_path().is_dir() {
331        subpaths.push(top_level_agents);
332    }
333
334    let top_level_codex = writable_root.join(".codex");
335    if protect_missing_dot_codex || top_level_codex.as_path().is_dir() {
336        subpaths.push(top_level_codex);
337    }
338
339    let mut deduped = Vec::with_capacity(subpaths.len());
340    let mut seen = HashSet::new();
341    for path in subpaths {
342        if seen.insert(path.to_path_buf()) {
343            deduped.push(path);
344        }
345    }
346    deduped
347}
348
349fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool {
350    path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(".git"))
351}
352
353fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option<AbsolutePathBuf> {
354    let contents = match std::fs::read_to_string(dot_git.as_path()) {
355        Ok(contents) => contents,
356        Err(err) => {
357            error!(
358                "Failed to read {path} for gitdir pointer: {err}",
359                path = dot_git.as_path().display()
360            );
361            return None;
362        }
363    };
364
365    let trimmed = contents.trim();
366    let (_, gitdir_raw) = match trimmed.split_once(':') {
367        Some(parts) => parts,
368        None => {
369            error!(
370                "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: <path>`.",
371                path = dot_git.as_path().display()
372            );
373            return None;
374        }
375    };
376    let gitdir_raw = gitdir_raw.trim();
377    if gitdir_raw.is_empty() {
378        error!(
379            "Expected {path} to contain a gitdir pointer, but it was empty.",
380            path = dot_git.as_path().display()
381        );
382        return None;
383    }
384    let base = match dot_git.as_path().parent() {
385        Some(base) => base,
386        None => {
387            error!(
388                "Unable to resolve parent directory for {path}.",
389                path = dot_git.as_path().display()
390            );
391            return None;
392        }
393    };
394    let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base);
395    if !gitdir_path.as_path().exists() {
396        error!(
397            "Resolved gitdir path {path} does not exist.",
398            path = gitdir_path.as_path().display()
399        );
400        return None;
401    }
402    Some(gitdir_path)
403}