openmoat 0.13.0

Policy checks for AI coding agents: decides allow, defer, ask or deny for every tool call from Claude Code, Codex, Cursor, OpenClaw, GitHub Copilot CLI, pi and Gemini CLI, configures Claude Code's, Codex's and Cursor's own OS sandboxes from the same policy, and keeps a local audit log.
//! `moat run` on macOS: the agent under `sandbox-exec` with the Seatbelt
//! profile generated for the session (`crate::sandbox::seatbelt`). Before
//! macOS 13 it refuses (exit 64): `sandbox-exec` there cannot read the profile.
//!
//! The Isolated tier here needs a Linux guest, since Seatbelt does not nest
//! and bubblewrap is Linux-only (ADR-018). ADR-023 picks Apple's
//! `containerization` toolchain for that; today `moat run --isolate` on macOS
//! is still a stub that refuses to run the agent (exit 64), reporting either
//! that `container` is missing or that the full implementation is still
//! coming. It never falls back to the Lightweight tier.

use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};

use anyhow::{Result, bail};
use openmoat_core::{EvalContext, Policy};

use crate::cli::IsolatedArgs;
use crate::environment::find_in;
use crate::exit::Code;
use crate::os_version;
use crate::sandbox::{Grants, Report, seatbelt_profile};

/// Part of the base system; never one found on `PATH`. Apple has deprecated
/// it, but Codex, Claude Code and Chromium still start their sandboxes with it.
const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";

/// The command the Isolated tier on macOS will drive once the implementation
/// lands (ADR-023): Apple's `containerization` CLI, which boots the Linux
/// guest and projects the virtiofs mounts.
const CONTAINER: &str = "container";

/// The agent, ready to start in its sandbox.
pub struct Confined {
    command: Command,
    report: Report,
}

pub fn confine(
    policy: &Policy,
    ctx: &EvalContext,
    grants: Grants,
    program: &Path,
    // Seatbelt denies inside a granted tree, so `kernel-self` keeps the state
    // directory wherever it is.
    _state: &Path,
) -> Result<Confined> {
    // Refused here with a plain reason rather than by `sandbox-exec` with
    // `unbound variable: TIOCSTI`; the profile keeps its deny either way.
    if let Some(reason) = os_version::run_floor(os_version::macos().as_deref()) {
        bail!("{}", refusal(&reason));
    }
    let generated = seatbelt_profile(policy, ctx, grants)?;
    let mut command = Command::new(SANDBOX_EXEC);
    command.arg("-p").arg(&generated.profile).arg(program);
    Ok(Confined {
        command,
        report: generated.report,
    })
}

/// The Isolated tier on macOS (ADR-023, #175): a stub until the Apple
/// Virtualization implementation lands. Refuses with a clear reason so no
/// one is surprised by half-work.
///
/// - `container` not on `PATH`: exit 64 with the install hint.
/// - `container` on `PATH`: exit 64 with "not yet implemented, see ADR-023".
///
/// Never a silent fallback to the Lightweight tier; the owner asked for the
/// Isolated boundary, so refusing is fail-closed.
pub fn isolate(_: &Policy, _: &EvalContext, _: Grants, _: &Path, _: &Path) -> Result<Confined> {
    bail!("{}", stub_message(&search_path(), CONTAINER))
}

pub fn inside(_: &IsolatedArgs) -> Result<Code> {
    bail!("only `openmoat run --isolate` on Linux starts this")
}

/// Why `moat run` cannot sandbox on this Mac; `None` when it can.
pub fn unavailable() -> Option<String> {
    os_version::run_floor(os_version::macos().as_deref()).map(|reason| refusal(&reason))
}

/// None: Seatbelt names the proxy's address, not only its port.
pub fn port_only_network() -> Option<String> {
    None
}

/// The terminals (`/dev/ttys*`) this process's standard input, output and
/// error are on. macOS has no `/proc` to name an open file, so each stream's
/// device number is looked up among the terminals in `/dev`.
pub fn terminals() -> Vec<String> {
    use std::os::fd::{AsFd as _, BorrowedFd};
    use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};

    let stat = |fd: BorrowedFd<'_>| {
        let file = std::fs::File::from(fd.try_clone_to_owned().ok()?);
        file.metadata().ok()
    };
    let (stdin, stdout, stderr) = (std::io::stdin(), std::io::stdout(), std::io::stderr());
    let streams = [stdin.as_fd(), stdout.as_fd(), stderr.as_fd()];
    let devices: Vec<u64> = streams
        .into_iter()
        .filter_map(stat)
        .filter(|m| m.file_type().is_char_device())
        .map(|m| m.rdev())
        .collect();
    if devices.is_empty() {
        return Vec::new();
    }
    let Ok(entries) = std::fs::read_dir("/dev") else {
        return Vec::new();
    };
    let mut found: Vec<String> = entries
        .flatten()
        .filter(|e| e.file_name().to_string_lossy().starts_with("ttys"))
        .filter_map(|e| {
            let meta = std::fs::symlink_metadata(e.path()).ok()?;
            let terminal = meta.file_type().is_char_device() && devices.contains(&meta.rdev());
            terminal.then(|| crate::context::path_string(&e.path()))
        })
        .collect();
    found.sort();
    found
}

impl Confined {
    pub fn command(&mut self) -> &mut Command {
        &mut self.command
    }

    pub fn report(&self) -> &Report {
        &self.report
    }

    /// None: the profile allows only `localhost:<port>`.
    #[expect(
        clippy::unused_self,
        reason = "the method every platform's `Confined` has; Linux's reads itself"
    )]
    pub fn warning(&self) -> Option<&str> {
        None
    }

    pub fn status(mut self) -> Result<ExitStatus> {
        Ok(self.command.status()?)
    }
}

/// What `moat run` says on a macOS too old for its profile (exit 64).
fn refusal(reason: &str) -> String {
    format!(
        "`openmoat run` {reason}. The agent was not started. The agent hooks still apply the \
         policy to each tool call: start the agent without `openmoat run`"
    )
}

/// The dirs `PATH` lists today, as [`find_in`] takes them.
fn search_path() -> Vec<PathBuf> {
    std::env::var_os("PATH")
        .map(|p| std::env::split_paths(&p).collect())
        .unwrap_or_default()
}

/// What `moat run --isolate` tells the user on macOS today, with `#175` in
/// it so hosts and tests can match on it as they did before.
fn stub_message(path: &[PathBuf], program: &str) -> String {
    let found = find_in(path, &[], program);
    if found.is_some() {
        format!(
            "`openmoat run --isolate` on macOS: not yet implemented, see ADR-023 (#175). `{program}` \
             is installed; the virtualization work ships in follow-up PRs. For now the Isolated \
             tier is Linux-only; `openmoat run` without --isolate is the Lightweight tier"
        )
    } else {
        format!(
            "`openmoat run --isolate` on macOS needs Apple's `containerization` toolchain (ADR-023, \
             #175): `{program}` is not on PATH. Install it with `brew install --cask container` \
             or from https://github.com/apple/container/releases, then re-run. Refusing rather \
             than starting the agent in a weaker tier"
        )
    }
}

#[cfg(test)]
mod tests {
    use std::fs::{self, File};
    use std::os::unix::fs::PermissionsExt as _;
    use std::path::PathBuf;

    use super::*;

    /// A directory with one executable called `name`, as `PATH` would list it.
    fn with_executable(dir: &Path, name: &str) -> PathBuf {
        let bin = dir.join(name);
        File::create(&bin).expect("creating a fake `container` binary");
        fs::set_permissions(&bin, fs::Permissions::from_mode(0o755))
            .expect("marking it executable");
        dir.to_path_buf()
    }

    /// The stub never exits 0 or 2: hosts rely on these exit codes
    /// (ADR-004), and the owner picked exit 64 for refusing to start.
    #[test]
    fn exit_code_contract_is_64_whichever_branch_the_stub_takes() {
        // The CLI maps `Err(_)` from `isolate()` to `Code::Usage` (64) in
        // `main.rs`; this test asserts the mapping stays in that enum.
        assert_eq!(Code::Usage as u8, 64);
    }

    #[test]
    fn an_old_macos_is_refused_with_a_plain_reason() {
        let reason = os_version::run_floor(Some("12.7")).unwrap();
        let message = refusal(&reason);
        assert!(
            message.starts_with("`openmoat run` needs macOS 13 or later"),
            "{message}"
        );
        assert!(
            message.contains("hooks still apply the policy"),
            "{message}"
        );
        assert!(!message.contains("unbound variable"), "{message}");
    }

    /// Only terminal devices, never `/dev/tty` or another device; under
    /// `cargo test` the streams are usually pipes, so often none.
    #[test]
    fn the_terminals_are_the_streams_own_ttys() {
        for terminal in terminals() {
            assert!(terminal.starts_with("/dev/ttys"), "{terminal}");
        }
    }

    #[test]
    fn missing_container_points_at_the_install_command() {
        let dir = tempfile::tempdir().expect("a scratch dir");
        let path = vec![dir.path().to_path_buf()];
        let message = stub_message(&path, "container-not-here");
        assert!(message.contains("ADR-023"), "{message}");
        assert!(message.contains("#175"), "{message}");
        assert!(
            message.contains("brew install --cask container"),
            "{message}"
        );
        assert!(
            !message.contains("not yet implemented"),
            "missing is a different branch: {message}"
        );
    }

    #[test]
    fn present_container_says_not_yet_implemented() {
        let dir = tempfile::tempdir().expect("a scratch dir");
        let path = vec![with_executable(dir.path(), "container")];
        let message = stub_message(&path, "container");
        assert!(message.contains("ADR-023"), "{message}");
        assert!(message.contains("#175"), "{message}");
        assert!(message.contains("not yet implemented"), "{message}");
        assert!(
            !message.contains("brew install"),
            "present is a different branch: {message}"
        );
    }

    #[test]
    fn stub_message_never_promises_a_lightweight_fallback() {
        // Fail-closed (ADR-018): the Isolated tier never silently downgrades.
        let dir = tempfile::tempdir().expect("a scratch dir");
        let missing = stub_message(&[dir.path().to_path_buf()], "nope");
        let present = stub_message(&[with_executable(dir.path(), "container")], "container");
        for message in [missing, present] {
            let lowered = message.to_lowercase();
            assert!(!lowered.contains("falls back"), "{message}");
            assert!(!lowered.contains("falling back"), "{message}");
        }
    }
}