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;
const PLATFORM_READ: [&str; 8] = [
"/",
"/dev/null",
"/dev/zero",
"/dev/random",
"/dev/urandom",
"/dev/tty",
"/dev/fd/**",
"/private/var/select/**",
];
const PLATFORM_WRITE: [&str; 5] = [
"/dev/null",
"/dev/zero",
"/dev/tty",
"/dev/dtracehelper",
"/dev/fd/**",
];
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\"))
";
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\"))
";
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))
";
#[derive(Debug, Clone)]
pub struct Generated {
pub profile: String,
pub report: Report,
}
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);
}
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 {
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);
}
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`)",
);
}
}
}
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,
);
}
}
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
}
fn rule(out: &mut String, comment: &str, operation: &str, patterns: &[String], case: Case) {
let patterns: Vec<String> = patterns.iter().flat_map(|p| alternatives(p)).collect();
let patterns = &patterns[..];
let (mut positive, mut excluded) = split(patterns);
excluded.retain(|p| !runs_later_exclusion(p) || agent_config_exclusion(p));
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");
}
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());
}
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);
}
fn through_links(pattern: &str) -> Option<String> {
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;