openmoat 0.4.0

Policy guard for AI coding agents: decides allow, 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 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::{literal_tree, split};
use super::{Grants, Report};

/// Paths every process reads: `/` (the dynamic loader), the terminal and
/// random devices, and `/private/var/select` (where `/bin/sh` finds its shell).
const PLATFORM_READ: [&str; 9] = [
    "/",
    "/dev/null",
    "/dev/zero",
    "/dev/random",
    "/dev/urandom",
    "/dev/tty",
    "/dev/ttys[0-9]*",
    "/dev/fd/**",
    "/private/var/select/**",
];

/// Devices every process writes.
const PLATFORM_WRITE: [&str; 6] = [
    "/dev/null",
    "/dev/zero",
    "/dev/tty",
    "/dev/dtracehelper",
    "/dev/ttys[0-9]*",
    "/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 `moat 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 let Some(program) = &grants.program {
        let message = "the program `moat 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,
        );
        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 = "`moat run --write`: commands may read and write these; deny rules still win";
        p.grant(write, "seatbelt.write", both, trees, message);
    }
    rules(
        &mut p.profile,
        &ir.fs.read.deny,
        "deny file-read-data file-read-xattr",
    );
    rules(&mut p.profile, &ir.fs.write.deny, "deny file-write*");
    p.profile.push_str(KEYCHAIN);
    p.profile.push_str(TERMINAL_INPUT);
    network(&mut p, &ir.egress.net, grants.proxy_port);
    if ir.fs.case_insensitive {
        let message = "Seatbelt compares paths in their on-disk spelling, so a pattern spelled in \
                       another case than the file on disk does not match it";
        p.report
            .allowance(read, "seatbelt.case", Vec::new(), message);
    }
    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,
        );
        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);
    }
}

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

/// `(<operation> โ€ฆ)` matching what `patterns` match: at least one positive
/// pattern and none of the `!` exceptions.
fn rule(out: &mut String, comment: &str, operation: &str, patterns: &[String]) {
    let (mut positive, excluded) = split(patterns);
    // `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| {
        let filters: Vec<String> = list.iter().map(|p| filter(p)).collect();
        format!("\n{indent}{}", filters.join(&format!("\n{indent}")))
    };
    let _ = write!(out, "; {comment}\n({operation}");
    if excluded.is_empty() {
        out.push_str(&lines(&positive, "  "));
    } else {
        let _ = write!(
            out,
            "\n  (require-all\n    (require-any{})\n    (require-not (require-any{})))",
            lines(&positive, "      "),
            lines(&excluded, "      ")
        );
    }
    out.push_str(")\n");
}

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

/// One IR glob as a Seatbelt path filter.
fn filter(pattern: &str) -> String {
    match literal_tree(pattern) {
        Some(dir) if pattern.ends_with("/**") => format!("(subpath {})", quote(dir)),
        Some(path) => format!("(literal {})", quote(path)),
        None => format!("(regex {})", quote(&regex(pattern))),
    }
}

/// An SBPL string literal.
fn quote(text: &str) -> String {
    format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
}

/// A path glob as an anchored regular expression with the engine's semantics
/// (`docs/POLICY.md` ยง3): `*` and `?` stop at `/`, `**` as a whole component
/// crosses it, and `dir/**` also matches `dir`.
fn regex(glob: &str) -> String {
    let mut re = String::from("^");
    let mut depth = 0usize;
    let mut i = 0;
    while let Some(c) = glob[i..].chars().next() {
        let rest = &glob[i..];
        let (piece, used) = if rest == "/**" {
            ("(/.*)?".to_owned(), 3)
        } else if rest.starts_with("/**/") {
            ("/(.*/)?".to_owned(), 4)
        } else if i == 0 && rest == "**" {
            (".*".to_owned(), 2)
        } else if i == 0 && rest.starts_with("**/") {
            ("(.*/)?".to_owned(), 3)
        } else {
            match c {
                '*' => ("[^/]*".to_owned(), 1),
                '?' => ("[^/]".to_owned(), 1),
                '{' => {
                    depth += 1;
                    ("(".to_owned(), 1)
                }
                '}' if depth > 0 => {
                    depth -= 1;
                    (")".to_owned(), 1)
                }
                ',' if depth > 0 => ("|".to_owned(), 1),
                '[' => class(rest).unwrap_or_else(|| (escape('['), 1)),
                '\\' => match rest[1..].chars().next() {
                    Some(next) => (escape(next), 1 + next.len_utf8()),
                    None => (escape('\\'), 1),
                },
                other => (escape(other), other.len_utf8()),
            }
        };
        re.push_str(&piece);
        i += used;
    }
    re.push('$');
    re
}

/// A glob character class at the start of `rest` (`[ab]`, `[!ab]`) and its
/// length, or `None` when it is not closed.
fn class(rest: &str) -> Option<(String, usize)> {
    let body = &rest[1..];
    let (negated, body) = match body.strip_prefix(['!', '^']) {
        Some(b) => (true, b),
        None => (false, body),
    };
    // A `]` right after the opening bracket is a member, not the end.
    let end = body.char_indices().skip(1).find(|(_, c)| *c == ']')?.0;
    let members = body[..end].replace('\\', "\\\\");
    let negation = if negated { "^/" } else { "" };
    Some((
        format!("[{negation}{members}]"),
        rest.len() - body.len() + end + 1,
    ))
}

fn escape(c: char) -> String {
    if ".^$|()+[]{}\\*?".contains(c) {
        format!("\\{c}")
    } else {
        c.to_string()
    }
}

#[cfg(test)]
mod tests;