openmoat 0.9.0

Policy checks for AI coding agents: decides allow, defer, ask or deny for every tool call from Claude Code, Codex and Cursor, configures Claude Code's and Codex's own OS sandboxes from the same policy, and keeps a local audit log.
//! Lightweight tier on macOS: a Seatbelt (SBPL) profile around the whole
//! agent, generated from the IR of one session (ADR-018).
//!
//! What the generator relies on, verified with `sandbox-exec` on macOS 26:
//! - the last matching rule wins, so the policy's deny rules come last and no
//!   allow above them can re-open what they name (ADR-002);
//! - a read deny of `file-read-data` (contents) and `file-read-xattr` leaves
//!   `stat` working, so `git status` does not fail on `.env`;
//! - `require-all`/`require-not` express a rule's `!` exceptions exactly;
//! - paths are compared resolved (`/tmp` is `/private/tmp`) and in their
//!   on-disk spelling, even when a program names them in another case; a
//!   path that does not exist yet has the spelling it is created with, so on a
//!   case-insensitive disk deny rules match in either case ([`mod@filter`]);
//! - a program cannot start from a file it cannot read, and the dynamic loader
//!   needs to read `/` itself;
//! - a network address can only be `*` or `localhost`, so egress goes to the
//!   loopback port of OpenMoat's proxy, which decides each host by the policy.

use std::fmt::Write as _;

use openmoat_core::Kind;
use openmoat_core::ir::{Access, Effect, Enforcement, Rule};

use super::patterns::{
    AGENT_CONFIG, RUNS_LATER, agent_config_exclusion, agent_config_folders, push_unique,
    runs_later_exclusion, split,
};
use super::{Grants, Report};
use braces::alternatives;
use filter::{Case, filter};

mod braces;
mod filter;

/// Paths every process reads: `/` (the dynamic loader), the controlling
/// terminal (`/dev/tty`) and random devices, and `/private/var/select` (where
/// `/bin/sh` finds its shell). No other terminal: the session's own come from
/// [`Grants::terminals`], so a command cannot open another terminal of the
/// person's and read what is typed there or write to it.
const PLATFORM_READ: [&str; 8] = [
    "/",
    "/dev/null",
    "/dev/zero",
    "/dev/random",
    "/dev/urandom",
    "/dev/tty",
    "/dev/fd/**",
    "/private/var/select/**",
];

/// Devices every process writes.
const PLATFORM_WRITE: [&str; 5] = [
    "/dev/null",
    "/dev/zero",
    "/dev/tty",
    "/dev/dtracehelper",
    "/dev/fd/**",
];

/// What every process may do besides files: start programs, signal and
/// inspect its own processes, use terminals and shared memory, and look up
/// users and groups. No other system service: not the keychain
/// (`SecurityServer`), not DNS (the proxy resolves), not `trustd`, which
/// fetches certificate status from the network.
const PROCESS: &str = "\
(allow process-exec process-fork)
(allow signal (target same-sandbox))
(allow process-info* (target same-sandbox))
(allow sysctl-read)
(allow pseudo-tty)
(allow file-ioctl)
(allow ipc-posix-shm* ipc-posix-sem)
(allow mach-lookup
  (global-name \"com.apple.system.opendirectoryd.libinfo\")
  (global-name \"com.apple.system.opendirectoryd.membership\"))
";

/// The keychain's services, denied whatever the policy says: the agent and
/// every script it starts share this profile, so a token the agent could read
/// from the keychain, a project script could send away.
const KEYCHAIN: &str = "\
; never the keychain: the agent and its scripts share this profile
(deny mach-lookup
  (global-name \"com.apple.SecurityServer\")
  (global-name \"com.apple.securityd\"))
";

/// `TIOCSTI`, denied whatever the policy says: it pushes characters into the
/// input of the terminal the agent shares with the user, whose shell reads and
/// runs them once the agent exits, outside the sandbox (CVE-2017-5226).
const TERMINAL_INPUT: &str = "\
; never typing into the user's terminal: its shell would run it after the session
(deny file-ioctl (ioctl-command TIOCSTI))
";

/// A generated profile.
#[derive(Debug, Clone)]
pub struct Generated {
    /// The SBPL text `sandbox-exec -p` takes.
    pub profile: String,
    /// Losses and allowances of this backend.
    pub report: Report,
}

/// Generate the profile for `ir`, lowered for one session's project.
pub fn generate(ir: &Enforcement, grants: &Grants) -> Generated {
    let mut p = Generated {
        profile: String::from(
            "(version 1)\n\
             ; generated by OpenMoat from policy.yaml for `openmoat run` (Lightweight tier, ADR-018)\n\
             (deny default)\n",
        ),
        report: Report::default(),
    };
    p.profile.push_str(PROCESS);
    p.profile
        .push_str("; seatbelt.metadata\n(allow file-read-metadata)\n");
    let metadata = "the existence, size and times of every file stay visible (stat); \
                    contents follow the rules";
    p.report
        .allowance(Kind::FsRead, "seatbelt.metadata", Vec::new(), metadata);
    let platform = |list: &[&str]| list.iter().map(|p| (*p).to_owned()).collect();
    let (read, write) = (Kind::FsRead, Kind::FsWrite);
    let starts = "every process reads these to start";
    p.grant(
        read,
        "seatbelt.platform",
        "file-read*",
        platform(&PLATFORM_READ),
        starts,
    );
    let devices = "the devices every process writes";
    p.grant(
        write,
        "seatbelt.platform",
        "file-write-data",
        platform(&PLATFORM_WRITE),
        devices,
    );
    if !grants.terminals.is_empty() {
        let message = "the terminal `openmoat run` was started on (its standard input, output \
                       and error), and no other";
        let both = "file-read* file-write-data";
        let terminals = grants.terminals.clone();
        p.grant(write, "seatbelt.terminal", both, terminals, message);
    }
    if let Some(program) = &grants.program {
        let message = "the program `openmoat run` starts, which must be readable to start";
        p.grant(
            read,
            "seatbelt.program",
            "file-read*",
            vec![program.clone()],
            message,
        );
    }
    p.allows(&ir.fs.read, "allow file-read*");
    for allowance in &ir.allowances {
        rule(
            &mut p.profile,
            &allowance.rule,
            "allow file-read*",
            &allowance.patterns,
            Case::Exact,
        );
        p.report.allowances.push(allowance.clone());
    }
    p.allows(&ir.fs.write, "allow file-write*");
    let both = "file-read* file-write*";
    if let Some(tmpdir) = &grants.tmpdir {
        let message = "commands may read and write the temp directory; the hook asks";
        p.grant(
            write,
            "seatbelt.tmpdir",
            both,
            vec![format!("{tmpdir}/**")],
            message,
        );
    }
    if !grants.writes.is_empty() {
        let trees = grants.writes.iter().map(|w| format!("{w}/**")).collect();
        let message =
            "`openmoat run --write`: commands may read and write these; deny rules still win";
        p.grant(write, "seatbelt.write", both, trees, message);
    }
    // A path created in another case (`.MCP.json`) is spelled so on disk: on a
    // case-insensitive disk the denies match either case. Allows stay as
    // written, which can only narrow them.
    let deny = if ir.fs.case_insensitive {
        Case::Folded
    } else {
        Case::Exact
    };
    rules(
        &mut p.profile,
        &ir.fs.read.deny,
        "deny file-read-data file-read-xattr",
        deny,
    );
    rules(&mut p.profile, &ir.fs.write.deny, "deny file-write*", deny);
    agent_config(&mut p, &ir.fs.write, deny);
    p.profile.push_str(KEYCHAIN);
    p.profile.push_str(TERMINAL_INPUT);
    network(&mut p, &ir.egress.net, grants.proxy_port);
    p
}

impl Generated {
    /// Allow `operation` on `patterns` and list it as an allowance.
    fn grant(&mut self, kind: Kind, id: &str, operation: &str, patterns: Vec<String>, why: &str) {
        rule(
            &mut self.profile,
            id,
            &format!("allow {operation}"),
            &patterns,
            Case::Exact,
        );
        self.report.allowance(kind, id, patterns, why);
    }

    /// The access's default, then its allow rules.
    fn allows(&mut self, access: &Access, operation: &str) {
        if access.default == Effect::Allow {
            let _ = writeln!(self.profile, "; default\n({operation})");
        }
        rules(&mut self.profile, &access.allow, operation, Case::Exact);
        let runs_later = access
            .allow
            .iter()
            .any(|r| split(&r.patterns).1.iter().any(|p| runs_later_exclusion(p)));
        if runs_later {
            let ci = RUNS_LATER.iter().filter(|f| !AGENT_CONFIG.contains(f));
            self.report.allowance(
                Kind::FsWrite,
                "seatbelt.runs-later",
                ci.map(|f| (*f).to_owned()).collect(),
                "commands may write CI configuration, commit-hook managers' files and editor \
                 tasks (an install runs `husky`); the hook asks before the agent writes one \
                 (`runs-later`)",
            );
        }
    }
}

/// One `(<operation> …)` per IR rule, named in a comment; `case` for their
/// positive patterns.
fn rules(out: &mut String, rules: &[Rule], operation: &str, case: Case) {
    let verb = operation.split(' ').next().unwrap_or_default();
    for r in rules {
        let comment = format!("{verb} {} ({})", r.id, r.list);
        rule(
            out,
            &comment,
            operation,
            &with_real_paths(&r.patterns),
            case,
        );
    }
}

/// `patterns` and, for each under a symlinked directory, the same pattern where
/// the link points: Seatbelt sees the path the kernel opens (`/tmp/x` is
/// `/private/tmp/x` on macOS). Only for the policy's rules; the platform's own
/// paths are listed in every spelling already, and the profile must not depend
/// on links elsewhere on the machine that generates it.
fn with_real_paths(patterns: &[String]) -> Vec<String> {
    let mut out = patterns.to_vec();
    for real in patterns.iter().filter_map(|p| through_links(p)) {
        if !out.contains(&real) {
            out.push(real);
        }
    }
    out
}

/// `(<operation> …)` matching what `patterns` match: at least one positive
/// pattern, compared as `case` says, and none of the `!` exceptions, compared
/// as written: a folded exception would take more out of a deny.
fn rule(out: &mut String, comment: &str, operation: &str, patterns: &[String], case: Case) {
    // `~/{.ssh/**,.aws/**}`: each alternative on its own, so `/**` inside braces
    // is recursive here as it is in the engine
    let patterns: Vec<String> = patterns.iter().flat_map(|p| alternatives(p)).collect();
    let patterns = &patterns[..];
    let (mut positive, mut excluded) = split(patterns);
    // CI and commit-hook managers' files run later are the hook's to ask about:
    // a deny here would fail installs. An agent's own configuration stays out.
    excluded.retain(|p| !runs_later_exclusion(p) || agent_config_exclusion(p));
    // `dir/**` becomes `(subpath dir)`, which covers `dir` itself.
    positive.retain(|p| !patterns.contains(&format!("{p}/**")));
    if positive.is_empty() {
        return;
    }
    let lines = |list: &[&str], indent: &str, case: Case| {
        let filters: Vec<String> = list.iter().map(|p| filter(p, case)).collect();
        format!("\n{indent}{}", filters.join(&format!("\n{indent}")))
    };
    let _ = write!(out, "; {comment}\n({operation}");
    if excluded.is_empty() {
        out.push_str(&lines(&positive, "  ", case));
    } else {
        let _ = write!(
            out,
            "\n  (require-all\n    (require-any{})\n    (require-not (require-any{})))",
            lines(&positive, "      ", case),
            lines(&excluded, "      ", Case::Exact)
        );
    }
    out.push_str(")\n");
}

/// Commands may not write the agent configuration files ([`AGENT_CONFIG`])
/// the write allow rules leave out, even where another grant (`--write`, the
/// temp directory) covers them: the agent host loads them later, outside every
/// sandbox, so the person changes one outside `openmoat run`. Nor the folders
/// above them (`.github`, `.pi`): renamed away, a file inside them would no
/// longer match, and renamed back it would be loaded.
fn agent_config(p: &mut Generated, write: &Access, case: Case) {
    let mut patterns = Vec::new();
    for r in &write.allow {
        let (_, excluded) = split(&r.patterns);
        for pattern in excluded.into_iter().filter(|e| agent_config_exclusion(e)) {
            push_unique(&mut patterns, pattern.to_owned());
        }
    }
    if patterns.is_empty() {
        return;
    }
    for folder in patterns
        .clone()
        .iter()
        .flat_map(|p| agent_config_folders(p))
    {
        push_unique(&mut patterns, folder);
    }
    let comment = "deny seatbelt.agent-config (fs.write)";
    rule(
        &mut p.profile,
        comment,
        "deny file-write*",
        &with_real_paths(&patterns),
        case,
    );
    let message = "commands may not write an agent's hooks, MCP servers or extensions in the \
                   project (`.claude/hooks`, `.mcp.json`, `.cursor/mcp.json`, \
                   `.codex/config.toml`, `.github/hooks`, `.pi/…`, `.openclaw/extensions`), \
                   which the agent loads later outside the sandbox, even when the hook allowed \
                   it, nor create, rename or remove the folders that hold them (`.github`, \
                   `.pi`, `.openclaw`): change one outside `openmoat run`";
    p.report
        .loss(Kind::FsWrite, "seatbelt.agent-config", message.into());
}

/// Egress goes only to the proxy's loopback port: Seatbelt cannot name a host.
fn network(p: &mut Generated, net: &Access, port: Option<u16>) {
    match port {
        Some(port) => {
            let _ = writeln!(
                p.profile,
                "; network: only OpenMoat's egress proxy, which decides each host by the policy\n\
                 (allow network-outbound (remote ip \"localhost:{port}\"))"
            );
        }
        None => p.profile.push_str("; network: none\n"),
    }
    p.report.proxy_only(net);
}

/// `pattern` (an absolute glob, `!` for an exclusion) with its literal
/// directory prefix written as the filesystem resolves it, when that differs:
/// `/tmp/x/**` → `/private/tmp/x/**` on macOS. The deepest existing directory
/// of the prefix is resolved; `None` when nothing changes.
fn through_links(pattern: &str) -> Option<String> {
    // Seatbelt runs on macOS only; elsewhere the profile is shown, not enforced,
    // and resolving there would only write that machine's links into it.
    if !cfg!(target_os = "macos") {
        return None;
    }
    let (bang, body) = match pattern.strip_prefix('!') {
        Some(rest) => ("!", rest),
        None => ("", pattern),
    };
    if !body.starts_with('/') {
        return None;
    }
    let literal_end = body.find(['*', '?', '[', '{', '\\']).unwrap_or(body.len());
    let literal = &body[..literal_end];
    let dir_end = literal.rfind('/')?;
    let mut dir = std::path::Path::new(&literal[..dir_end.max(1)]);
    loop {
        if let Ok(real) = std::fs::canonicalize(dir) {
            let (dir, real) = (dir.to_str()?, real.to_str()?.to_owned());
            if real == dir {
                return None;
            }
            return Some(format!("{bang}{real}{}", &body[dir.len()..]));
        }
        dir = dir.parent()?;
    }
}

#[cfg(test)]
mod tests;