sofos 0.4.0

An interactive AI coding agent for your terminal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
//! Operating-system confinement for shell commands.
//!
//! In the sandboxed mode the permission gate stops refusing unknown or
//! structurally unusual commands and instead runs them confined: writes
//! are limited to the workspace and the temporary directories, and the
//! network is closed where the operating system allows it. The model
//! can use the shell freely while the kernel keeps side effects inside
//! the project.
//!
//! Each platform wraps the shell with its native primitive:
//! - macOS: Seatbelt, via `sandbox-exec`.
//! - Linux: Bubblewrap, via `bwrap`.
//! - Windows: a restricted access token combined with an allow-write
//!   rule on the workspace, used to spawn the shell. The network is
//!   left open because closing it on Windows needs administrator
//!   privileges and applies to the whole user account.
//!
//! On platforms without a supported wrapper, [`confined_invocation`]
//! returns `None` and the caller keeps the permission checks as the
//! only boundary.

use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};

#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
pub mod windows;

/// Locations a confined command may write to, whether it may reach the
/// network, and which paths it may not read. `allow_network`, the read
/// lists, and `write_protect_subpaths` are consulted by the macOS and
/// Linux backends only; the Windows backend leaves the network open and
/// reads unrestricted (not gated by this policy).
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
pub struct SandboxPolicy {
    pub writable_roots: Vec<PathBuf>,
    pub allow_network: bool,
    /// Directory subtrees and files the confined command may not read,
    /// enforced by the kernel so a denied path cannot be reached even
    /// when its name never appears as a command argument.
    pub read_deny_subpaths: Vec<PathBuf>,
    /// Absolute glob patterns (with the glob-free prefix canonicalized) for
    /// read denies whose prefix is a system tree outside the workspace and
    /// home, so they cannot be a subpath mask without blanking that tree.
    /// macOS compiles each into a precise `file-read*` regex deny; Linux
    /// cannot match a glob, so a confined command is refused while any is
    /// set.
    pub read_deny_globs: Vec<String>,
    /// Subpaths that stay readable even when a broader entry denies them,
    /// so a specific allow can carve an exception out of a denied tree.
    pub read_allow_subpaths: Vec<PathBuf>,
    /// Subpaths that stay readable but never writable, even inside a
    /// writable root: project metadata a confined command must not
    /// rewrite, since doing so could plant a hook or relax the next
    /// command's gate.
    pub write_protect_subpaths: Vec<PathBuf>,
}

impl SandboxPolicy {
    /// Confine writes to the workspace and the system temporary
    /// directories, with the network closed. Temporary directories are
    /// included because common build and test tools write there.
    pub fn for_workspace(workspace: &Path) -> Self {
        let mut writable_roots = vec![workspace.to_path_buf()];
        for dir in temporary_directories() {
            if !writable_roots.contains(&dir) {
                writable_roots.push(dir);
            }
        }
        Self {
            writable_roots,
            allow_network: false,
            read_deny_subpaths: Vec::new(),
            read_deny_globs: Vec::new(),
            read_allow_subpaths: Vec::new(),
            write_protect_subpaths: metadata_protect_subpaths(workspace),
        }
    }

    /// Add the kernel-enforced read rules from the workspace's `Read(...)`
    /// deny and allow patterns. A literal deny, or a glob deny whose
    /// glob-free prefix is inside the workspace or home, becomes a subpath
    /// the command may not read. A glob deny onto a system tree outside both
    /// — which cannot be masked without blanking that tree — is kept as a
    /// `read_deny_globs` entry for a precise per-platform deny instead of
    /// being dropped. An allow is kept only when it carves an exception out
    /// of a denied subtree — an exact path strictly inside a deny — so a
    /// broad or glob allow cannot re-open a denied path.
    pub fn with_read_rules(mut self, workspace: &Path, deny: &[String], allow: &[String]) -> Self {
        let mut deny_subpaths: Vec<PathBuf> = Vec::new();
        let mut deny_globs: Vec<String> = Vec::new();
        for pattern in deny {
            match resolve_deny(pattern, workspace) {
                // Drop a deny that covers the workspace itself or an ancestor
                // of it: the confined command runs inside the workspace and
                // must be able to read it, so such a deny would break the
                // command rather than hide a secret.
                DenyResolution::Subpath(subpath) if !workspace.starts_with(&subpath) => {
                    deny_subpaths.push(subpath)
                }
                DenyResolution::Glob(glob) => deny_globs.push(glob),
                _ => {}
            }
        }
        self.read_allow_subpaths = allow
            .iter()
            .filter_map(|p| resolve_allow_subpath(p, workspace))
            .filter(|a| {
                !deny_subpaths.contains(a) && deny_subpaths.iter().any(|d| a.starts_with(d))
            })
            .collect();
        self.read_deny_subpaths = deny_subpaths;
        self.read_deny_globs = deny_globs;
        self
    }

    /// Add kernel-enforced write protection for the workspace's `Write(...)`
    /// deny patterns: each becomes a subpath the confined command may read
    /// but not write, so a redirect such as `echo x > blocked/path` is
    /// refused the same way the `write_file` tool already refuses it. Only
    /// paths inside the writable workspace are added — a write outside it is
    /// already refused — and the workspace root itself is never protected,
    /// which would make the whole workspace read-only. A glob deny is
    /// widened to its glob-free prefix, the same way a read deny is; a glob
    /// onto a system tree outside the workspace is ignored (writes there are
    /// already refused).
    pub fn with_write_deny_rules(mut self, workspace: &Path, deny: &[String]) -> Self {
        for pattern in deny {
            let DenyResolution::Subpath(subpath) = resolve_deny(pattern, workspace) else {
                continue;
            };
            if subpath.starts_with(workspace)
                && subpath != workspace
                && !self.write_protect_subpaths.contains(&subpath)
            {
                self.write_protect_subpaths.push(subpath);
            }
        }
        self
    }
}

/// Characters a `Read(...)` pattern can use as glob metacharacters; a path
/// component holding any of them is not a literal path segment.
const GLOB_METACHARACTERS: &[char] = &['*', '?', '[', ']', '{', '}'];

/// Outcome of resolving a `Read(...)` or `Write(...)` deny pattern for the
/// kernel rules.
enum DenyResolution {
    /// A concrete subpath the kernel can mask directly.
    Subpath(PathBuf),
    /// A glob whose glob-free prefix is a system tree outside the workspace
    /// and home, so masking the prefix would blank a tree the command needs.
    /// The absolute glob (prefix canonicalized) is kept for a precise
    /// per-platform deny — a macOS regex, or a Linux refusal to confine.
    Glob(String),
    /// Nothing the kernel rules can use (an empty pattern).
    Nothing,
}

/// Expand a trimmed rule pattern to an absolute, lexically normalized path:
/// `~` / `~/...` resolve against `HOME`, an absolute path is taken as-is,
/// and a relative path joins the workspace. `.` and `..` are folded.
fn expand_rule_pattern(trimmed: &str, workspace: &Path) -> Option<PathBuf> {
    let expanded = if trimmed == "~" {
        PathBuf::from(std::env::var_os("HOME")?)
    } else if let Some(rest) = trimmed.strip_prefix("~/") {
        PathBuf::from(std::env::var_os("HOME")?).join(rest)
    } else if Path::new(trimmed).is_absolute() {
        PathBuf::from(trimmed)
    } else {
        workspace.join(trimmed.strip_prefix("./").unwrap_or(trimmed))
    };
    Some(crate::tools::utils::lexically_normalize(&expanded))
}

/// Resolve a deny pattern for the kernel rules. A trailing `/**` is dropped
/// (a subpath rule already covers the whole tree); a literal path, or a glob
/// whose glob-free prefix is maskable (inside the workspace or home),
/// becomes a [`DenyResolution::Subpath`]; a glob onto an unmaskable system
/// tree becomes a [`DenyResolution::Glob`] rather than being dropped. The
/// glob-free prefix is canonicalized — resolving symlinks like macOS `/tmp`
/// -> `/private/tmp` — so the rule matches the path the kernel sees even
/// when the target does not exist yet.
fn resolve_deny(pattern: &str, workspace: &Path) -> DenyResolution {
    let trimmed = pattern.strip_suffix("/**").unwrap_or(pattern).trim();
    if trimmed.is_empty() {
        return DenyResolution::Nothing;
    }
    let Some(normalized) = expand_rule_pattern(trimmed, workspace) else {
        return DenyResolution::Nothing;
    };
    let prefix = longest_glob_free_prefix(&normalized);
    let resolved = canonicalize_existing_prefix(&prefix);
    // A literal path, or a glob whose prefix can be masked without blanking a
    // tree the command needs (inside the workspace or home), is a subpath
    // mask. Otherwise keep the glob, prefix canonicalized, for a precise
    // per-platform deny. The maskability check is on the resolved path, so a
    // prefix that resolves through a symlink onto a system tree (for example
    // `~/link` -> `/etc`) is handled as a glob too.
    if prefix == normalized || widened_prefix_is_maskable(&resolved, workspace) {
        return DenyResolution::Subpath(resolved);
    }
    match normalized.strip_prefix(&prefix) {
        Ok(tail) => DenyResolution::Glob(resolved.join(tail).to_string_lossy().into_owned()),
        Err(_) => DenyResolution::Nothing,
    }
}

/// Resolve an allow pattern to one absolute subpath, or `None` for an empty
/// pattern or a glob: an allow must stay an exact path, so a glob allow is
/// left to the per-argument read check and cannot re-open a denied path. The
/// existing prefix is canonicalized like a deny.
fn resolve_allow_subpath(pattern: &str, workspace: &Path) -> Option<PathBuf> {
    let trimmed = pattern.trim();
    if trimmed.is_empty() || trimmed.contains(GLOB_METACHARACTERS) {
        return None;
    }
    let normalized = expand_rule_pattern(trimmed, workspace)?;
    Some(canonicalize_existing_prefix(&normalized))
}

/// Whether a glob deny widened up to the resolved `prefix` may be masked in
/// the kernel. True inside the workspace or the user's home directory, where
/// masking a parent only hides files belonging to the project or the user;
/// false for a system path, where blanking the parent (for example `/etc`)
/// would break the confined command, so that deny stays a per-argument check
/// only. `prefix` is the canonicalized path the kernel would actually mask,
/// and the home directory is canonicalized to match, so a symlink in either
/// cannot make the comparison disagree with the path that gets masked.
fn widened_prefix_is_maskable(prefix: &Path, workspace: &Path) -> bool {
    prefix.starts_with(workspace)
        || std::env::var_os("HOME")
            .is_some_and(|home| prefix.starts_with(canonicalize_existing_prefix(Path::new(&home))))
}

/// The longest leading path made only of components without a glob
/// character: `/a/b*/c` yields `/a`, and a path with no glob is returned
/// whole. Used to widen a glob deny to a real path the kernel can hide
/// instead of dropping it. The result stays absolute when `path` is,
/// because the root component carries no glob.
fn longest_glob_free_prefix(path: &Path) -> PathBuf {
    let mut prefix = PathBuf::new();
    for component in path.components() {
        // Only a named path segment can carry a glob; stop at the first one
        // that does. Root and drive-prefix components are always kept, so a
        // Windows verbatim prefix (`\\?\…`) is not mistaken for a glob.
        if let std::path::Component::Normal(part) = component {
            if part.to_string_lossy().contains(GLOB_METACHARACTERS) {
                break;
            }
        }
        prefix.push(component);
    }
    prefix
}

/// Canonicalize `path`, resolving symlinks even when the leaf does not
/// exist yet: canonicalize the longest existing ancestor and re-append the
/// missing trailing components. Plain `canonicalize` fails outright on a
/// missing target, which would leave a symlinked prefix (such as macOS
/// `/tmp` -> `/private/tmp`, or a workspace-relative path whose symlink
/// points elsewhere) unresolved.
pub(super) fn canonicalize_existing_prefix(path: &Path) -> PathBuf {
    if let Ok(canonical) = std::fs::canonicalize(path) {
        return canonical;
    }
    let mut trailing: Vec<&OsStr> = Vec::new();
    let mut current = path;
    while let Some(parent) = current.parent() {
        if let Some(name) = current.file_name() {
            trailing.push(name);
        }
        if let Ok(mut canonical) = std::fs::canonicalize(parent) {
            canonical.extend(trailing.iter().rev());
            return canonical;
        }
        current = parent;
    }
    path.to_path_buf()
}

/// The repository's Git directory. Write-protected like the other
/// metadata directories, but lifted for commands that run only git, which
/// need to write it for `checkout`, `config`, and similar.
pub(super) const GIT_METADATA_DIR: &str = ".git";

/// Project metadata kept read-only inside the sandbox even though the
/// workspace is writable. Each can run code or relax the command gate on
/// a later turn — Git hooks/config, the `.sofos` permission rules
/// (re-read every command), and agent settings — so a confined command
/// must not rewrite them. Joined onto the workspace as-is so the path
/// matches the writable-root bind.
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
const METADATA_PROTECT_DIRS: &[&str] =
    &[GIT_METADATA_DIR, ".sofos", ".agents", ".claude", ".codex"];

#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
fn metadata_protect_subpaths(workspace: &Path) -> Vec<PathBuf> {
    METADATA_PROTECT_DIRS
        .iter()
        .map(|name| workspace.join(name))
        .collect()
}

/// System temporary directories that stay writable inside the sandbox.
/// Paths are canonicalised so the rules match the real location the
/// kernel sees (on macOS `/tmp` resolves to `/private/tmp`).
fn temporary_directories() -> Vec<PathBuf> {
    let mut candidates: Vec<PathBuf> = Vec::new();
    #[cfg(unix)]
    {
        if let Some(tmpdir) = std::env::var_os("TMPDIR") {
            if !tmpdir.is_empty() {
                candidates.push(PathBuf::from(tmpdir));
            }
        }
        candidates.push(PathBuf::from("/tmp"));
    }
    #[cfg(windows)]
    {
        for key in ["TEMP", "TMP", "LOCALAPPDATA"] {
            if let Some(value) = std::env::var_os(key) {
                if !value.is_empty() {
                    let mut path = PathBuf::from(value);
                    if key == "LOCALAPPDATA" {
                        path.push("Temp");
                    }
                    candidates.push(path);
                }
            }
        }
    }

    let mut roots = Vec::new();
    for candidate in candidates {
        let resolved = std::fs::canonicalize(&candidate).unwrap_or(candidate);
        if resolved.is_dir() && !roots.contains(&resolved) {
            roots.push(resolved);
        }
    }
    roots
}

/// Whether a usable sandbox wrapper is present on this machine. When it
/// is false the caller keeps the permission checks as the boundary
/// instead of running a command unconfined.
#[cfg(target_os = "macos")]
pub fn is_available() -> bool {
    Path::new(macos::SANDBOX_EXEC_PATH).is_file()
}

/// Linux: Bubblewrap is an optional package, so confirm it is installed
/// and can actually create user namespaces. On hardened kernels, inside
/// containers, and on WSL1, `bwrap` is present but cannot unshare the
/// user namespace; reporting it unavailable there makes the caller fall
/// back to the permission prompt instead of failing every command with a
/// bwrap error. The network seccomp filter must also build, since it
/// closes the unix-socket hole `--unshare-net` leaves open; without it
/// the prompt is the safer boundary. The probe is cached for the process
/// lifetime.
#[cfg(target_os = "linux")]
pub fn is_available() -> bool {
    use std::sync::OnceLock;
    static USABLE: OnceLock<bool> = OnceLock::new();
    *USABLE.get_or_init(|| {
        linux::resolved_bwrap().is_some()
            && linux::bwrap_can_unshare_namespaces()
            && linux::network_seccomp_program().is_some()
    })
}

/// Build the network seccomp program for a confined Linux command, or
/// `None` if it cannot be built on this architecture.
#[cfg(target_os = "linux")]
pub fn network_seccomp_program() -> Option<seccompiler::BpfProgram> {
    linux::network_seccomp_program()
}

/// Install the network seccomp program on the current thread, or do
/// nothing when there is none (an architecture the filter does not
/// target). The caller runs this in the child between `fork` and `exec`
/// so the confined command inherits the filter; the executor, the
/// availability probe, and the end-to-end test all install through here,
/// so one path is exercised. Must not allocate after `fork`.
#[cfg(target_os = "linux")]
pub fn apply_network_seccomp(program: Option<&seccompiler::BpfProgram>) -> std::io::Result<()> {
    match program {
        Some(program) => seccompiler::apply_filter(program)
            .map_err(|_| std::io::Error::from(std::io::ErrorKind::Other)),
        None => Ok(()),
    }
}

/// Windows: the restricted-token backend is present but not yet engaged.
/// The default Windows shell (Git for Windows `sh.exe`, a Cygwin
/// binary) cannot start under the restricted access token because its
/// session-shared-memory attach is refused by the kernel. Until the
/// shell story is solved, `is_available` reports false so workspace
/// mode falls back to the permission gate on Windows. The backend
/// modules stay in the tree as the foundation for future re-enabling.
#[cfg(target_os = "windows")]
pub fn is_available() -> bool {
    false
}

/// Platforms without a wrapper never report a sandbox as available.
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
pub fn is_available() -> bool {
    false
}

/// Build the program and arguments that run `<shell> -c <command>`
/// confined by `policy`. Returns `None` when this platform has no
/// supported sandbox, signalling the caller to run the shell directly
/// and rely on the permission checks instead.
#[cfg(target_os = "macos")]
pub fn confined_invocation(
    shell: &OsStr,
    command: &str,
    policy: &SandboxPolicy,
) -> Option<(OsString, Vec<OsString>)> {
    let profile = macos::seatbelt_profile(policy)?;
    let mut args: Vec<OsString> = vec![OsString::from("-p"), OsString::from(profile)];
    args.push(shell.to_os_string());
    args.push(OsString::from("-c"));
    args.push(OsString::from(command));
    Some((OsString::from(macos::SANDBOX_EXEC_PATH), args))
}

/// Linux confinement via Bubblewrap. See the module documentation.
#[cfg(target_os = "linux")]
pub fn confined_invocation(
    shell: &OsStr,
    command: &str,
    policy: &SandboxPolicy,
) -> Option<(OsString, Vec<OsString>)> {
    // Bubblewrap masks concrete paths, not globs, so a glob read-deny onto a
    // system tree cannot be expressed here. Refuse to confine (the caller
    // then refuses the command) rather than run with the deny unenforced.
    if !policy.read_deny_globs.is_empty() {
        return None;
    }
    let program = linux::resolved_bwrap()?;
    let mut args = linux::bwrap_arguments(policy);
    args.push(OsString::from("--"));
    args.push(shell.to_os_string());
    args.push(OsString::from("-c"));
    args.push(OsString::from(command));
    Some((program.as_os_str().to_os_string(), args))
}

/// Windows does not fit the `(program, args)` shape because the spawn
/// itself goes through `CreateProcessAsUserW` rather than a wrapper
/// command; the executor calls [`windows::run_confined`] directly.
#[cfg(target_os = "windows")]
pub fn confined_invocation(
    _shell: &OsStr,
    _command: &str,
    _policy: &SandboxPolicy,
) -> Option<(OsString, Vec<OsString>)> {
    None
}

/// Platforms without a wrapper: no confinement is applied, so the caller
/// keeps the permission checks as the boundary.
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
pub fn confined_invocation(
    _shell: &OsStr,
    _command: &str,
    _policy: &SandboxPolicy,
) -> Option<(OsString, Vec<OsString>)> {
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The workspace policy marks the project's metadata directories as
    /// write-protected so a confined command cannot plant a Git hook,
    /// rewrite Git or `.sofos` config, or edit agent settings even though
    /// the workspace around them is writable.
    #[test]
    fn workspace_policy_write_protects_metadata_dirs() {
        let dir = tempfile::tempdir().unwrap();
        let policy = SandboxPolicy::for_workspace(dir.path());
        for name in [".git", ".sofos", ".agents", ".claude", ".codex"] {
            assert!(
                policy
                    .write_protect_subpaths
                    .contains(&dir.path().join(name)),
                "{name} must be write-protected"
            );
        }
    }

    #[cfg(unix)]
    #[test]
    fn workspace_policy_includes_workspace_and_closes_network_on_unix() {
        let dir = tempfile::tempdir().unwrap();
        let policy = SandboxPolicy::for_workspace(dir.path());
        assert!(!policy.allow_network);
        let canonical = std::fs::canonicalize(dir.path()).unwrap();
        assert!(
            policy.writable_roots.contains(&canonical)
                || policy.writable_roots.contains(&dir.path().to_path_buf())
        );
    }

    /// An allow is kept only as an exact exception strictly inside a
    /// denied subtree; a broad or glob allow, or an allow outside the
    /// deny, is dropped so it cannot re-open a denied path.
    #[test]
    fn read_rules_keep_only_exceptions_inside_a_deny() {
        let dir = tempfile::tempdir().unwrap();
        let workspace = std::fs::canonicalize(dir.path()).unwrap();
        let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
            &workspace,
            &["./secret/**".to_string()],
            &[
                "./**".to_string(),
                "./other".to_string(),
                "./secret/ok.txt".to_string(),
            ],
        );
        let secret = workspace.join("secret");
        assert_eq!(policy.read_deny_subpaths, vec![secret.clone()]);
        assert_eq!(policy.read_allow_subpaths, vec![secret.join("ok.txt")]);
    }

    /// An in-workspace `Write(...)` deny becomes a write-protected subpath;
    /// a deny outside the workspace, or one covering the workspace root, is
    /// not added — the first is already unwritable and the second would make
    /// the whole workspace read-only.
    #[test]
    fn write_deny_protects_only_in_workspace_subpaths() {
        let dir = tempfile::tempdir().unwrap();
        let workspace = std::fs::canonicalize(dir.path()).unwrap();
        let policy = SandboxPolicy::for_workspace(&workspace).with_write_deny_rules(
            &workspace,
            &[
                "./build/output".to_string(),
                "/etc/passwd".to_string(),
                "./".to_string(),
            ],
        );
        assert!(
            policy
                .write_protect_subpaths
                .contains(&workspace.join("build/output")),
            "an in-workspace write deny must be protected: {:?}",
            policy.write_protect_subpaths
        );
        assert!(
            !policy
                .write_protect_subpaths
                .iter()
                .any(|p| p.ends_with("passwd")),
            "an out-of-workspace write deny must not be added"
        );
        assert!(
            !policy.write_protect_subpaths.contains(&workspace),
            "the workspace root must never be write-protected"
        );
    }

    /// A `..` segment is collapsed so the rule matches the path the kernel
    /// sees rather than a literal `..` path that would match nothing.
    #[test]
    fn read_rule_normalizes_parent_segments() {
        let dir = tempfile::tempdir().unwrap();
        let workspace = std::fs::canonicalize(dir.path()).unwrap();
        let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
            &workspace,
            &["./secret/../private".to_string()],
            &[],
        );
        assert_eq!(policy.read_deny_subpaths, vec![workspace.join("private")]);
    }

    /// A deny with a glob in the middle is widened to its longest glob-free
    /// leading path instead of being dropped, so the kernel still hides the
    /// directory the secret lives under.
    #[test]
    fn read_deny_with_inner_glob_falls_back_to_prefix() {
        let dir = tempfile::tempdir().unwrap();
        let workspace = std::fs::canonicalize(dir.path()).unwrap();
        let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
            &workspace,
            &["./config/*/secret.env".to_string()],
            &[],
        );
        assert_eq!(policy.read_deny_subpaths, vec![workspace.join("config")]);
    }

    /// A deny whose glob-free prefix is the workspace itself is dropped
    /// rather than blocking every read in the project the confined command
    /// runs in.
    #[test]
    fn read_deny_covering_the_workspace_is_dropped() {
        let dir = tempfile::tempdir().unwrap();
        let workspace = std::fs::canonicalize(dir.path()).unwrap();
        let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
            &workspace,
            &["*/secret".to_string()],
            &[],
        );
        assert!(policy.read_deny_subpaths.is_empty());
    }

    /// A glob deny that would widen to a system directory outside the
    /// workspace and home (here `/etc`) is not masked as a subpath — blanking
    /// the whole tree would break the confined command — but it is kept as a
    /// precise glob deny (`read_deny_globs`) instead of being dropped, so the
    /// macOS backend can still enforce it with a regex.
    #[test]
    fn read_deny_with_glob_outside_workspace_and_home_is_kept_as_a_glob() {
        let dir = tempfile::tempdir().unwrap();
        let workspace = std::fs::canonicalize(dir.path()).unwrap();
        let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
            &workspace,
            &["/etc/*/passwd".to_string()],
            &[],
        );
        assert!(
            policy.read_deny_subpaths.is_empty(),
            "a glob deny widening to a system directory must not mask it"
        );
        assert_eq!(policy.read_deny_globs.len(), 1, "the glob deny is kept");
        let glob = &policy.read_deny_globs[0];
        let sep = std::path::MAIN_SEPARATOR;
        assert!(
            glob.ends_with(&format!("{sep}*{sep}passwd")) && glob.contains("etc"),
            "the kept glob keeps its pattern with the prefix canonicalized: {glob}"
        );
    }

    /// A glob deny whose glob-free prefix resolves through a symlink onto a
    /// tree outside the workspace and home is not masked as a subpath: the
    /// maskability check runs on the resolved path, so masking the whole
    /// resolved system tree (which would break the confined command) is
    /// refused. It is kept as a glob deny with the prefix canonicalized to
    /// the symlink target, so the rule matches the path the kernel sees.
    #[cfg(unix)]
    #[test]
    fn read_deny_glob_through_symlink_to_outside_is_kept_as_a_glob() {
        let ws_dir = tempfile::tempdir().unwrap();
        let workspace = std::fs::canonicalize(ws_dir.path()).unwrap();
        let outside_dir = tempfile::tempdir().unwrap();
        let outside = std::fs::canonicalize(outside_dir.path()).unwrap();
        // A workspace entry that resolves to a directory outside workspace+home.
        std::os::unix::fs::symlink(&outside, workspace.join("config")).unwrap();

        let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
            &workspace,
            &["./config/*/secret.env".to_string()],
            &[],
        );
        assert!(
            policy.read_deny_subpaths.is_empty(),
            "a glob deny whose prefix resolves outside workspace+home must not mask the resolved tree"
        );
        assert_eq!(policy.read_deny_globs.len(), 1, "the glob deny is kept");
        let glob = &policy.read_deny_globs[0];
        assert!(
            glob.starts_with(&*outside.to_string_lossy()) && glob.ends_with("/*/secret.env"),
            "the kept glob has its prefix canonicalized to the symlink target: {glob}"
        );
    }

    /// A deny on a path that does not exist yet still resolves through a
    /// symlinked prefix to the real directory, so the kernel — which sees
    /// the resolved inode — matches the rule. Without this, a create-then
    /// -read inside the command could reach the secret.
    #[cfg(unix)]
    #[test]
    fn read_rule_resolves_symlinked_prefix_for_missing_target() {
        let dir = tempfile::tempdir().unwrap();
        let workspace = std::fs::canonicalize(dir.path()).unwrap();
        let real = workspace.join("real");
        std::fs::create_dir(&real).unwrap();
        let link = workspace.join("link");
        std::os::unix::fs::symlink(&real, &link).unwrap();

        let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
            &workspace,
            &[format!("{}/missing/**", link.display())],
            &[],
        );
        assert_eq!(policy.read_deny_subpaths, vec![real.join("missing")]);
    }

    /// Linux cannot match a glob in the kernel sandbox, so a glob read-deny
    /// makes confinement refuse outright (the caller then refuses the
    /// command) rather than run with the deny unenforced.
    #[cfg(target_os = "linux")]
    #[test]
    fn linux_glob_read_deny_refuses_confinement() {
        let dir = tempfile::tempdir().unwrap();
        let mut policy = SandboxPolicy::for_workspace(dir.path());
        policy.read_deny_globs = vec!["/etc/*/passwd".to_string()];
        assert!(
            confined_invocation(std::ffi::OsStr::new("/bin/sh"), "echo hi", &policy).is_none(),
            "a glob read-deny must refuse confinement on Linux"
        );
    }

    #[cfg(target_os = "windows")]
    #[test]
    fn workspace_policy_includes_workspace_on_windows() {
        let dir = tempfile::tempdir().unwrap();
        let policy = SandboxPolicy::for_workspace(dir.path());
        assert!(!policy.allow_network);
        let canonical =
            std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
        assert!(
            policy.writable_roots.contains(&canonical)
                || policy.writable_roots.contains(&dir.path().to_path_buf())
        );
    }
}