//! 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())
        );
    }
}
