openmoat 0.1.0-alpha.2

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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Codex backend: a `[permissions.moat]` profile in `config.toml` (ADR-018).
//!
//! Key names are those of codex-cli 0.160 (`PermissionProfileToml` in
//! `codex-rs/config/src/permissions_toml.rs`). The profile does not extend
//! `:workspace`, which reads the whole disk (it let `npm test` read `~/.ssh`
//! in the spike); it grants exactly the IR's reads. What the generator relies
//! on, verified with `codex sandbox -P` on macOS:
//! - a path maps to `read`, `write` or `deny`; a glob may only `deny`, and only
//!   at the top level or under `:workspace_roots` (not under `:tmpdir`);
//! - `deny` wins over an enclosing `read` (`~/.cargo` leaves
//!   `~/.cargo/credentials.toml` denied); nothing re-allows inside a deny;
//! - `.git` in a workspace root stays read-only whatever the profile says;
//! - `network.domains` is enforced by Codex's own proxy (`features.network_proxy`),
//!   which `codex sandbox` starts on an ephemeral loopback port; the profile
//!   then allows commands no other connection (`curl --noproxy '*'` fails);
//! - that proxy hands allowed connections to an upstream proxy only when Codex
//!   itself runs with `HTTP(S)_PROXY` and `allow_upstream_proxy` is on; no
//!   `config.toml` key names the upstream (`network-proxy/src/upstream.rs`).

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, split, spot};
use super::{PROJECT, Report};

mod config;

pub use config::{apply, in_sync, owned_part, protect, remove, weaknesses};

/// The profile name OpenMoat owns and sets as `default_permissions`.
pub const PROFILE: &str = "moat";

/// Codex's access modes, weakest first: a stricter grant replaces a weaker one.
#[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",
        }
    }
}

/// What OpenMoat writes into `config.toml`.
#[derive(Debug, Clone)]
pub struct Generated {
    /// The `[permissions.moat]` table.
    pub profile: Table,
    /// Losses and allowances of this backend.
    pub report: Report,
}

/// Generate the profile for `ir`, lowered by [`super::lower_for_hosts`]. With
/// `proxy_port`, Codex's proxy may hand allowed traffic to `moat proxy` there.
pub fn generate(ir: &Enforcement, proxy_port: Option<u16>) -> Result<Generated> {
    let mut report = Report::default();
    let fs = Filesystem::build(ir, &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 {
        // Stated rather than left to Codex's default, so turning it off is drift.
        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 {
    /// Absolute paths, globs and special paths (`:minimal`, `:tmpdir`).
    paths: Vec<(String, Mode)>,
    /// Entries under `:workspace_roots`, relative to each root.
    workspace: Vec<(String, Mode)>,
    /// Absolute directories reads are granted in, for `**/` read denies.
    roots: Vec<String>,
    /// Absolute directories writes are granted in, for `**/` write denies.
    write_roots: Vec<String>,
    /// Deny entries naming a directory node only, and whole trees.
    nodes: Vec<String>,
    trees: Vec<String>,
}

/// Record a deny or read-only entry: the stricter of two grants wins.
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)),
    }
}

/// Record an allow: allows add up, so the wider of two grants wins. Every
/// allow is recorded before any deny.
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, 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::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);
        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.roots.iter().any(|r| r == path) {
            self.roots.push(path.to_owned());
        }
        if mode == Mode::Write && !self.write_roots.iter().any(|r| r == path) {
            self.write_roots.push(path.to_owned());
        }
    }

    /// Allow rules: the project maps to the workspace roots, a literal path to
    /// itself; an exclusion becomes read-only or denied. A write grant also
    /// grants reads, so it is only made where the IR reads too.
    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,
                );
            }
        }
    }

    /// Deny (or make read-only) what `pattern` names. A `**/` glob is denied in
    /// the workspace roots and below every directory granted for the access
    /// (`write`: writes, else reads); elsewhere the profile denies by default.
    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(),
        };
        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);
        }
    }

    /// Like Claude Code, a denied directory covers its subtree, so a node deny
    /// (`**/.claude`: no rename) with entries below it is left out and listed.
    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
    }
}

/// The path a write-deny names when it names one literal path (`~/.zshrc`,
/// the project's `.git`), which Codex can then make read-only instead of
/// unreadable. Globs have no read-only form.
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,
    }
}

/// `[permissions.moat.network]`: Codex's proxy allows the listed domains.
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"));
        }
    }
    // Hosts that are not domain names (`169.254.*`) stay unlisted, so denied.
    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));
    // Codex's default, stated so that turning it on is drift: no direct
    // connection to local services.
    table.insert("allow_local_binding", value(false));
    table.insert("domains", Item::Table(domains));
    table
}

#[cfg(test)]
mod tests;