mod settings;
use anyhow::Result;
use openmoat_core::ir::{Access, Effect, Enforcement, Rule};
use openmoat_core::{AtomicAction, Kind};
use serde_json::{Map, Value, json};
pub use settings::{apply, in_sync, protect, weaknesses};
use super::Report;
use super::patterns::{Spot, domain, has_glob, is_below, literal_tree, push_unique, split, spot};
pub const BLOCK_READS: &str = "blockReadsOutsideWorkingDirectories";
const GIT_EXEC_VECTORS: &[&str] = &[
"/**/.git/hooks",
"/**/.git/hooks/**",
"/**/.git/config",
"/**/.git/config.worktree",
"/**/.git/info/attributes",
"/**/.git/worktrees/*/config.worktree",
"/**/.git/worktrees/*/commondir",
"/**/.git/modules/**/hooks",
"/**/.git/modules/**/hooks/**",
"/**/.git/modules/**/config",
"/**/.git/modules/**/config.worktree",
"/**/.git/modules/**/info/attributes",
"/**/.git/modules/**/commondir",
];
#[derive(Debug, Clone, PartialEq)]
pub struct Generated {
pub sandbox: Map<String, Value>,
pub block_reads: bool,
pub report: Report,
}
pub fn generate(ir: &Enforcement, proxy_port: Option<u16>) -> Result<Generated> {
let mut report = Report::default();
let filesystem = Filesystem::build(ir, &mut report)?;
let network = match proxy_port {
None => network(&ir.egress.net, &mut report),
Some(port) => through_moat_proxy(&ir.egress.net, port, &mut report),
};
let mut sandbox = Map::new();
sandbox.insert("enabled".into(), json!(true));
sandbox.insert("failIfUnavailable".into(), json!(true));
sandbox.insert("allowUnsandboxedCommands".into(), json!(false));
sandbox.insert("excludedCommands".into(), json!([]));
sandbox.insert("filesystem".into(), filesystem.into_json());
sandbox.insert("network".into(), network);
let block_reads = ir.fs.read.default == Effect::Deny;
if block_reads {
report.loss(
Kind::FsRead,
"default",
"Claude Code's file tools refuse reads outside the working directories \
(permissions.blockReadsOutsideWorkingDirectories) where the hook would ask; \
`/add-dir` adds a directory"
.into(),
);
}
report.allowance(
Kind::FsRead,
"claude-code.system-paths",
Vec::new(),
"Claude Code closes reads only under the user directories (/Users, /home, /Volumes, \
/media, /root); other paths that are not read roots stay readable for sandboxed commands",
);
report.allowance(
Kind::FsWrite,
"claude-code.working-directories",
Vec::new(),
"sandboxed commands may write the session's working directories and temp directory; \
OpenMoat's project is the git root and never the home directory, so a session started \
above a project writes more than the hook allows",
);
Ok(Generated {
sandbox,
block_reads,
report,
})
}
#[derive(Default)]
struct Filesystem {
deny_read: Vec<String>,
allow_read: Vec<String>,
deny_write: Vec<String>,
allow_write: Vec<String>,
nodes: Vec<String>,
trees: Vec<String>,
}
impl Filesystem {
fn build(ir: &Enforcement, report: &mut Report) -> Result<Self> {
let checker = ir.checker()?;
let mut fs = Self::default();
for rule in &ir.fs.read.deny {
fs.deny_rule(rule, Kind::FsRead, report);
}
for rule in &ir.fs.write.deny {
fs.deny_rule(rule, Kind::FsWrite, report);
}
fs.allow_rules(&ir.fs.read, Kind::FsRead, report);
fs.allow_rules(&ir.fs.write, Kind::FsWrite, report);
for allowance in ir.allowances.iter().filter(|a| a.kind == Kind::FsRead) {
for root in allowance.patterns.iter().filter(|p| !p.ends_with("/**")) {
let read = AtomicAction::FsRead { path: root.clone() };
if checker.check_os(&read) == Some(Effect::Allow) {
push_unique(&mut fs.allow_read, root.clone());
} else {
report.loss(
Kind::FsRead,
&allowance.rule,
format!("`{root}` is inside a denied path, so it is not re-opened"),
);
}
}
report.allowances.push(allowance.clone());
}
if ir.fs.write.default == Effect::Allow {
report.loss(
Kind::FsWrite,
"default",
"Claude Code allows writes only in the working directories and listed paths".into(),
);
}
fs.drop_directory_nodes(report);
Ok(fs)
}
fn deny_rule(&mut self, rule: &Rule, kind: Kind, report: &mut Report) {
let (positive, excluded) = split(&rule.patterns);
for pattern in positive {
self.deny(kind, pattern);
}
if !excluded.is_empty() {
report.loss(
kind,
&rule.id,
format!(
"Claude Code would re-open the exceptions ({}) outside the working directories \
too, so they are left out: denied for sandboxed commands",
excluded.join(", ")
),
);
}
}
fn deny(&mut self, kind: Kind, pattern: &str) {
let anchored = match spot(pattern) {
Spot::Project("" | "**") => "/".to_owned(),
Spot::Project(rest) | Spot::Anywhere(rest) => format!("/**/{rest}"),
Spot::Absolute(path) => path.to_owned(),
};
let list = match kind {
Kind::FsRead => &mut self.deny_read,
_ => &mut self.deny_write,
};
if let Some(base) = anchored.strip_suffix("/**") {
push_unique(list, base.to_owned());
if has_glob(base) {
push_unique(list, anchored.clone());
}
if kind == Kind::FsWrite {
self.trees.push(base.to_owned());
}
} else {
push_unique(list, anchored.clone());
if kind == Kind::FsWrite {
self.nodes.push(anchored);
}
}
}
fn allow_rules(&mut self, access: &Access, kind: Kind, report: &mut Report) {
for rule in &access.allow {
let (positive, excluded) = split(&rule.patterns);
for pattern in excluded {
let project = match spot(pattern) {
Spot::Project(rest) => Some(rest),
_ => None,
};
if kind == Kind::FsWrite && project == Some(".git/**") {
self.git_internals(report);
continue;
}
self.deny(kind, pattern);
if let Some(rest) = project {
report.loss(
kind,
&rule.id,
format!(
"user settings cannot name the project, so `{rest}` is denied in every \
directory"
),
);
}
}
for pattern in positive {
let literal = match spot(pattern) {
Spot::Project(_) => continue,
Spot::Absolute(path) => literal_tree(path),
Spot::Anywhere(_) => None,
};
let list = match kind {
Kind::FsRead => &mut self.allow_read,
_ => &mut self.allow_write,
};
match literal {
Some(dir) => push_unique(list, dir.to_owned()),
None => report.loss(
kind,
&rule.id,
format!("`{pattern}` cannot be granted as a path, so it stays denied"),
),
}
}
}
}
fn git_internals(&mut self, report: &mut Report) {
for vector in GIT_EXEC_VECTORS {
push_unique(&mut self.deny_write, (*vector).to_owned());
}
report.allowance(
Kind::FsWrite,
"claude-code.git-internals",
vec!["/**/.git/**".to_owned()],
"user settings cannot name the project, so sandboxed commands may write `.git` \
(objects, refs, index: `git commit` works) in every directory, except what makes git \
run code or follow another repository (hooks, config, config.worktree, \
info/attributes, a linked worktree's commondir, the same in submodules); a script \
can still rewrite history, and the hook still asks for file-tool writes to `.git`",
);
}
fn drop_directory_nodes(&mut self, report: &mut Report) {
let dropped: Vec<String> = self
.nodes
.iter()
.filter(|node| !self.trees.contains(node))
.filter(|node| self.deny_write.iter().any(|e| is_below(e, node)))
.cloned()
.collect();
if dropped.is_empty() {
return;
}
self.deny_write.retain(|e| !dropped.contains(e));
report.allowance(
Kind::FsWrite,
"claude-code.directory-nodes",
dropped,
"a directory in denyWrite 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_json(self) -> Value {
json!({
"denyRead": self.deny_read,
"allowRead": self.allow_read,
"denyWrite": self.deny_write,
"allowWrite": self.allow_write,
})
}
}
fn network(net: &Access, report: &mut Report) -> Value {
let mut allowed = Vec::new();
let mut denied = Vec::new();
if net.default == Effect::Allow {
report.loss(
Kind::Net,
"default.net",
"Claude Code's allowlist cannot allow every host: sandboxed commands reach only listed hosts"
.into(),
);
}
for rule in &net.deny {
let (positive, excluded) = split(&rule.patterns);
for pattern in positive.into_iter().filter(|p| domain(p)) {
push_unique(&mut denied, pattern.to_owned());
}
if !excluded.is_empty() {
report.loss(
Kind::Net,
&rule.id,
"exceptions to a deny rule are left out: denied".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 excluded {
push_unique(&mut denied, pattern.to_owned());
}
for pattern in positive {
if domain(pattern) {
push_unique(&mut allowed, pattern.to_owned());
} else {
report.loss(
Kind::Net,
&rule.id,
format!("`{pattern}` is not a domain name, so it stays denied"),
);
}
}
}
json!({ "allowedDomains": allowed, "deniedDomains": denied, "strictAllowlist": true })
}
fn through_moat_proxy(net: &Access, port: u16, report: &mut Report) -> Value {
let mut value = network(net, &mut Report::default());
value["httpProxyPort"] = json!(port);
value["socksProxyPort"] = json!(port);
report.proxy_only(net);
report.loss(
Kind::Net,
"claude-code.proxy",
format!(
"sandboxed commands have no network while nothing listens on 127.0.0.1:{port} \
(`moat proxy`), and none over SOCKS5 (`ALL_PROXY`, ssh): `moat proxy` speaks HTTP only"
),
);
value
}
#[cfg(test)]
mod tests;