| 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::io; |
| 12 | use std::path::{Path, PathBuf}; |
| 13 | |
| 14 | use super::{CommandSpec, ExecEnv}; |
| 15 | use crate::command_safety::SafetyLevel; |
| 16 | |
| 17 | /// Determines execution restrictions for shell commands. |
| 18 | /// |
| 19 | /// The sandbox policy controls filesystem access, network access, and other |
| 20 | /// system resources for executed commands. Choose the most restrictive policy |
| 21 | /// that still allows your command to function. |
| 22 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 23 | #[serde(tag = "type", rename_all = "kebab-case")] |
| 24 | pub enum SandboxPolicy { |
| 25 | /// No restrictions whatsoever. Use with extreme caution. |
| 26 | /// |
| 27 | /// This policy disables all sandboxing and allows full system access. |
| 28 | /// Only use this when absolutely necessary and the command source is trusted. |
| 29 | #[serde(rename = "danger-full-access")] |
| 30 | DangerFullAccess, |
| 31 | |
| 32 | /// Read-only access to the entire filesystem. |
| 33 | /// |
| 34 | /// The process can read any file but cannot write anywhere. |
| 35 | /// Useful for analysis tools that need broad read access. |
| 36 | #[serde(rename = "read-only")] |
| 37 | ReadOnly, |
| 38 | |
| 39 | /// Indicates the process is already running in an external sandbox. |
| 40 | /// |
| 41 | /// Use this when CodeWhale is itself running inside a container, |
| 42 | /// VM, or other sandboxed environment. This avoids double-sandboxing |
| 43 | /// which can cause issues. |
| 44 | #[serde(rename = "external-sandbox")] |
| 45 | ExternalSandbox { |
| 46 | /// Whether network access is allowed in the external sandbox. |
| 47 | #[serde(default)] |
| 48 | network_access: bool, |
| 49 | }, |
| 50 | |
| 51 | /// Read-only filesystem access plus write access to specified directories. |
| 52 | /// |
| 53 | /// This is the default and recommended policy. It allows: |
| 54 | /// - Read access to the entire filesystem (for tools, libraries, etc.) |
| 55 | /// - Write access only to the current working directory and specified roots |
| 56 | /// - Optional network access |
| 57 | #[serde(rename = "workspace-write")] |
| 58 | WorkspaceWrite { |
| 59 | /// Additional directories where writes are allowed. |
| 60 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 61 | writable_roots: Vec<PathBuf>, |
| 62 | |
| 63 | /// Whether outbound network connections are permitted. |
| 64 | #[serde(default)] |
| 65 | network_access: bool, |
| 66 | |
| 67 | /// Exclude TMPDIR from writable paths. |
| 68 | #[serde(default)] |
| 69 | exclude_tmpdir: bool, |
| 70 | |
| 71 | /// Exclude /tmp from writable paths. |
| 72 | #[serde(default)] |
| 73 | exclude_slash_tmp: bool, |
| 74 | }, |
| 75 | } |
| 76 | |
| 77 | impl Default for SandboxPolicy { |
| 78 | /// Returns the default policy: workspace-write with no extra roots and no network. |
| 79 | fn default() -> Self { |
| 80 | SandboxPolicy::WorkspaceWrite { |
| 81 | writable_roots: vec![], |
| 82 | network_access: false, |
| 83 | exclude_tmpdir: false, |
| 84 | exclude_slash_tmp: false, |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | impl SandboxPolicy { |
| 90 | /// Create a workspace-write policy with network access enabled. |
| 91 | pub fn workspace_with_network() -> Self { |
| 92 | SandboxPolicy::WorkspaceWrite { |
| 93 | writable_roots: vec![], |
| 94 | network_access: true, |
| 95 | exclude_tmpdir: false, |
| 96 | exclude_slash_tmp: false, |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | /// Create a workspace-write policy with additional writable directories. |
| 101 | pub fn workspace_with_roots(roots: Vec<PathBuf>, network: bool) -> Self { |
| 102 | SandboxPolicy::WorkspaceWrite { |
| 103 | writable_roots: roots, |
| 104 | network_access: network, |
| 105 | exclude_tmpdir: false, |
| 106 | exclude_slash_tmp: false, |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// Returns true if the policy allows reading any file on the filesystem. |
| 111 | pub fn has_full_disk_read_access() -> bool { |
| 112 | // All current policies allow full disk read access |
| 113 | true |
| 114 | } |
| 115 | |
| 116 | /// Returns true if the policy allows writing to any file on the filesystem. |
| 117 | pub fn has_full_disk_write_access(&self) -> bool { |
| 118 | matches!( |
| 119 | self, |
| 120 | SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } |
| 121 | ) |
| 122 | } |
| 123 | |
| 124 | /// Returns true if the policy allows outbound network connections. |
| 125 | pub fn has_network_access(&self) -> bool { |
| 126 | match self { |
| 127 | SandboxPolicy::DangerFullAccess => true, |
| 128 | SandboxPolicy::ReadOnly => false, |
| 129 | SandboxPolicy::ExternalSandbox { network_access } |
| 130 | | SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | /// Returns true if the sandbox should be applied (not bypassed). |
| 135 | pub fn should_sandbox(&self) -> bool { |
| 136 | !matches!( |
| 137 | self, |
| 138 | SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } |
| 139 | ) |
| 140 | } |
| 141 | |
| 142 | /// Compact, deterministic posture label for model- and user-facing |
| 143 | /// surfaces (`<turn_meta>`, sandbox-denial hints). Byte-stable for a |
| 144 | /// given policy so per-turn metadata stays cache-friendly. |
| 145 | #[must_use] |
| 146 | pub fn posture_label(&self) -> String { |
| 147 | match self { |
| 148 | SandboxPolicy::DangerFullAccess => "full access (sandbox disabled)".to_string(), |
| 149 | SandboxPolicy::ReadOnly => { |
| 150 | "read-only (shell writes are blocked; tool approval cannot lift this)".to_string() |
| 151 | } |
| 152 | SandboxPolicy::ExternalSandbox { network_access } => format!( |
| 153 | "external sandbox (host-managed; network {})", |
| 154 | if *network_access { |
| 155 | "allowed" |
| 156 | } else { |
| 157 | "blocked" |
| 158 | } |
| 159 | ), |
| 160 | SandboxPolicy::WorkspaceWrite { |
| 161 | writable_roots, |
| 162 | network_access, |
| 163 | .. |
| 164 | } => format!( |
| 165 | "workspace-write (writes inside the workspace{}; network {})", |
| 166 | if writable_roots.len() > 1 { |
| 167 | " and approved roots" |
| 168 | } else { |
| 169 | "" |
| 170 | }, |
| 171 | if *network_access { |
| 172 | "allowed" |
| 173 | } else { |
| 174 | "blocked" |
| 175 | } |
| 176 | ), |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | /// Get the list of writable roots for this policy. |
| 181 | /// |
| 182 | /// This includes: |
| 183 | /// - The current working directory |
| 184 | /// - Any explicitly specified `writable_roots` |
| 185 | /// - /tmp (unless excluded) |
| 186 | /// - TMPDIR (unless excluded) |
| 187 | /// |
| 188 | /// For policies with full write access, returns an empty vec since |
| 189 | /// there's no need to enumerate specific paths. |
| 190 | pub fn get_writable_roots(&self, cwd: &Path) -> Vec<WritableRoot> { |
| 191 | match self { |
| 192 | // Full write access or read-only - no enumeration needed |
| 193 | SandboxPolicy::DangerFullAccess |
| 194 | | SandboxPolicy::ExternalSandbox { .. } |
| 195 | | SandboxPolicy::ReadOnly => vec![], |
| 196 | |
| 197 | // Workspace write - enumerate all writable paths |
| 198 | SandboxPolicy::WorkspaceWrite { |
| 199 | writable_roots, |
| 200 | exclude_tmpdir, |
| 201 | exclude_slash_tmp, |
| 202 | .. |
| 203 | } => { |
| 204 | let mut roots: Vec<PathBuf> = writable_roots.clone(); |
| 205 | |
| 206 | // Add the current working directory |
| 207 | if let Ok(canonical_cwd) = cwd.canonicalize() { |
| 208 | roots.push(canonical_cwd); |
| 209 | } else { |
| 210 | roots.push(cwd.to_path_buf()); |
| 211 | } |
| 212 | |
| 213 | // Git worktrees keep mutable metadata outside the worktree |
| 214 | // directory. Allow only the gitdir and commondir derived from |
| 215 | // a workspace `.git` pointer, preserving the workspace boundary |
| 216 | // for all other external paths. |
| 217 | for root in roots.clone() { |
| 218 | roots.extend(resolve_git_worktree_writable_roots(&root)); |
| 219 | } |
| 220 | |
| 221 | // Add /tmp unless excluded |
| 222 | if !exclude_slash_tmp && let Ok(tmp) = Path::new("/tmp").canonicalize() { |
| 223 | roots.push(tmp); |
| 224 | } |
| 225 | |
| 226 | // Add TMPDIR unless excluded |
| 227 | if !exclude_tmpdir |
| 228 | && let Ok(tmpdir) = std::env::var("TMPDIR") |
| 229 | && let Ok(canonical) = Path::new(&tmpdir).canonicalize() |
| 230 | { |
| 231 | roots.push(canonical); |
| 232 | } |
| 233 | |
| 234 | // Convert to WritableRoot with read-only subpaths |
| 235 | roots |
| 236 | .into_iter() |
| 237 | .map(|root| { |
| 238 | let mut read_only_subpaths = Vec::new(); |
| 239 | |
| 240 | // Protect .codewhale/ and .deepseek/ directories from modification |
| 241 | let codewhale_dir = root.join(".codewhale"); |
| 242 | if codewhale_dir.is_dir() { |
| 243 | read_only_subpaths.push(codewhale_dir); |
| 244 | } |
| 245 | let deepseek_dir = root.join(".deepseek"); |
| 246 | if deepseek_dir.is_dir() { |
| 247 | read_only_subpaths.push(deepseek_dir); |
| 248 | } |
| 249 | |
| 250 | WritableRoot { |
| 251 | root, |
| 252 | read_only_subpaths, |
| 253 | } |
| 254 | }) |
| 255 | .collect() |
| 256 | } |
| 257 | } |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | fn resolve_git_worktree_writable_roots(root: &Path) -> Vec<PathBuf> { |
| 262 | let Some(pointer) = resolve_gitdir_pointer(root) else { |
| 263 | return Vec::new(); |
| 264 | }; |
| 265 | let git_dir = pointer.git_dir; |
| 266 | let Some(common_dir) = resolve_git_common_dir(&git_dir) else { |
| 267 | return Vec::new(); |
| 268 | }; |
| 269 | if !git_dir.starts_with(common_dir.join("worktrees")) { |
| 270 | return Vec::new(); |
| 271 | } |
| 272 | if !worktree_metadata_points_back_to_workspace(&git_dir, &pointer.git_file) { |
| 273 | return Vec::new(); |
| 274 | } |
| 275 | |
| 276 | vec![git_dir, common_dir] |
| 277 | } |
| 278 | |
| 279 | #[derive(Debug)] |
| 280 | struct GitDirPointer { |
| 281 | git_dir: PathBuf, |
| 282 | git_file: PathBuf, |
| 283 | } |
| 284 | |
| 285 | fn resolve_gitdir_pointer(root: &Path) -> Option<GitDirPointer> { |
| 286 | let search_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); |
| 287 | for ancestor in search_root.ancestors() { |
| 288 | let git_file = ancestor.join(".git"); |
| 289 | if !git_file.is_file() { |
| 290 | continue; |
| 291 | } |
| 292 | |
| 293 | let contents = fs::read_to_string(&git_file).ok()?; |
| 294 | let value = contents |
| 295 | .lines() |
| 296 | .find_map(|line| line.strip_prefix("gitdir:"))? |
| 297 | .trim(); |
| 298 | if value.is_empty() { |
| 299 | return None; |
| 300 | } |
| 301 | |
| 302 | let path = PathBuf::from(value); |
| 303 | let resolved = if path.is_absolute() { |
| 304 | path |
| 305 | } else { |
| 306 | ancestor.join(path) |
| 307 | }; |
| 308 | |
| 309 | return Some(GitDirPointer { |
| 310 | git_dir: resolved.canonicalize().ok()?, |
| 311 | git_file: git_file.canonicalize().ok()?, |
| 312 | }); |
| 313 | } |
| 314 | |
| 315 | None |
| 316 | } |
| 317 | |
| 318 | fn resolve_git_common_dir(git_dir: &Path) -> Option<PathBuf> { |
| 319 | let contents = fs::read_to_string(git_dir.join("commondir")).ok()?; |
| 320 | let value = contents.lines().next()?.trim(); |
| 321 | if value.is_empty() { |
| 322 | return None; |
| 323 | } |
| 324 | |
| 325 | let path = PathBuf::from(value); |
| 326 | let resolved = if path.is_absolute() { |
| 327 | path |
| 328 | } else { |
| 329 | git_dir.join(path) |
| 330 | }; |
| 331 | |
| 332 | resolved.canonicalize().ok() |
| 333 | } |
| 334 | |
| 335 | fn worktree_metadata_points_back_to_workspace(git_dir: &Path, expected_git_file: &Path) -> bool { |
| 336 | let Some(actual_git_file) = resolve_gitdir_back_pointer(git_dir) else { |
| 337 | return false; |
| 338 | }; |
| 339 | actual_git_file == expected_git_file |
| 340 | } |
| 341 | |
| 342 | fn resolve_gitdir_back_pointer(git_dir: &Path) -> Option<PathBuf> { |
| 343 | let contents = fs::read_to_string(git_dir.join("gitdir")).ok()?; |
| 344 | let value = contents.lines().next()?.trim(); |
| 345 | if value.is_empty() { |
| 346 | return None; |
| 347 | } |
| 348 | |
| 349 | let path = PathBuf::from(value); |
| 350 | let resolved = if path.is_absolute() { |
| 351 | path |
| 352 | } else { |
| 353 | git_dir.join(path) |
| 354 | }; |
| 355 | |
| 356 | resolved.canonicalize().ok() |
| 357 | } |
| 358 | |
| 359 | /// A directory tree where writes are allowed, with optional read-only subpaths. |
| 360 | /// |
| 361 | /// This allows fine-grained control like "allow writes to /project but not /project/.deepseek". |
| 362 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 363 | pub struct WritableRoot { |
| 364 | /// The root directory where writes are allowed. |
| 365 | pub root: PathBuf, |
| 366 | |
| 367 | /// Subdirectories within root that should remain read-only. |
| 368 | pub read_only_subpaths: Vec<PathBuf>, |
| 369 | } |
| 370 | |
| 371 | impl WritableRoot { |
| 372 | /// Create a new writable root with no read-only exceptions. |
| 373 | pub fn new(root: PathBuf) -> Self { |
| 374 | Self { |
| 375 | root, |
| 376 | read_only_subpaths: vec![], |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | /// Create a writable root with specific read-only subpaths. |
| 381 | pub fn with_exceptions(root: PathBuf, read_only: Vec<PathBuf>) -> Self { |
| 382 | Self { |
| 383 | root, |
| 384 | read_only_subpaths: read_only, |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | /// Check if a path is writable under this root. |
| 389 | /// |
| 390 | /// Returns true if the path is under the root and not under any read-only subpath. |
| 391 | pub fn is_path_writable(&self, path: &Path) -> bool { |
| 392 | // Must be under the root |
| 393 | if !path.starts_with(&self.root) { |
| 394 | return false; |
| 395 | } |
| 396 | |
| 397 | // Must not be under any read-only subpath |
| 398 | for subpath in &self.read_only_subpaths { |
| 399 | if path.starts_with(subpath) { |
| 400 | return false; |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | true |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | /// Unified trait for platform-specific sandbox executors (#2186). |
| 409 | /// |
| 410 | /// Platform implementations can use this trait to convert a policy into |
| 411 | /// wrapper-specific rules. The current `SandboxManager` command path does not |
| 412 | /// dispatch through this trait yet. |
| 413 | pub trait SandboxExecutor { |
| 414 | /// Prepare a sandboxed execution environment from a command spec. |
| 415 | /// |
| 416 | /// Returns the transformed command, environment, and sandbox metadata |
| 417 | /// needed to spawn the process. |
| 418 | fn prepare(&self, spec: &CommandSpec) -> io::Result<ExecEnv>; |
| 419 | |
| 420 | /// Check if a command failure was caused by sandbox denial. |
| 421 | fn was_denied(&self, exit_code: i32, stderr: &str) -> bool; |
| 422 | |
| 423 | /// Get a human-readable description of why the sandbox blocked the command. |
| 424 | fn denial_message(&self, stderr: &str) -> String; |
| 425 | |
| 426 | /// Returns the type of sandbox this executor provides. |
| 427 | fn sandbox_type(&self) -> super::SandboxType; |
| 428 | } |
| 429 | |
| 430 | /// Map a command safety classification to the appropriate sandbox policy (#2186). |
| 431 | /// |
| 432 | /// - `Safe` / `WorkspaceSafe` → use the default sandbox policy |
| 433 | /// - `RequiresApproval` → user must approve before execution (handled by caller) |
| 434 | /// - `Dangerous` → blocked unless in YOLO mode with trust |
| 435 | pub fn map_safety_level_to_behavior( |
| 436 | level: SafetyLevel, |
| 437 | default_policy: &SandboxPolicy, |
| 438 | ) -> SandboxPolicyBehavior { |
| 439 | match level { |
| 440 | SafetyLevel::Safe | SafetyLevel::WorkspaceSafe => { |
| 441 | SandboxPolicyBehavior::Sandboxed(default_policy.clone()) |
| 442 | } |
| 443 | SafetyLevel::RequiresApproval => SandboxPolicyBehavior::RequiresApproval, |
| 444 | SafetyLevel::Dangerous => SandboxPolicyBehavior::Blocked, |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | /// Behavior decision for a sandboxed command based on safety level. |
| 449 | #[derive(Debug, Clone)] |
| 450 | pub enum SandboxPolicyBehavior { |
| 451 | /// Execute with the given sandbox policy. |
| 452 | Sandboxed(SandboxPolicy), |
| 453 | /// User approval required before execution. |
| 454 | RequiresApproval, |
| 455 | /// Block execution entirely (unless YOLO+trust). |
| 456 | Blocked, |
| 457 | } |
| 458 | |
| 459 | #[cfg(test)] |
| 460 | mod tests { |
| 461 | use super::*; |
| 462 | |
| 463 | #[test] |
| 464 | fn test_default_policy() { |
| 465 | let policy = SandboxPolicy::default(); |
| 466 | assert!(matches!(policy, SandboxPolicy::WorkspaceWrite { .. })); |
| 467 | assert!(!policy.has_network_access()); |
| 468 | assert!(policy.should_sandbox()); |
| 469 | } |
| 470 | |
| 471 | #[test] |
| 472 | fn test_full_access_policy() { |
| 473 | let policy = SandboxPolicy::DangerFullAccess; |
| 474 | assert!(policy.has_full_disk_write_access()); |
| 475 | assert!(policy.has_network_access()); |
| 476 | assert!(!policy.should_sandbox()); |
| 477 | } |
| 478 | |
| 479 | #[test] |
| 480 | fn posture_labels_name_the_binding_fact() { |
| 481 | // DGF-02: the read-only label must state that approval cannot lift |
| 482 | // the sandbox — that sentence is what stops the model (and the |
| 483 | // user) from treating a denial as a bug to debug. |
| 484 | let read_only = SandboxPolicy::ReadOnly.posture_label(); |
| 485 | assert!(read_only.contains("read-only"), "{read_only}"); |
| 486 | assert!(read_only.contains("approval cannot lift"), "{read_only}"); |
| 487 | |
| 488 | assert!( |
| 489 | SandboxPolicy::default() |
| 490 | .posture_label() |
| 491 | .starts_with("workspace-write"), |
| 492 | ); |
| 493 | assert!( |
| 494 | SandboxPolicy::DangerFullAccess |
| 495 | .posture_label() |
| 496 | .contains("full access"), |
| 497 | ); |
| 498 | assert!( |
| 499 | SandboxPolicy::ExternalSandbox { |
| 500 | network_access: false |
| 501 | } |
| 502 | .posture_label() |
| 503 | .contains("network blocked"), |
| 504 | ); |
| 505 | } |
| 506 | |
| 507 | #[test] |
| 508 | fn test_read_only_policy() { |
| 509 | let policy = SandboxPolicy::ReadOnly; |
| 510 | assert!(!policy.has_full_disk_write_access()); |
| 511 | assert!(!policy.has_network_access()); |
| 512 | assert!(policy.should_sandbox()); |
| 513 | } |
| 514 | |
| 515 | #[test] |
| 516 | fn test_workspace_with_network() { |
| 517 | let policy = SandboxPolicy::workspace_with_network(); |
| 518 | assert!(policy.has_network_access()); |
| 519 | assert!(policy.should_sandbox()); |
| 520 | } |
| 521 | |
| 522 | #[test] |
| 523 | fn workspace_write_includes_git_worktree_metadata_roots() { |
| 524 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 525 | let common_git_dir = tmp.path().join("main-repo").join(".git"); |
| 526 | let worktree_git_dir = common_git_dir.join("worktrees").join("feature"); |
| 527 | let worktree = tmp.path().join("feature-worktree"); |
| 528 | std::fs::create_dir_all(&worktree_git_dir).expect("mkdir gitdir"); |
| 529 | std::fs::create_dir_all(&worktree).expect("mkdir worktree"); |
| 530 | std::fs::write( |
| 531 | worktree.join(".git"), |
| 532 | format!("gitdir: {}\n", worktree_git_dir.display()), |
| 533 | ) |
| 534 | .expect("write git pointer"); |
| 535 | std::fs::write(worktree_git_dir.join("commondir"), "../..").expect("write commondir"); |
| 536 | std::fs::write( |
| 537 | worktree_git_dir.join("gitdir"), |
| 538 | worktree.join(".git").display().to_string(), |
| 539 | ) |
| 540 | .expect("write gitdir back pointer"); |
| 541 | |
| 542 | let policy = SandboxPolicy::WorkspaceWrite { |
| 543 | writable_roots: vec![worktree.clone()], |
| 544 | network_access: true, |
| 545 | exclude_tmpdir: true, |
| 546 | exclude_slash_tmp: true, |
| 547 | }; |
| 548 | |
| 549 | let root_paths: Vec<PathBuf> = policy |
| 550 | .get_writable_roots(&worktree) |
| 551 | .into_iter() |
| 552 | .map(|root| root.root) |
| 553 | .collect(); |
| 554 | |
| 555 | assert!(root_paths.contains(&worktree.canonicalize().expect("canonical worktree"))); |
| 556 | assert!(root_paths.contains(&worktree_git_dir.canonicalize().expect("canonical gitdir"))); |
| 557 | assert!(root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git"))); |
| 558 | } |
| 559 | |
| 560 | #[test] |
| 561 | fn workspace_write_resolves_git_worktree_metadata_from_subdirectory() { |
| 562 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 563 | let common_git_dir = tmp.path().join("main-repo").join(".git"); |
| 564 | let worktree_git_dir = common_git_dir.join("worktrees").join("feature"); |
| 565 | let worktree = tmp.path().join("feature-worktree"); |
| 566 | let nested = worktree.join("crates").join("cli"); |
| 567 | std::fs::create_dir_all(&worktree_git_dir).expect("mkdir gitdir"); |
| 568 | std::fs::create_dir_all(&nested).expect("mkdir nested worktree path"); |
| 569 | std::fs::write( |
| 570 | worktree.join(".git"), |
| 571 | format!("gitdir: {}\n", worktree_git_dir.display()), |
| 572 | ) |
| 573 | .expect("write git pointer"); |
| 574 | std::fs::write(worktree_git_dir.join("commondir"), "../..").expect("write commondir"); |
| 575 | std::fs::write( |
| 576 | worktree_git_dir.join("gitdir"), |
| 577 | worktree.join(".git").display().to_string(), |
| 578 | ) |
| 579 | .expect("write gitdir back pointer"); |
| 580 | |
| 581 | let policy = SandboxPolicy::WorkspaceWrite { |
| 582 | writable_roots: vec![], |
| 583 | network_access: true, |
| 584 | exclude_tmpdir: true, |
| 585 | exclude_slash_tmp: true, |
| 586 | }; |
| 587 | |
| 588 | let root_paths: Vec<PathBuf> = policy |
| 589 | .get_writable_roots(&nested) |
| 590 | .into_iter() |
| 591 | .map(|root| root.root) |
| 592 | .collect(); |
| 593 | |
| 594 | assert!(root_paths.contains(&nested.canonicalize().expect("canonical nested cwd"))); |
| 595 | assert!(root_paths.contains(&worktree_git_dir.canonicalize().expect("canonical gitdir"))); |
| 596 | assert!(root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git"))); |
| 597 | } |
| 598 | |
| 599 | #[test] |
| 600 | fn workspace_write_rejects_non_reciprocal_git_worktree_metadata() { |
| 601 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 602 | let common_git_dir = tmp.path().join("main-repo").join(".git"); |
| 603 | let worktree_git_dir = common_git_dir.join("worktrees").join("feature"); |
| 604 | let worktree = tmp.path().join("feature-worktree"); |
| 605 | let other_worktree = tmp.path().join("other-worktree"); |
| 606 | std::fs::create_dir_all(&worktree_git_dir).expect("mkdir gitdir"); |
| 607 | std::fs::create_dir_all(&worktree).expect("mkdir worktree"); |
| 608 | std::fs::create_dir_all(&other_worktree).expect("mkdir other worktree"); |
| 609 | std::fs::write( |
| 610 | worktree.join(".git"), |
| 611 | format!("gitdir: {}\n", worktree_git_dir.display()), |
| 612 | ) |
| 613 | .expect("write git pointer"); |
| 614 | std::fs::write(worktree_git_dir.join("commondir"), "../..").expect("write commondir"); |
| 615 | std::fs::write( |
| 616 | worktree_git_dir.join("gitdir"), |
| 617 | other_worktree.join(".git").display().to_string(), |
| 618 | ) |
| 619 | .expect("write mismatched gitdir back pointer"); |
| 620 | std::fs::write( |
| 621 | other_worktree.join(".git"), |
| 622 | "gitdir: /tmp/not-this-worktree\n", |
| 623 | ) |
| 624 | .expect("write other git pointer"); |
| 625 | |
| 626 | let policy = SandboxPolicy::WorkspaceWrite { |
| 627 | writable_roots: vec![worktree.clone()], |
| 628 | network_access: true, |
| 629 | exclude_tmpdir: true, |
| 630 | exclude_slash_tmp: true, |
| 631 | }; |
| 632 | |
| 633 | let root_paths: Vec<PathBuf> = policy |
| 634 | .get_writable_roots(&worktree) |
| 635 | .into_iter() |
| 636 | .map(|root| root.root) |
| 637 | .collect(); |
| 638 | |
| 639 | assert!(root_paths.contains(&worktree.canonicalize().expect("canonical worktree"))); |
| 640 | assert!(!root_paths.contains(&worktree_git_dir.canonicalize().expect("canonical gitdir"))); |
| 641 | assert!( |
| 642 | !root_paths.contains(&common_git_dir.canonicalize().expect("canonical common git")) |
| 643 | ); |
| 644 | } |
| 645 | |
| 646 | #[test] |
| 647 | fn test_writable_root_basic() { |
| 648 | let root = WritableRoot::new(PathBuf::from("/project")); |
| 649 | assert!(root.is_path_writable(Path::new("/project/src/main.rs"))); |
| 650 | assert!(!root.is_path_writable(Path::new("/other/file.txt"))); |
| 651 | } |
| 652 | |
| 653 | #[test] |
| 654 | fn test_writable_root_with_exceptions() { |
| 655 | let root = WritableRoot::with_exceptions( |
| 656 | PathBuf::from("/project"), |
| 657 | vec![PathBuf::from("/project/.deepseek")], |
| 658 | ); |
| 659 | assert!(root.is_path_writable(Path::new("/project/src/main.rs"))); |
| 660 | assert!(!root.is_path_writable(Path::new("/project/.deepseek/config"))); |
| 661 | } |
| 662 | |
| 663 | #[test] |
| 664 | fn test_safety_level_mapping() { |
| 665 | let default = SandboxPolicy::default(); |
| 666 | |
| 667 | // Safe commands get sandboxed |
| 668 | assert!(matches!( |
| 669 | map_safety_level_to_behavior(SafetyLevel::Safe, &default), |
| 670 | SandboxPolicyBehavior::Sandboxed(_) |
| 671 | )); |
| 672 | assert!(matches!( |
| 673 | map_safety_level_to_behavior(SafetyLevel::WorkspaceSafe, &default), |
| 674 | SandboxPolicyBehavior::Sandboxed(_) |
| 675 | )); |
| 676 | |
| 677 | // RequiresApproval gets RequiresApproval |
| 678 | assert!(matches!( |
| 679 | map_safety_level_to_behavior(SafetyLevel::RequiresApproval, &default), |
| 680 | SandboxPolicyBehavior::RequiresApproval |
| 681 | )); |
| 682 | |
| 683 | // Dangerous gets Blocked |
| 684 | assert!(matches!( |
| 685 | map_safety_level_to_behavior(SafetyLevel::Dangerous, &default), |
| 686 | SandboxPolicyBehavior::Blocked |
| 687 | )); |
| 688 | } |
| 689 | |
| 690 | #[test] |
| 691 | fn test_policy_serialization() { |
| 692 | let policy = SandboxPolicy::WorkspaceWrite { |
| 693 | writable_roots: vec![PathBuf::from("/extra")], |
| 694 | network_access: true, |
| 695 | exclude_tmpdir: false, |
| 696 | exclude_slash_tmp: false, |
| 697 | }; |
| 698 | |
| 699 | let json = serde_json::to_string(&policy).unwrap(); |
| 700 | assert!(json.contains("workspace-write")); |
| 701 | |
| 702 | let parsed: SandboxPolicy = serde_json::from_str(&json).unwrap(); |
| 703 | assert_eq!(policy, parsed); |
| 704 | } |
| 705 | } |
| 706 |