use anyhow::Result;
use openmoat_core::ir::{Access, Effect, Enforcement};
use openmoat_core::{AtomicAction, Kind};
use toml_edit::{Item, Table, value};
use super::patterns::{Spot, domain, is_below, literal_tree, push_unique, split, spot};
use super::{PROJECT, Report};
mod config;
mod own_binary;
pub use config::{apply, in_sync, owned_part, protect, remove, weaknesses};
pub use own_binary::{own_binary, read_own_binary};
pub const PROFILE: &str = "moat";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Mode {
Write,
Read,
Deny,
}
impl Mode {
fn as_str(self) -> &'static str {
match self {
Self::Write => "write",
Self::Read => "read",
Self::Deny => "deny",
}
}
}
#[derive(Debug, Clone)]
pub struct Generated {
pub profile: Table,
pub report: Report,
}
pub fn generate(ir: &Enforcement, homes: &[String], proxy_port: Option<u16>) -> Result<Generated> {
let mut report = Report::default();
let fs = Filesystem::build(ir, homes, &mut report)?;
let mut profile = Table::new();
profile.insert(
"description",
value("generated by moat from policy.yaml; run `moat sandbox sync` instead of editing"),
);
profile.insert("filesystem", Item::Table(fs.into_table()));
let mut network = network(&ir.egress.net, &mut report);
if let Some(port) = proxy_port {
network.insert("allow_upstream_proxy", value(true));
report.allowance(
Kind::Net,
"codex.upstream",
Vec::new(),
&format!(
"Codex's own proxy decides the hosts; it sends what it allows on to `moat proxy` \
only when Codex starts with HTTP_PROXY and HTTPS_PROXY set to \
http://127.0.0.1:{port}, which config.toml cannot set. Without them that traffic \
skips OpenMoat's address checks, audit log and brokered secrets"
),
);
}
profile.insert("network", Item::Table(network));
Ok(Generated { profile, report })
}
#[derive(Default)]
struct Filesystem {
paths: Vec<(String, Mode)>,
workspace: Vec<(String, Mode)>,
homes: Vec<String>,
roots: Vec<String>,
write_roots: Vec<String>,
outside_home: Vec<String>,
denies_anywhere: bool,
nodes: Vec<String>,
trees: Vec<String>,
}
fn grant(list: &mut Vec<(String, Mode)>, key: String, mode: Mode) {
match list.iter_mut().find(|(k, _)| *k == key) {
Some((_, existing)) => *existing = (*existing).max(mode),
None => list.push((key, mode)),
}
}
fn widen(list: &mut Vec<(String, Mode)>, key: String, mode: Mode) {
match list.iter_mut().find(|(k, _)| *k == key) {
Some((_, existing)) => *existing = (*existing).min(mode),
None => list.push((key, mode)),
}
}
impl Filesystem {
fn build(ir: &Enforcement, homes: &[String], report: &mut Report) -> Result<Self> {
let checker = ir.checker()?;
let readable = |path: &str| {
checker.check_os(&AtomicAction::FsRead {
path: path.to_owned(),
}) == Some(Effect::Allow)
};
let mut fs = Self {
homes: homes.to_vec(),
..Self::default()
};
grant(&mut fs.paths, ":minimal".into(), Mode::Read);
report.allowance(
Kind::FsRead,
"codex.minimal",
vec![":minimal".into()],
"Codex's minimal platform reads (system libraries, devices) every command needs",
);
fs.allows(&ir.fs.read, Mode::Read, &readable, report);
for allowance in ir.allowances.iter().filter(|a| a.kind == Kind::FsRead) {
for root in allowance.patterns.iter().filter(|p| !p.ends_with("/**")) {
if readable(root) {
fs.grant_root(root, Mode::Read);
} else {
report.loss(
Kind::FsRead,
&allowance.rule,
format!("`{root}` is inside a denied path, so it is not granted"),
);
}
}
report.allowances.push(allowance.clone());
}
fs.allows(&ir.fs.write, Mode::Write, &readable, report);
grant(&mut fs.paths, ":tmpdir".into(), Mode::Write);
report.allowance(
Kind::FsWrite,
"codex.tmpdir",
vec![":tmpdir".into()],
"commands may write the temp directory; the hook asks",
);
for rule in &ir.fs.read.deny {
let (positive, excluded) = split(&rule.patterns);
for pattern in positive {
fs.deny(pattern, Mode::Deny, false);
}
if !excluded.is_empty() {
report.loss(
Kind::FsRead,
&rule.id,
format!(
"Codex cannot re-allow inside a deny glob, so the exceptions ({}) stay denied",
excluded.join(", ")
),
);
}
}
for rule in &ir.fs.write.deny {
let (positive, _) = split(&rule.patterns);
for pattern in positive {
let read_only = write_only_target(pattern).is_some_and(|path| readable(&path));
fs.deny(
pattern,
if read_only { Mode::Read } else { Mode::Deny },
true,
);
}
}
fs.drop_directory_nodes(report);
if fs.denies_anywhere && !fs.outside_home.is_empty() {
report.allowance(
Kind::FsRead,
"codex.outside-home",
fs.outside_home.clone(),
"Codex on Linux lists every directory below a deny glob before each command \
and runs no command when one is unreadable (root-only directories under /etc \
and /tmp), so `**/` denies (`.env` files) cover the project and the read roots \
in the home but not these directories; the hook still denies the agent's own \
reads there",
);
}
report.loss(
Kind::FsWrite,
"codex.git",
"Codex keeps all of `.git` read-only: sandboxed commands cannot commit, Codex asks \
to run them outside the sandbox"
.into(),
);
Ok(fs)
}
fn grant_root(&mut self, path: &str, mode: Mode) {
widen(&mut self.paths, path.to_owned(), mode);
if !self.homes.iter().any(|h| path == h || is_below(path, h)) {
push_unique(&mut self.outside_home, path.to_owned());
return;
}
push_unique(&mut self.roots, path.to_owned());
if mode == Mode::Write {
push_unique(&mut self.write_roots, path.to_owned());
}
}
fn allows(
&mut self,
access: &Access,
mode: Mode,
readable: &dyn Fn(&str) -> bool,
report: &mut Report,
) {
let kind = if mode == Mode::Write {
Kind::FsWrite
} else {
Kind::FsRead
};
for rule in &access.allow {
let (positive, excluded) = split(&rule.patterns);
for pattern in positive {
match spot(pattern) {
Spot::Project("" | "**") => widen(&mut self.workspace, ".".into(), mode),
Spot::Project(rest)
if literal_tree(rest)
.is_some_and(|r| readable(&format!("{PROJECT}/{r}"))) =>
{
let rest = literal_tree(rest).unwrap_or(rest).to_owned();
widen(&mut self.workspace, rest, mode);
}
Spot::Absolute(path) if literal_tree(path).is_some_and(readable) => {
self.grant_root(literal_tree(path).unwrap_or(path), mode);
}
_ => report.loss(
kind,
&rule.id,
format!("`{pattern}` cannot be granted as a path, so it stays denied"),
),
}
}
for pattern in excluded {
let read_only = write_only_target(pattern).is_some_and(|path| readable(&path));
self.deny(
pattern,
if mode == Mode::Write && read_only {
Mode::Read
} else {
Mode::Deny
},
mode == Mode::Write,
);
}
}
}
fn deny(&mut self, pattern: &str, mode: Mode, write: bool) {
let roots = if write {
&self.write_roots
} else {
&self.roots
};
let tree = pattern.ends_with("/**");
let key = |p: &str| literal_tree(p).unwrap_or(p).to_owned();
let targets: Vec<(bool, String)> = match spot(pattern) {
Spot::Project("" | "**") => vec![(true, ".".into())],
Spot::Project(rest) => vec![(true, key(rest))],
Spot::Absolute(path) => vec![(false, key(path))],
Spot::Anywhere(rest) => std::iter::once((true, format!("**/{rest}")))
.chain(roots.iter().map(|r| (false, format!("{r}/**/{rest}"))))
.collect(),
};
self.denies_anywhere |= matches!(spot(pattern), Spot::Anywhere(_));
for (in_workspace, target) in targets {
if tree {
&mut self.trees
} else {
&mut self.nodes
}
.push(target.clone());
let list = if in_workspace {
&mut self.workspace
} else {
&mut self.paths
};
grant(list, target, mode);
}
}
fn drop_directory_nodes(&mut self, report: &mut Report) {
let below =
|list: &[(String, Mode)], node: &str| list.iter().any(|(k, _)| is_below(k, node));
let dropped: Vec<String> = self
.nodes
.iter()
.filter(|n| !self.trees.contains(n))
.filter(|n| below(&self.paths, n) || below(&self.workspace, n))
.cloned()
.collect();
if dropped.is_empty() {
return;
}
self.paths.retain(|(k, _)| !dropped.contains(k));
self.workspace.retain(|(k, _)| !dropped.contains(k));
report.allowance(
Kind::FsWrite,
"codex.directory-nodes",
dropped,
"a denied directory covers its subtree, so these directory nodes are left out and \
only the files below them are denied; renaming or deleting the directory itself is \
left to the hook",
);
}
fn into_table(self) -> Table {
let mut table = Table::new();
for (key, mode) in self.paths {
table.insert(&key, value(mode.as_str()));
}
let mut workspace = Table::new();
for (key, mode) in self.workspace {
workspace.insert(&key, value(mode.as_str()));
}
table.insert(":workspace_roots", Item::Table(workspace));
table
}
}
fn write_only_target(pattern: &str) -> Option<String> {
match spot(pattern) {
Spot::Project(rest) => literal_tree(rest).map(|r| format!("{PROJECT}/{r}")),
Spot::Absolute(path) => literal_tree(path).map(str::to_owned),
Spot::Anywhere(_) => None,
}
}
fn network(net: &Access, report: &mut Report) -> Table {
let mut domains = Table::new();
if net.default == Effect::Allow {
report.loss(
Kind::Net,
"default.net",
"the profile cannot allow every host: commands reach only listed hosts".into(),
);
}
for rule in &net.allow {
let (positive, excluded) = split(&rule.patterns);
if !excluded.iter().all(|p| domain(p)) {
report.loss(
Kind::Net,
&rule.id,
"an exception is not a domain name, so the whole rule is left out: denied".into(),
);
continue;
}
for pattern in positive {
if domain(pattern) {
domains.insert(pattern, value("allow"));
} else {
report.loss(
Kind::Net,
&rule.id,
format!("`{pattern}` is not a domain name, so it stays denied"),
);
}
}
for pattern in excluded {
domains.insert(pattern, value("deny"));
}
}
for rule in &net.deny {
let (positive, _) = split(&rule.patterns);
for pattern in positive.into_iter().filter(|p| domain(p)) {
domains.insert(pattern, value("deny"));
}
}
let mut table = Table::new();
table.insert("enabled", value(true));
table.insert("allow_local_binding", value(false));
table.insert("domains", Item::Table(domains));
table
}
#[cfg(test)]
mod tests;