返回 CodeWhale
policy.rs
根目录 / crates / tui / src / sandbox / policy.rs
1 #![allow(dead_code)]
2
3 //! Sandbox policy definitions for command execution restrictions.
4 //!
5 //! This module defines the policies that control what resources a sandboxed
6 //! process can access. Policies range from full unrestricted access to
7 //! tightly controlled workspace-only write access.
8
9 use serde::{Deserialize, Serialize};
10 use std::fs;
11 use std::path::{Path, PathBuf};
12
13 use codewhale_execpolicy::command_safety::SafetyLevel;
14
15 /// Determines execution restrictions for shell commands.
16 ///
17 /// The sandbox policy controls filesystem access, network access, and other
18 /// system resources for executed commands. Choose the most restrictive policy
19 /// that still allows your command to function.
20 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21 #[serde(tag = "type", rename_all = "kebab-case")]
22 pub enum SandboxPolicy {
23 /// No restrictions whatsoever. Use with extreme caution.
24 ///
25 /// This policy disables all sandboxing and allows full system access.
26 /// Only use this when absolutely necessary and the command source is trusted.
27 #[serde(rename = "danger-full-access")]
28 DangerFullAccess,
29
30 /// Read-only access to the entire filesystem.
31 ///
32 /// The process can read any file but cannot write anywhere.
33 /// Useful for analysis tools that need broad read access.
34 #[serde(rename = "read-only")]
35 ReadOnly,
36
37 /// Indicates the process is already running in an external sandbox.
38 ///
39 /// Use this when CodeWhale is itself running inside a container,
40 /// VM, or other sandboxed environment. This avoids double-sandboxing
41 /// which can cause issues.
42 #[serde(rename = "external-sandbox")]
43 ExternalSandbox {
44 /// Whether network access is allowed in the external sandbox.
45 #[serde(default)]
46 network_access: bool,
47 },
48
49 /// Read-only filesystem access plus write access to specified directories.
50 ///
51 /// This is the default and recommended policy. It allows:
52 /// - Read access to the entire filesystem (for tools, libraries, etc.)
53 /// - Write access only to the current working directory and specified roots
54 /// - Optional network access
55 #[serde(rename = "workspace-write")]
56 WorkspaceWrite {
57 /// Additional directories where writes are allowed.
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 writable_roots: Vec<PathBuf>,
60
61 /// Whether outbound network connections are permitted.
62 #[serde(default)]
63 network_access: bool,
64
65 /// Exclude TMPDIR from writable paths.
66 #[serde(default)]
67 exclude_tmpdir: bool,
68
69 /// Exclude /tmp from writable paths.
70 #[serde(default)]
71 exclude_slash_tmp: bool,
72 },
73 }
74
75 /// Execution boundary available to apply a sandbox policy for this session.
76 ///
77 /// The engine snapshots this once at construction so model-visible turn
78 /// metadata stays byte-stable even if a local wrapper is installed or removed
79 /// while the session is running. An external backend accepts a raw command and
80 /// delegates isolation to its service, so it must not inherit local
81 /// workspace/network enforcement claims.
82 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
83 pub enum SandboxEnforcement {
84 /// A local OS wrapper (Seatbelt or opt-in bubblewrap) is configured.
85 LocalOs,
86 /// Shell execution is routed to a configured external service.
87 ExternalBackend,
88 /// No local wrapper or external execution backend is available.
89 Unavailable,
90 }
91
92 impl Default for SandboxPolicy {
93 /// Returns the default policy: workspace-write with no extra roots and no network.
94 fn default() -> Self {
95 SandboxPolicy::WorkspaceWrite {
96 writable_roots: vec![],
97 network_access: false,
98 exclude_tmpdir: false,
99 exclude_slash_tmp: false,
100 }
101 }
102 }
103
104 impl SandboxPolicy {
105 /// Create a workspace-write policy with network access enabled.
106 pub fn workspace_with_network() -> Self {
107 SandboxPolicy::WorkspaceWrite {
108 writable_roots: vec![],
109 network_access: true,
110 exclude_tmpdir: false,
111 exclude_slash_tmp: false,
112 }
113 }
114
115 /// Create a workspace-write policy with additional writable directories.
116 pub fn workspace_with_roots(roots: Vec<PathBuf>, network: bool) -> Self {
117 SandboxPolicy::WorkspaceWrite {
118 writable_roots: roots,
119 network_access: network,
120 exclude_tmpdir: false,
121 exclude_slash_tmp: false,
122 }
123 }
124
125 /// Returns true when the sandbox really does grant read of every file on
126 /// disk — i.e. no read deny-list is in force.
127 ///
128 /// Every posture, `read-only` included, grants full-disk *read*: the
129 /// postures differ only in what they may write and whether they may reach
130 /// the network. The read deny-list (S1, `super::read_guard`) is the only
131 /// thing that narrows this, and it is defense-in-depth rather than a
132 /// boundary — a hardlink or an indirect read (`ssh-agent`, `security`)
133 /// walks around it.
134 ///
135 /// Note the Seatbelt profile still emits the broad `(allow file-read*)`
136 /// even when this returns `false`: SBPL is last-match-wins, so the deny
137 /// rules appended after it are what actually narrow the grant. This
138 /// function reports the *posture*, for labels and telemetry.
139 pub fn has_full_disk_read_access(denied_read_subpaths: &[PathBuf]) -> bool {
140 denied_read_subpaths.is_empty()
141 }
142
143 /// Returns true if the policy allows writing to any file on the filesystem.
144 pub fn has_full_disk_write_access(&self) -> bool {
145 matches!(
146 self,
147 SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
148 )
149 }
150
151 /// Returns true if the policy allows outbound network connections.
152 pub fn has_network_access(&self) -> bool {
153 match self {
154 SandboxPolicy::DangerFullAccess => true,
155 SandboxPolicy::ReadOnly => false,
156 SandboxPolicy::ExternalSandbox { network_access }
157 | SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
158 }
159 }
160
161 /// Returns true if the sandbox should be applied (not bypassed).
162 pub fn should_sandbox(&self) -> bool {
163 !matches!(
164 self,
165 SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
166 )
167 }
168
169 /// Compact, deterministic posture label for model- and user-facing
170 /// surfaces (`<turn_meta>`, sandbox-denial hints). Byte-stable for a
171 /// given policy so per-turn metadata stays cache-friendly.
172 #[must_use]
173 pub fn posture_label(&self) -> String {
174 match self {
175 SandboxPolicy::DangerFullAccess => "full access (sandbox disabled)".to_string(),
176 SandboxPolicy::ReadOnly => {
177 "read-only (shell writes are blocked; ordinary approval does not change this)"
178 .to_string()
179 }
180 SandboxPolicy::ExternalSandbox { network_access } => format!(
181 "external sandbox (host-managed; network {})",
182 if *network_access {
183 "allowed"
184 } else {
185 "blocked"
186 }
187 ),
188 SandboxPolicy::WorkspaceWrite {
189 writable_roots,
190 network_access,
191 ..
192 } => format!(
193 "workspace-write (writes inside the workspace{}; network {})",
194 if writable_roots.len() > 1 {
195 " and approved roots"
196 } else {
197 ""
198 },
199 if *network_access {
200 "allowed"
201 } else {
202 "blocked"
203 }
204 ),
205 }
206 }
207
208 /// Render the policy together with the session-pinned execution boundary.
209 ///
210 /// Local wrappers may truthfully retain the policy's concrete filesystem
211 /// and network claims. External backends receive a raw command and delegate
212 /// isolation to their service, so their label names only the requested
213 /// policy and explicitly leaves the actual boundary unverified. When no
214 /// backend exists, restrictive policies are identified as policy-only.
215 #[must_use]
216 pub fn posture_label_with_enforcement(&self, enforcement: SandboxEnforcement) -> String {
217 if enforcement == SandboxEnforcement::ExternalBackend {
218 let requested_policy = match self {
219 SandboxPolicy::DangerFullAccess => "full-access",
220 SandboxPolicy::ReadOnly => "read-only",
221 SandboxPolicy::ExternalSandbox { .. } => "external-sandbox",
222 SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
223 };
224 return format!(
225 "{requested_policy} policy (external execution backend configured; filesystem/network isolation unverified by Codewhale)"
226 );
227 }
228
229 let label = self.posture_label();
230 match enforcement {
231 SandboxEnforcement::LocalOs if self.should_sandbox() => {
232 format!("{label} (local OS sandbox applied)")
233 }
234 SandboxEnforcement::Unavailable if self.should_sandbox() => {
235 format!("{label} (policy only; no execution sandbox available)")
236 }
237 SandboxEnforcement::LocalOs | SandboxEnforcement::Unavailable => label,
238 SandboxEnforcement::ExternalBackend => unreachable!("handled above"),
239 }
240 }
241
242 /// Posture label with the no-new-privileges kernel flag's effect on
243 /// privilege escalation named, for surfaces that can observe the live
244 /// flag state.
245 ///
246 /// `no_new_privs_active` is `Some(true)` when the irreversible flag is set
247 /// on this process tree, `Some(false)` when startup relaxed it, and `None`
248 /// on platforms without the flag (see
249 /// [`super::process_hardening::no_new_privs_active`]). Only the full-access
250 /// posture gains a clause: it is the posture whose name promises
251 /// unrestricted privilege transitions (#5723), so a residual setuid block
252 /// there is a lie of omission. Narrower postures keep the plain label —
253 /// setuid was never expected to work inside them.
254 #[must_use]
255 pub fn posture_label_with_no_new_privs(&self, no_new_privs_active: Option<bool>) -> String {
256 match (self, no_new_privs_active) {
257 (SandboxPolicy::DangerFullAccess, Some(true)) => format!(
258 "{}; sudo/setuid still blocked by the no-new-privs kernel flag set at startup",
259 self.posture_label()
260 ),
261 (SandboxPolicy::DangerFullAccess, Some(false)) => format!(
262 "{}; sudo/setuid allowed (no-new-privs relaxed at startup)",
263 self.posture_label()
264 ),
265 _ => self.posture_label(),
266 }
267 }
268
269 /// Render the policy together with the session-pinned execution boundary
270 /// and the live no-new-privileges flag state.
271 ///
272 /// The setuid clause only describes privilege transitions in *this*
273 /// process tree, so an external execution backend — where commands run on
274 /// the configured service — keeps the plain enforcement label.
275 #[must_use]
276 pub fn posture_label_with_enforcement_and_no_new_privs(
277 &self,
278 enforcement: SandboxEnforcement,
279 no_new_privs_active: Option<bool>,
280 ) -> String {
281 match (self, enforcement) {
282 (SandboxPolicy::DangerFullAccess, SandboxEnforcement::ExternalBackend) => {
283 self.posture_label_with_enforcement(enforcement)
284 }
285 (SandboxPolicy::DangerFullAccess, _) => {
286 self.posture_label_with_no_new_privs(no_new_privs_active)
287 }
288 _ => self.posture_label_with_enforcement(enforcement),
289 }
290 }
291
292 /// Get the list of writable roots for this policy.
293 ///
294 /// This includes:
295 /// - The current working directory
296 /// - Any explicitly specified `writable_roots`
297 /// - /tmp (unless excluded)
298 /// - TMPDIR (unless excluded)
299 ///
300 /// For policies with full write access, returns an empty vec since
301 /// there's no need to enumerate specific paths.
302 pub fn get_writable_roots(&self, cwd: &Path) -> Vec<WritableRoot> {
303 match self {
304 // Full write access or read-only - no enumeration needed
305 SandboxPolicy::DangerFullAccess
306 | SandboxPolicy::ExternalSandbox { .. }
307 | SandboxPolicy::ReadOnly => vec![],
308
309 // Workspace write - enumerate all writable paths
310 SandboxPolicy::WorkspaceWrite {
311 writable_roots,
312 exclude_tmpdir,
313 exclude_slash_tmp,
314 ..
315 } => {
316 let mut roots: Vec<PathBuf> = writable_roots.clone();
317
318 // Add the current working directory
319 if let Ok(canonical_cwd) = cwd.canonicalize() {
320 roots.push(canonical_cwd);
321 } else {
322 roots.push(cwd.to_path_buf());
323 }
324
325 // Git worktrees keep mutable metadata outside the worktree
326 // directory. Allow only the gitdir and commondir derived from
327 // a workspace `.git` pointer, preserving the workspace boundary
328 // for all other external paths.
329 for root in roots.clone() {
330 roots.extend(resolve_git_worktree_writable_roots(&root));
331 }
332
333 // Add /tmp unless excluded
334 if !exclude_slash_tmp && let Ok(tmp) = Path::new("/tmp").canonicalize() {
335 roots.push(tmp);
336 }
337
338 // Add TMPDIR unless excluded
339 if !exclude_tmpdir
340 && let Ok(tmpdir) = std::env::var("TMPDIR")
341 && let Ok(canonical) = Path::new(&tmpdir).canonicalize()
342 {
343 roots.push(canonical);
344 }
345
346 // Convert to WritableRoot with read-only subpaths
347 roots
348 .into_iter()
349 .map(|root| {
350 let mut read_only_subpaths = Vec::new();
351
352 // Protect .codewhale/ and .deepseek/ directories from modification
353 let codewhale_dir = root.join(".codewhale");
354 if codewhale_dir.is_dir() {
355 read_only_subpaths.push(codewhale_dir);
356 }
357 let deepseek_dir = root.join(".deepseek");
358 if deepseek_dir.is_dir() {
359 read_only_subpaths.push(deepseek_dir);
360 }
361
362 WritableRoot {
363 root,
364 read_only_subpaths,
365 }
366 })
367 .collect()
368 }
369 }
370 }
371 }
372
373 fn resolve_git_worktree_writable_roots(root: &Path) -> Vec<PathBuf> {
374 let Some(pointer) = resolve_gitdir_pointer(root) else {
375 return Vec::new();
376 };
377 let git_dir = pointer.git_dir;
378 let Some(common_dir) = resolve_git_common_dir(&git_dir) else {
379 return Vec::new();
380 };
381 if !git_dir.starts_with(common_dir.join("worktrees")) {
382 return Vec::new();
383 }
384 if !worktree_metadata_points_back_to_workspace(&git_dir, &pointer.git_file) {
385 return Vec::new();
386 }
387
388 vec![git_dir, common_dir]
389 }
390
391 #[derive(Debug)]
392 struct GitDirPointer {
393 git_dir: PathBuf,
394 git_file: PathBuf,
395 }
396
397 fn resolve_gitdir_pointer(root: &Path) -> Option<GitDirPointer> {
398 let search_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
399 for ancestor in search_root.ancestors() {
400 let git_file = ancestor.join(".git");
401 if !git_file.is_file() {
402 continue;
403 }
404
405 let contents = fs::read_to_string(&git_file).ok()?;
406 let value = contents
407 .lines()
408 .find_map(|line| line.strip_prefix("gitdir:"))?
409 .trim();
410 if value.is_empty() {
411 return None;
412 }
413
414 let path = PathBuf::from(value);
415 let resolved = if path.is_absolute() {
416 path
417 } else {
418 ancestor.join(path)
419 };
420
421 return Some(GitDirPointer {
422 git_dir: resolved.canonicalize().ok()?,
423 git_file: git_file.canonicalize().ok()?,
424 });
425 }
426
427 None
428 }
429
430 fn resolve_git_common_dir(git_dir: &Path) -> Option<PathBuf> {
431 let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
432 let value = contents.lines().next()?.trim();
433 if value.is_empty() {
434 return None;
435 }
436
437 let path = PathBuf::from(value);
438 let resolved = if path.is_absolute() {
439 path
440 } else {
441 git_dir.join(path)
442 };
443
444 resolved.canonicalize().ok()
445 }
446
447 fn worktree_metadata_points_back_to_workspace(git_dir: &Path, expected_git_file: &Path) -> bool {
448 let Some(actual_git_file) = resolve_gitdir_back_pointer(git_dir) else {
449 return false;
450 };
451 actual_git_file == expected_git_file
452 }
453
454 fn resolve_gitdir_back_pointer(git_dir: &Path) -> Option<PathBuf> {
455 let contents = fs::read_to_string(git_dir.join("gitdir")).ok()?;
456 let value = contents.lines().next()?.trim();
457 if value.is_empty() {
458 return None;
459 }
460
461 let path = PathBuf::from(value);
462 let resolved = if path.is_absolute() {
463 path
464 } else {
465 git_dir.join(path)
466 };
467
468 resolved.canonicalize().ok()
469 }
470
471 /// A directory tree where writes are allowed, with optional read-only subpaths.
472 ///
473 /// This allows fine-grained control like "allow writes to /project but not /project/.deepseek".
474 #[derive(Debug, Clone, PartialEq, Eq)]
475 pub struct WritableRoot {
476 /// The root directory where writes are allowed.
477 pub root: PathBuf,
478
479 /// Subdirectories within root that should remain read-only.
480 pub read_only_subpaths: Vec<PathBuf>,
481 }
482
483 impl WritableRoot {
484 /// Create a new writable root with no read-only exceptions.
485 pub fn new(root: PathBuf) -> Self {
486 Self {
487 root,
488 read_only_subpaths: vec![],
489 }
490 }
491
492 /// Create a writable root with specific read-only subpaths.
493 pub fn with_exceptions(root: PathBuf, read_only: Vec<PathBuf>) -> Self {
494 Self {
495 root,
496 read_only_subpaths: read_only,
497 }
498 }
499
500 /// Check if a path is writable under this root.
501 ///
502 /// Returns true if the path is under the root and not under any read-only subpath.
503 pub fn is_path_writable(&self, path: &Path) -> bool {
504 // Must be under the root
505 if !path.starts_with(&self.root) {
506 return false;
507 }
508
509 // Must not be under any read-only subpath
510 for subpath in &self.read_only_subpaths {
511 if path.starts_with(subpath) {
512 return false;
513 }
514 }
515
516 true
517 }
518 }
519
520 /// Map a command safety classification to the appropriate sandbox policy (#2186).
521 ///
522 /// - `Safe` / `WorkspaceSafe` → use the default sandbox policy
523 /// - `RequiresApproval` → user must approve before execution (handled by caller)
524 /// - `Dangerous` → blocked unless in YOLO mode with trust
525 pub fn map_safety_level_to_behavior(
526 level: SafetyLevel,
527 default_policy: &SandboxPolicy,
528 ) -> SandboxPolicyBehavior {
529 match level {
530 SafetyLevel::Safe | SafetyLevel::WorkspaceSafe => {
531 SandboxPolicyBehavior::Sandboxed(default_policy.clone())
532 }
533 SafetyLevel::RequiresApproval => SandboxPolicyBehavior::RequiresApproval,
534 SafetyLevel::Dangerous => SandboxPolicyBehavior::Blocked,
535 }
536 }
537
538 /// Behavior decision for a sandboxed command based on safety level.
539 #[derive(Debug, Clone)]
540 pub enum SandboxPolicyBehavior {
541 /// Execute with the given sandbox policy.
542 Sandboxed(SandboxPolicy),
543 /// User approval required before execution.
544 RequiresApproval,
545 /// Block execution entirely (unless YOLO+trust).
546 Blocked,
547 }
548
549 #[cfg(test)]
550 mod tests {
551 use super::*;
552
553 #[test]
554 fn test_default_policy() {
555 let policy = SandboxPolicy::default();
556 assert!(matches!(policy, SandboxPolicy::WorkspaceWrite { .. }));
557 assert!(!policy.has_network_access());
558 assert!(policy.should_sandbox());
559 }
560
561 #[test]
562 fn test_full_access_policy() {
563 let policy = SandboxPolicy::DangerFullAccess;
564 assert!(policy.has_full_disk_write_access());
565 assert!(policy.has_network_access());
566 assert!(!policy.should_sandbox());
567 }
568
569 #[test]
570 fn posture_labels_name_the_binding_fact() {
571 // This label is shared by interactive and non-interactive postures, so
572 // state only the invariant. The denial hint names the Ask-only path.
573 let read_only = SandboxPolicy::ReadOnly.posture_label();
574 assert!(read_only.contains("read-only"), "{read_only}");
575 assert!(read_only.contains("ordinary approval"), "{read_only}");
576
577 assert!(
578 SandboxPolicy::default()
579 .posture_label()
580 .starts_with("workspace-write"),
581 );
582 assert!(
583 SandboxPolicy::DangerFullAccess
584 .posture_label()
585 .contains("full access"),
586 );
587 assert!(
588 SandboxPolicy::ExternalSandbox {
589 network_access: false
590 }
591 .posture_label()
592 .contains("network blocked"),
593 );
594 }
595
596 #[test]
597 fn enforcement_labels_distinguish_local_external_and_unavailable() {
598 let local =
599 SandboxPolicy::default().posture_label_with_enforcement(SandboxEnforcement::LocalOs);
600 assert!(local.starts_with("workspace-write"), "{local}");
601 assert!(local.contains("local OS sandbox applied"), "{local}");
602
603 let unavailable =
604 SandboxPolicy::ReadOnly.posture_label_with_enforcement(SandboxEnforcement::Unavailable);
605 assert!(
606 unavailable.contains("policy only; no execution sandbox available"),
607 "{unavailable}"
608 );
609
610 let external = SandboxPolicy::default()
611 .posture_label_with_enforcement(SandboxEnforcement::ExternalBackend);
612 assert!(external.starts_with("workspace-write policy"), "{external}");
613 assert!(
614 external.contains("external execution backend configured"),
615 "{external}"
616 );
617 assert!(
618 external.contains("isolation unverified by Codewhale"),
619 "{external}"
620 );
621 assert!(
622 !external.contains("writes inside the workspace"),
623 "{external}"
624 );
625 assert!(!external.contains("network allowed"), "{external}");
626
627 let full_external = SandboxPolicy::DangerFullAccess
628 .posture_label_with_enforcement(SandboxEnforcement::ExternalBackend);
629 assert!(
630 full_external.starts_with("full-access policy"),
631 "{full_external}"
632 );
633 assert!(
634 !full_external.contains("sandbox disabled"),
635 "{full_external}"
636 );
637
638 let full_unavailable = SandboxPolicy::DangerFullAccess
639 .posture_label_with_enforcement(SandboxEnforcement::Unavailable);
640 assert_eq!(
641 full_unavailable,
642 SandboxPolicy::DangerFullAccess.posture_label()
643 );
644 }
645
646 #[test]
647 fn posture_labels_disclose_the_no_new_privs_flag_for_full_access() {
648 // #5723: "full access" promises unrestricted privilege transitions, so
649 // the label must say when the irreversible kernel flag still blocks
650 // sudo/setuid — and when the startup posture relaxed it.
651 let blocked = SandboxPolicy::DangerFullAccess.posture_label_with_no_new_privs(Some(true));
652 assert!(
653 blocked.contains("full access (sandbox disabled)"),
654 "{blocked}"
655 );
656 assert!(blocked.contains("sudo/setuid still blocked"), "{blocked}");
657 assert!(blocked.contains("no-new-privs"), "{blocked}");
658
659 let relaxed = SandboxPolicy::DangerFullAccess.posture_label_with_no_new_privs(Some(false));
660 assert!(relaxed.contains("sudo/setuid allowed"), "{relaxed}");
661
662 // No flag on this platform → the plain label, byte-identical.
663 let unknown = SandboxPolicy::DangerFullAccess.posture_label_with_no_new_privs(None);
664 assert_eq!(unknown, SandboxPolicy::DangerFullAccess.posture_label());
665
666 // Narrower postures never promised setuid; the clause is full-access
667 // only, in either flag state.
668 for flag in [Some(true), Some(false), None] {
669 assert_eq!(
670 SandboxPolicy::default().posture_label_with_no_new_privs(flag),
671 SandboxPolicy::default().posture_label(),
672 );
673 assert_eq!(
674 SandboxPolicy::ReadOnly.posture_label_with_no_new_privs(flag),
675 SandboxPolicy::ReadOnly.posture_label(),
676 );
677 }
678 }
679
680 #[test]
681 fn enforcement_and_no_new_privs_labels_compose_only_where_they_apply() {
682 // The setuid clause describes this process tree; an external backend
683 // runs commands on its own service, so its label stays untouched.
684 let external = SandboxPolicy::DangerFullAccess
685 .posture_label_with_enforcement_and_no_new_privs(
686 SandboxEnforcement::ExternalBackend,
687 Some(true),
688 );
689 assert_eq!(
690 external,
691 SandboxPolicy::DangerFullAccess
692 .posture_label_with_enforcement(SandboxEnforcement::ExternalBackend),
693 "{external}"
694 );
695
696 let local_blocked = SandboxPolicy::DangerFullAccess
697 .posture_label_with_enforcement_and_no_new_privs(
698 SandboxEnforcement::LocalOs,
699 Some(true),
700 );
701 assert!(
702 local_blocked.contains("sudo/setuid still blocked"),
703 "{local_blocked}"
704 );
705
706 // Other postures keep exactly the enforcement label.
707 let workspace = SandboxPolicy::default().posture_label_with_enforcement_and_no_new_privs(
708 SandboxEnforcement::LocalOs,
709 Some(false),
710 );
711 assert_eq!(
712 workspace,
713 SandboxPolicy::default().posture_label_with_enforcement(SandboxEnforcement::LocalOs),
714 "{workspace}"
715 );
716 }
717
718 #[test]
719 fn test_read_only_policy() {
720 let policy = SandboxPolicy::ReadOnly;
721 assert!(!policy.has_full_disk_write_access());
722 assert!(!policy.has_network_access());
723 assert!(policy.should_sandbox());
724 }
725
726 #[test]
727 fn test_workspace_with_network() {
728 let policy = SandboxPolicy::workspace_with_network();
729 assert!(policy.has_network_access());
730 assert!(policy.should_sandbox());
731 }
732
733 #[test]
734 fn workspace_write_includes_git_worktree_metadata_roots() {
735 let tmp = tempfile::tempdir().expect("tempdir");
736 let common_git_dir = tmp.path().join("main-repo").join(".git");
737 let worktree_git_dir = common_git_dir.join("worktrees").join("feature");
738 let worktree = tmp.path().join("feature-worktree");
739 std::fs::create_dir_all(&worktree_git_dir).expect("mkdir gitdir");
740 std::fs::create_dir_all(&worktree).expect("mkdir worktree");
741 std::fs::write(
742 worktree.join(".git"),
743 format!("gitdir: {}\n", worktree_git_dir.display()),
744 )
745 .expect("write git pointer");
746 std::fs::write(worktree_git_dir.join("commondir"), "../..").expect("write commondir");
747 std::fs::write(
748 worktree_git_dir.join("gitdir"),
749 worktree.join(".git").display().to_string(),
750 )
751 .expect("write gitdir back pointer");
752
753 let policy = SandboxPolicy::WorkspaceWrite {
754 writable_roots: vec![worktree.clone()],
755 network_access: true,
756 exclude_tmpdir: true,
757 exclude_slash_tmp: true,
758 };
759
760 let root_paths: Vec<PathBuf> = policy
761 .get_writable_roots(&worktree)
762 .into_iter()
763 .map(|root| root.root)
764 .collect();
765
766 assert!(root_paths.contains(&worktree.canonicalize().expect("canonical worktree")));
767 assert!(root_paths.contains(&worktree_git_dir.canonicalize().expect("canonical gitdir")));
768 assert!(root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git")));
769 }
770
771 #[test]
772 fn workspace_write_resolves_git_worktree_metadata_from_subdirectory() {
773 let tmp = tempfile::tempdir().expect("tempdir");
774 let common_git_dir = tmp.path().join("main-repo").join(".git");
775 let worktree_git_dir = common_git_dir.join("worktrees").join("feature");
776 let worktree = tmp.path().join("feature-worktree");
777 let nested = worktree.join("crates").join("cli");
778 std::fs::create_dir_all(&worktree_git_dir).expect("mkdir gitdir");
779 std::fs::create_dir_all(&nested).expect("mkdir nested worktree path");
780 std::fs::write(
781 worktree.join(".git"),
782 format!("gitdir: {}\n", worktree_git_dir.display()),
783 )
784 .expect("write git pointer");
785 std::fs::write(worktree_git_dir.join("commondir"), "../..").expect("write commondir");
786 std::fs::write(
787 worktree_git_dir.join("gitdir"),
788 worktree.join(".git").display().to_string(),
789 )
790 .expect("write gitdir back pointer");
791
792 let policy = SandboxPolicy::WorkspaceWrite {
793 writable_roots: vec![],
794 network_access: true,
795 exclude_tmpdir: true,
796 exclude_slash_tmp: true,
797 };
798
799 let root_paths: Vec<PathBuf> = policy
800 .get_writable_roots(&nested)
801 .into_iter()
802 .map(|root| root.root)
803 .collect();
804
805 assert!(root_paths.contains(&nested.canonicalize().expect("canonical nested cwd")));
806 assert!(root_paths.contains(&worktree_git_dir.canonicalize().expect("canonical gitdir")));
807 assert!(root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git")));
808 }
809
810 #[test]
811 fn workspace_write_rejects_non_reciprocal_git_worktree_metadata() {
812 let tmp = tempfile::tempdir().expect("tempdir");
813 let common_git_dir = tmp.path().join("main-repo").join(".git");
814 let worktree_git_dir = common_git_dir.join("worktrees").join("feature");
815 let worktree = tmp.path().join("feature-worktree");
816 let other_worktree = tmp.path().join("other-worktree");
817 std::fs::create_dir_all(&worktree_git_dir).expect("mkdir gitdir");
818 std::fs::create_dir_all(&worktree).expect("mkdir worktree");
819 std::fs::create_dir_all(&other_worktree).expect("mkdir other worktree");
820 std::fs::write(
821 worktree.join(".git"),
822 format!("gitdir: {}\n", worktree_git_dir.display()),
823 )
824 .expect("write git pointer");
825 std::fs::write(worktree_git_dir.join("commondir"), "../..").expect("write commondir");
826 std::fs::write(
827 worktree_git_dir.join("gitdir"),
828 other_worktree.join(".git").display().to_string(),
829 )
830 .expect("write mismatched gitdir back pointer");
831 std::fs::write(
832 other_worktree.join(".git"),
833 "gitdir: /tmp/not-this-worktree\n",
834 )
835 .expect("write other git pointer");
836
837 let policy = SandboxPolicy::WorkspaceWrite {
838 writable_roots: vec![worktree.clone()],
839 network_access: true,
840 exclude_tmpdir: true,
841 exclude_slash_tmp: true,
842 };
843
844 let root_paths: Vec<PathBuf> = policy
845 .get_writable_roots(&worktree)
846 .into_iter()
847 .map(|root| root.root)
848 .collect();
849
850 assert!(root_paths.contains(&worktree.canonicalize().expect("canonical worktree")));
851 assert!(!root_paths.contains(&worktree_git_dir.canonicalize().expect("canonical gitdir")));
852 assert!(
853 !root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git"))
854 );
855 }
856
857 #[test]
858 fn test_writable_root_basic() {
859 let root = WritableRoot::new(PathBuf::from("/project"));
860 assert!(root.is_path_writable(Path::new("/project/src/main.rs")));
861 assert!(!root.is_path_writable(Path::new("/other/file.txt")));
862 }
863
864 #[test]
865 fn test_writable_root_with_exceptions() {
866 let root = WritableRoot::with_exceptions(
867 PathBuf::from("/project"),
868 vec![PathBuf::from("/project/.deepseek")],
869 );
870 assert!(root.is_path_writable(Path::new("/project/src/main.rs")));
871 assert!(!root.is_path_writable(Path::new("/project/.deepseek/config")));
872 }
873
874 #[test]
875 fn test_safety_level_mapping() {
876 let default = SandboxPolicy::default();
877
878 // Safe commands get sandboxed
879 assert!(matches!(
880 map_safety_level_to_behavior(SafetyLevel::Safe, &default),
881 SandboxPolicyBehavior::Sandboxed(_)
882 ));
883 assert!(matches!(
884 map_safety_level_to_behavior(SafetyLevel::WorkspaceSafe, &default),
885 SandboxPolicyBehavior::Sandboxed(_)
886 ));
887
888 // RequiresApproval gets RequiresApproval
889 assert!(matches!(
890 map_safety_level_to_behavior(SafetyLevel::RequiresApproval, &default),
891 SandboxPolicyBehavior::RequiresApproval
892 ));
893
894 // Dangerous gets Blocked
895 assert!(matches!(
896 map_safety_level_to_behavior(SafetyLevel::Dangerous, &default),
897 SandboxPolicyBehavior::Blocked
898 ));
899 }
900
901 #[test]
902 fn test_policy_serialization() {
903 let policy = SandboxPolicy::WorkspaceWrite {
904 writable_roots: vec![PathBuf::from("/extra")],
905 network_access: true,
906 exclude_tmpdir: false,
907 exclude_slash_tmp: false,
908 };
909
910 let json = serde_json::to_string(&policy).unwrap();
911 assert!(json.contains("workspace-write"));
912
913 let parsed: SandboxPolicy = serde_json::from_str(&json).unwrap();
914 assert_eq!(policy, parsed);
915 }
916 }
917
917 lines RUST