| 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 |