openmoat 0.9.0

Policy checks for AI coding agents: decides allow, defer, 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
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! `moat allow`: turn an `ask` (a shell command or a file action) into a session
//! grant or a permanent rule, allow a site or a directory permanently, or remove a
//! permanent rule.

use std::io::Write as _;
use std::path::Path;

use anyhow::{Context as _, Result, bail, ensure};
use openmoat_audit::Event;
use openmoat_core::{Action, PathResolver as _, RuleGroup, TAINT_RULE, Verdict};

use crate::approvals::{self, GRANT_TTL_MS, Grants, Overlay};
use crate::cli::AllowArgs;
use crate::context::{absolute, path_string};
use crate::exit::Code;
use crate::home::Home;
use crate::integrity::{self, HookPins};
use crate::realpath::FsPathResolver;
use crate::render::{self, Deferred};

/// How many recent events `--last` searches for the newest `ask` it can approve.
const LAST_ASK_SEARCH: usize = 200;

pub fn run(args: &AllowArgs) -> Result<Code> {
    let home = checked_home()?;
    let mut out = Deferred::default();
    if args.site.is_some() || args.dir.is_some() || args.remove.is_some() {
        change_overlay(&home, args, &mut out)?;
        repin(&home, out)?;
        return sync_sandboxes();
    }
    let (who, action) = match (&args.command, args.last) {
        (Some(command), false) => (
            Who {
                host: args.host.map(|h| h.id().to_owned()),
                session: args.session.clone(),
                project: project_in(args.project.as_deref())?,
            },
            Action::Shell {
                command: command.trim().to_owned(),
            },
        ),
        (None, true) => {
            let event = newest_ask(&home.open_audit()?)?
                .context("no recent `ask` for a shell command or a file in the audit log")?;
            let action = asked(&event)?;
            let last = format!(
                "last ask: {} on {} (session {}), {} ago, wanted to {}",
                event.id,
                event.host,
                event.session_id,
                crate::time::duration(crate::time::now_ms().saturating_sub(event.ts_ms)),
                approvals::describe(&action)
            );
            render::line(&mut out, &last)?;
            // The newest ask of any agent session: say which one before approving
            // it, so a person never approves a call they did not see.
            let taint = !args.always && event.rules.iter().any(|r| r == TAINT_RULE);
            let answer = if taint {
                writeln!(
                    out,
                    "  it asks because the session read untrusted content earlier; `s` \
                     approves every protected write of this session until it reads \
                     untrusted content again"
                )?;
                super::edit::answer(&mut out, "Approve it? [y/s/N] ")?
            } else {
                super::edit::answer(&mut out, "Approve it? [y/N] ")?
            };
            let covers_taint = match answer.as_str() {
                "y" | "yes" => false,
                "s" if taint => true,
                _ => {
                    writeln!(out, "nothing approved")?;
                    out.finish()?;
                    return Ok(Code::Ok);
                }
            };
            let who = Who::of(&event)?;
            return approve(&home, who, &action, args.always, covers_taint, out);
        }
        _ => bail!("give a command, or --last to take the most recent ask"),
    };
    approve(&home, who, &action, args.always, false, out)
}

/// Approve exactly `action` (from [`asked`]) of `event` for its host session,
/// or for good with `always`: the home screen's answer, with the same checks as
/// `run`.
pub(super) fn run_for(event: &Event, action: &Action, always: bool) -> Result<Code> {
    let home = checked_home()?;
    let who = Who::of(event)?;
    approve(&home, who, action, always, false, Deferred::default())
}

/// The host session a grant is for, and the project it applies in
/// ([`approvals::project_of`]).
struct Who {
    host: Option<String>,
    session: Option<String>,
    project: String,
}

impl Who {
    /// The session of an asked call, in the project of its working
    /// directory; a call recorded without one is taken as asked in the
    /// current directory, where the person approves it.
    fn of(event: &Event) -> Result<Self> {
        Ok(Self {
            host: Some(event.host.clone()),
            session: Some(event.session_id.clone()),
            project: project_in(event.cwd.as_deref().map(Path::new))?,
        })
    }
}

/// The project of `dir`, or of the current directory without one.
fn project_in(dir: Option<&Path>) -> Result<String> {
    let dir = match dir {
        Some(dir) => absolute(dir)?,
        None => std::env::current_dir().context("determining the current directory")?,
    };
    Ok(approvals::project_of(&dir, &crate::home::user_home()?))
}

/// The state directory, once a person at a terminal is confirmed and the lock
/// shows no drift.
fn checked_home() -> Result<Home> {
    crate::terminal::refuse_agent("openmoat allow")?;
    if !crate::terminal::interactive() {
        bail!("`openmoat allow` must be run by a person in a terminal, not from a hook or script");
    }
    let home = Home::locate()?;
    home.require_exists()?;
    // The re-pin below covers every pinned file, so approving one command over a
    // drifted lock would also accept a tampered policy or hook unseen. Without a
    // lock there is nothing to accept yet and the re-pin creates one.
    if home.lock_path().exists()
        && let Some(deny) = integrity::violation(&home)?
    {
        bail!(
            "refusing to approve while the policy lock shows drift ({}):\n  {}",
            integrity::INTEGRITY_RULE,
            deny.reasons.join("\n  ")
        );
    }
    Ok(home)
}

/// Write the session grant, or with `always` the permanent rule, then re-pin;
/// a permanent file rule also updates the agents' sandboxes. With
/// `covers_taint` the session grant also answers the session's other taint
/// asks from untrusted content ([`approvals::Grant::covers_taint`]).
fn approve(
    home: &Home,
    who: Who,
    action: &Action,
    always: bool,
    covers_taint: bool,
    mut out: Deferred,
) -> Result<Code> {
    if always {
        let path = home.overlay_path();
        let mut overlay = Overlay::load(&path)?;
        let rule = if let Action::Shell { command } = action {
            overlay.allow_command(command)?.clone()
        } else {
            let (reads, writes) = approvals::files(action);
            overlay
                .allow_files(&spellings(reads), &spellings(writes))?
                .clone()
        };
        render::line(&mut out, &will_allow(&rule))?;
        overlay.save(&path)?;
        if let Action::Shell { command } = action {
            let added = format!("✔ permanent rule {} allows shell \"{command}\"", rule.id);
            render::line(&mut out, &added)?;
        } else {
            writeln!(out, "✔ added to {}:", path.display())?;
            for line in openmoat_core::yaml::to_string(&[&rule])?.lines() {
                render::line(&mut out, &format!("    {line}"))?;
            }
            writeln!(
                out,
                "  `openmoat allow --dir <dir>` allows a whole directory, `openmoat edit` any pattern"
            )?;
        }
        writeln!(out, "  undo: openmoat allow --remove {}", rule.id)?;
        warn_if_shadowed(home, &rule.id, &mut out)?;
    } else {
        let session = who.session.context(
            "give --host and --session for a session grant, --always for a permanent rule, or --last",
        )?;
        let host = who.host.context("--session needs --host")?;
        let scope = approvals::Scope {
            host: &host,
            session_id: &session,
            project: &who.project,
        };
        if openmoat_hosts::is_unknown_session(&session) {
            bail!(
                "this call came with no session id, so a session grant would hold in every \
                 session that sends none; approve it in the agent, or give --always for a \
                 permanent rule you can remove with `openmoat allow --remove`"
            );
        }
        let path = home.grants_path();
        let mut grants = Grants::load(&path)?;
        let now = crate::time::now_ms();
        // The newest event so far: a taint ask is answered only if the session
        // read nothing untrusted after it, whatever the clock says.
        let seen = home.open_audit()?.latest_id()?.map_or(0, |id| id.0);
        grants.grant(scope, action, now, Some(seen));
        if covers_taint {
            grants.cover_taint(scope, action);
        }
        grants.save(&path, now)?;
        let granted = format!(
            "✔ session {session} on {host} may {} for {}",
            approvals::describe(action),
            crate::time::duration(GRANT_TTL_MS)
        );
        render::line(&mut out, &granted)?;
        if covers_taint {
            writeln!(
                out,
                "  and every protected write of this session until it reads untrusted content again"
            )?;
        }
        let resume = if host == openmoat_hosts::Host::ClaudeCode.id() {
            " (`claude --continue` resumes this one)"
        } else {
            ""
        };
        writeln!(
            out,
            "  only in this agent session: a new session asks again{resume}"
        )?;
        if matches!(action, Action::Shell { .. }) || covers_taint {
            render::line(
                &mut out,
                &format!("  only in project {}: elsewhere it asks again", who.project),
            )?;
        }
    }
    repin(home, out)?;
    // a command rule is the hook's alone; a file rule is also a sandbox grant
    if always && !matches!(action, Action::Shell { .. }) {
        return sync_sandboxes();
    }
    Ok(Code::Ok)
}

/// A rule that names a directory, a file or a host changes what the agents'
/// OS sandboxes may reach, so write them now, as `moat sandbox sync` does:
/// until then a sandboxed command still meets the old settings.
fn sync_sandboxes() -> Result<Code> {
    println!("Updating the agents' sandbox settings:");
    super::sandbox::sync()
}

/// What a permanent rule from `--always` will match, printed before it is
/// written: shell rules are prefixes (`Overlay::allow_command`), file rules
/// name exact paths.
fn will_allow(rule: &RuleGroup) -> String {
    let what = if rule.shell.is_empty() {
        let files: Vec<String> = [("read", &rule.fs_read), ("write", &rule.fs_write)]
            .iter()
            .filter(|(_, paths)| !paths.is_empty())
            .map(|(verb, paths)| format!("{verb} exactly {}", paths.join(", ")))
            .collect();
        format!("{} (no other path)", files.join(" and "))
    } else {
        // an install's near-miss atoms (`Overlay::allow_command`) are named apart
        let (near_misses, commands): (Vec<&str>, Vec<&str>) = rule
            .shell
            .iter()
            .map(String::as_str)
            .partition(|p| p.starts_with("@package-near-miss "));
        let notes: String = near_misses
            .iter()
            .filter_map(|atom| {
                let mut words = atom.split(' ').skip(2);
                let (name, popular) = (words.next()?, words.next()?);
                Some(format!(", even though \"{name}\" is not \"{popular}\""))
            })
            .collect();
        format!(
            "{} (and the same command with extra arguments){notes}",
            commands.join(", ")
        )
    };
    format!("will allow: {what}; deny rules still win")
}

/// Each path as asked and with its symlinks resolved, since the hook checks a
/// path both ways and the stricter answer wins.
fn spellings(paths: &[String]) -> Vec<String> {
    let mut out = paths.to_vec();
    for real in paths.iter().filter_map(|p| FsPathResolver.resolve(p)) {
        if !out.contains(&real) {
            out.push(real);
        }
    }
    out
}

fn repin(home: &Home, mut out: Deferred) -> Result<Code> {
    let binary = crate::install::hook_binary()?;
    let lock = integrity::repin(home, &binary, HookPins::Keep)?;
    writeln!(out, "✔ lock re-pinned ({} files)", lock.entries.len())?;
    out.finish()?;
    Ok(Code::Ok)
}

/// `--site`, `--dir` or `--remove`: change the overlay, check that the merged
/// policy still lints (restoring the overlay if not), and print the rule exactly
/// as written so the person sees what changed and how to take it back.
fn change_overlay(home: &Home, args: &AllowArgs, out: &mut Deferred) -> Result<()> {
    let path = home.overlay_path();
    let before = Overlay::load(&path)?;
    let mut overlay = before.clone();
    let (verb, rule) = if let Some(id) = &args.remove {
        let ids: Vec<String> = overlay.allow.iter().map(|g| g.id.clone()).collect();
        let rule = overlay.remove(id).with_context(|| {
            format!(
                "no rule `{id}` in {}; rules there: {}",
                path.display(),
                if ids.is_empty() {
                    "none".to_owned()
                } else {
                    ids.join(", ")
                }
            )
        })?;
        ("removed from", rule)
    } else if let Some(host) = &args.site {
        ("added to", overlay.allow_site(host)?.clone())
    } else {
        let dir = args
            .dir
            .as_deref()
            .context("give --site, --dir or --remove")?;
        ("added to", overlay.allow_dir(&dir_spellings(dir)?).clone())
    };
    overlay.save(&path)?;
    if let Err(error) = home.load_policy() {
        before.save(&path)?;
        return Err(error.context(format!("{} left unchanged", path.display())));
    }
    writeln!(out, "✔ {verb} {}:", path.display())?;
    for line in openmoat_core::yaml::to_string(&[&rule])?.lines() {
        writeln!(out, "    {line}")?;
    }
    if args.remove.is_none() {
        if args.dir.is_some() {
            writeln!(
                out,
                "  deny rules still win: secrets and kernel files in it stay denied"
            )?;
        }
        writeln!(out, "  undo: openmoat allow --remove {}", rule.id)?;
        warn_if_shadowed(home, &rule.id, out)?;
    }
    Ok(())
}

/// The directory as written (made absolute) and with its symlinks resolved,
/// since the hook checks a path both ways. Refuses a directory that would open
/// the home directory or the whole machine, and one whose name holds glob
/// characters, which a rule would read as wildcards.
fn dir_spellings(dir: &Path) -> Result<Vec<String>> {
    let written = absolute(dir)?;
    let real = std::fs::canonicalize(&written)
        .with_context(|| format!("{} does not exist", written.display()))?;
    ensure!(real.is_dir(), "{} is not a directory", written.display());
    ensure!(
        crate::project::trusted(&written, &crate::home::user_home()?),
        "refusing to allow {}: it is the home directory, one of its ancestors or a \
         filesystem root; name a directory inside it",
        written.display()
    );
    let mut spellings = vec![path_string(&written)];
    let real = path_string(&real);
    if real != spellings[0] {
        spellings.push(real);
    }
    ensure!(
        !spellings
            .iter()
            .any(|s| s.contains(['*', '?', '[', ']', '{', '}', '\\'])),
        "{} contains glob characters; write it in the policy with `openmoat edit` instead",
        written.display()
    );
    Ok(spellings)
}

/// A rule a deny already covers can never decide anything; say so rather than
/// let the person believe the command is now allowed.
fn warn_if_shadowed(home: &Home, id: &str, out: &mut Deferred) -> Result<()> {
    let Ok(policy) = home.load_policy() else {
        return Ok(());
    };
    for warning in openmoat_core::lint::warnings(&policy) {
        if warning.rule == id {
            writeln!(out, "warning: {warning}")?;
        }
    }
    Ok(())
}

/// The action an ask named, as a grant or rule approves it ([`approvals::approvable`]).
pub(super) fn asked(event: &Event) -> Result<Action> {
    let cwd = match event.cwd.as_deref() {
        Some(cwd) => Some(path_string(&absolute(Path::new(cwd))?)),
        None => None,
    };
    let home = path_string(&crate::home::user_home()?);
    event
        .action
        .as_ref()
        .and_then(|a| approvals::approvable(a, cwd.as_deref(), &home))
        .with_context(|| {
            format!(
                "ask {} names no file `openmoat allow` can approve (none, or a relative path \
                 without a working directory); allow it with `openmoat edit` instead",
                event.id
            )
        })
}

/// The most recent `ask` for a shell command or a file: what `--last` takes.
pub(super) fn newest_ask(store: &openmoat_audit::Store) -> Result<Option<Event>> {
    Ok(store.recent(LAST_ASK_SEARCH)?.into_iter().find(|e| {
        e.verdict == Verdict::Ask
            && matches!(
                e.action,
                Some(
                    Action::Shell { .. }
                        | Action::FsRead { .. }
                        | Action::FsWrite { .. }
                        | Action::Patch { .. }
                        | Action::ReadFiles { .. }
                )
            )
    }))
}