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#[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 Restricted {
55 #[serde(default = "default_include_platform_defaults")]
58 include_platform_defaults: bool,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 readable_roots: Vec<AbsolutePathBuf>,
62 },
63
64 #[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 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 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#[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 #[serde(rename = "danger-full-access")]
117 DangerFullAccess,
118
119 #[serde(rename = "read-only")]
121 ReadOnly {
122 #[serde(
124 default,
125 skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access"
126 )]
127 access: ReadOnlyAccess,
128
129 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
132 network_access: bool,
133 },
134
135 #[serde(rename = "external-sandbox")]
138 ExternalSandbox {
139 #[serde(default)]
141 network_access: NetworkAccess,
142 },
143
144 #[serde(rename = "workspace-write")]
147 WorkspaceWrite {
148 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 writable_roots: Vec<AbsolutePathBuf>,
152
153 #[serde(
155 default,
156 skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access"
157 )]
158 read_only_access: ReadOnlyAccess,
159
160 #[serde(default)]
163 network_access: bool,
164
165 #[serde(default)]
169 exclude_tmpdir_env_var: bool,
170
171 #[serde(default)]
174 exclude_slash_tmp: bool,
175 },
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
181pub struct WritableRoot {
182 pub root: AbsolutePathBuf,
183
184 pub read_only_subpaths: Vec<AbsolutePathBuf>,
186}
187
188impl WritableRoot {
189 pub fn is_path_writable(&self, path: &Path) -> bool {
190 if !path.starts_with(&self.root) {
192 return false;
193 }
194
195 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 pub fn new_read_only_policy() -> Self {
233 SandboxPolicy::ReadOnly {
234 access: ReadOnlyAccess::FullAccess,
235 network_access: false,
236 }
237 }
238
239 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 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 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 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}