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#[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 #[serde(rename = "danger-full-access")]
47 DangerFullAccess,
48
49 #[serde(rename = "read-only")]
51 ReadOnly {
52 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
55 network_access: bool,
56 },
57
58 #[serde(rename = "external-sandbox")]
61 ExternalSandbox {
62 #[serde(default)]
64 network_access: NetworkAccess,
65 },
66
67 #[serde(rename = "workspace-write")]
70 WorkspaceWrite {
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 writable_roots: Vec<AbsolutePathBuf>,
75
76 #[serde(default)]
79 network_access: bool,
80
81 #[serde(default)]
85 exclude_tmpdir_env_var: bool,
86
87 #[serde(default)]
90 exclude_slash_tmp: bool,
91 },
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
97pub struct WritableRoot {
98 pub root: AbsolutePathBuf,
99
100 pub read_only_subpaths: Vec<AbsolutePathBuf>,
102
103 pub protected_metadata_names: Vec<String>,
107}
108
109impl WritableRoot {
110 pub fn is_path_writable(&self, path: &Path) -> bool {
111 if !path.starts_with(&self.root) {
113 return false;
114 }
115
116 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 pub fn new_read_only_policy() -> Self {
170 SandboxPolicy::ReadOnly {
171 network_access: false,
172 }
173 }
174
175 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 pub fn include_platform_defaults(&self) -> bool {
211 false
212 }
213
214 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 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}