openmoat 0.15.0

Policy checks for AI coding agents: decides allow, defer, review or deny for every tool call from Claude Code, Codex, Cursor, OpenClaw, GitHub Copilot CLI, pi and Gemini CLI, configures Claude Code's, Codex's and Cursor'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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! Each agent's protection level, as `moat status`, `moat doctor` and the home
//! screen report it. It is derived only from checks moat already makes: the
//! hook's state and, for the hosts OpenMoat configures a sandbox for, whether
//! that sandbox is in place and matches the policy.

use std::path::Path;

use anyhow::Result;
use openmoat_hosts::Host;
use serde::Serialize;

use crate::home::Home;
use crate::install::{HookState, HostConfig, Recorded};
use crate::integrity::{Drift, Lock};
use crate::sandbox::{Plan, install as host_sandbox};

/// How much of the policy is enforced for one agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Level {
    /// The hook is installed and current, and the generated sandbox matches the policy.
    HookAndOsSandbox,
    /// The hook is installed and current, but no OS sandbox from OpenMoat is in force.
    HookOnly,
    /// The hook is missing, stale or unreadable.
    NotProtected,
    /// The agent is on this machine, but `moat init` left it out.
    NotSetUp,
    /// The agent's configuration directory does not exist.
    NotFound,
}

impl Level {
    pub fn label(self) -> &'static str {
        match self {
            Self::HookAndOsSandbox => "hook + OS sandbox",
            Self::HookOnly => "hook only",
            Self::NotProtected => "not protected",
            Self::NotSetUp => "not set up",
            Self::NotFound => "host not found",
        }
    }

    /// Whether the hook applies the policy to the agent's calls.
    pub fn hooked(self) -> bool {
        matches!(self, Self::HookAndOsSandbox | Self::HookOnly)
    }
}

/// One agent's protection level, why it is not higher, and what it never covers.
#[derive(Debug, Serialize)]
pub struct Protection {
    pub host: &'static str,
    pub name: &'static str,
    pub level: Level,
    /// Why the level is `hook-only` or `not-protected`.
    pub reason: Option<String>,
    /// What the hook and sandbox do not cover for this agent
    /// ([`HostSpec::gaps`](openmoat_hosts::HostSpec::gaps)).
    pub gaps: &'static str,
    /// The limit a person must not miss, printed on its own line
    /// ([`HostSpec::limit`](openmoat_hosts::HostSpec::limit)).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub limit: Option<&'static str>,
}

impl Protection {
    /// `<level>[: <reason>]`, or `None` for an agent not set up or not found,
    /// which the hook lines already report.
    pub fn summary(&self) -> Option<String> {
        if matches!(self.level, Level::NotSetUp | Level::NotFound) {
            return None;
        }
        Some(match &self.reason {
            Some(reason) => format!("{}: {reason}", self.level.label()),
            None => self.level.label().to_owned(),
        })
    }
}

/// Every supported agent's protection, in [`Host::ALL`] order.
pub fn report(home: &Home, binary: &Path) -> Result<Vec<Protection>> {
    let plan = home
        .load_policy()
        .ok()
        .and_then(|p| Plan::new(home, &p).ok());
    let lock = Lock::load(&home.lock_path()).ok();
    let recorded = Recorded::load(home).unwrap_or_default();
    Host::ALL
        .into_iter()
        .map(|host| {
            let config = HostConfig::for_host(home, host)?;
            // A version that does not load OpenMoat's hook (OpenCode before 1.3.4).
            let unsupported = config
                .host_present()
                .then(|| crate::install::host_version::unsupported(host))
                .flatten();
            let (level, reason) = match unsupported {
                Some(why) => (Level::NotProtected, Some(why)),
                None => level(
                    &config.state(binary),
                    config.host_present(),
                    recorded.skipped(host),
                    || {
                        crate::project_settings::switched_off(
                            host,
                            config.dir(),
                            &config.settings_path,
                        )
                        .map(|off| format!("hook turned off by {off}"))
                        .or_else(|| {
                            crate::project_settings::replaced_here(host, &config.settings_path)
                        })
                        .or_else(|| {
                            crate::heartbeat::check(home, &config).and_then(|loaded| {
                                crate::heartbeat::not_loaded(host.display_name(), &loaded)
                            })
                        })
                    },
                    || sandbox(home, host, plan.as_ref(), lock.as_ref()),
                ),
            };
            Ok(Protection {
                host: host.id(),
                name: host.display_name(),
                level,
                reason,
                gaps: host.spec().gaps,
                limit: host.spec().limit,
            })
        })
        .collect()
}

/// The level from the hook's state, the agent's presence, whether `moat init`
/// skipped it, and (read only when the hook is installed) why the installed
/// hook does not run (the agent's own settings turn it off, or its plugin has
/// not loaded though the agent ran, ADR-042) and its sandbox check.
fn level(
    hook: &HookState,
    present: bool,
    skipped: bool,
    not_running: impl FnOnce() -> Option<String>,
    sandbox: impl FnOnce() -> Result<(), String>,
) -> (Level, Option<String>) {
    let not_protected = |why: &str| (Level::NotProtected, Some(why.to_owned()));
    match hook {
        HookState::Installed => match not_running() {
            Some(why) => (Level::NotProtected, Some(why)),
            None => match sandbox() {
                Ok(()) => (Level::HookAndOsSandbox, None),
                Err(why) => (Level::HookOnly, Some(why)),
            },
        },
        HookState::Missing if !present => (Level::NotFound, None),
        HookState::Missing if skipped => (Level::NotSetUp, None),
        HookState::Missing => not_protected("hook missing"),
        HookState::Stale { .. } => not_protected("hook out of date"),
        HookState::Unreadable(_) => not_protected("hook file unreadable"),
        HookState::Unapproved(_) => not_protected("hook not approved by the agent"),
        HookState::FailsOpen(_) => not_protected("hook runs the call when it fails"),
    }
}

/// [`os_sandboxed`] as the guard reads it: kept in `sandboxed.json` in the
/// state directory under the digest of `policy.lock`. The lock pins the policy
/// and every host's sandbox settings, and the guard has verified them against
/// it before deciding (a drifted pin denies the call), so while the lock is the
/// same the answer is too; a changed policy or sandbox re-pins the lock, which
/// makes the next call work it out again. Working it out compiles the policy's
/// sandbox plan (tens of milliseconds), which a session that runs many scripts
/// it wrote would otherwise pay on every call. The agent cannot write the state
/// directory (`kernel-self`); a file that cannot be read or written only means
/// the answer is worked out again.
///
/// The lock alone is not the whole key: `drifted` is the drift the guard
/// decides through (`integrity/relaxed.rs`), which leaves the lock as it was.
/// One such setting, Claude Code's `permissions.blockReadsOutsideWorkingDirectories`,
/// is part of the answer, so the drifted files' bytes are in the key too.
pub fn os_sandboxed_by_lock(home: &Home, host: Host, drifted: &[Drift]) -> bool {
    if host_sandbox::format(host).is_none() || host_sandbox::unavailable(host).is_some() {
        return false;
    }
    let Ok(lock) = std::fs::read(home.lock_path()) else {
        return os_sandboxed(home, host);
    };
    // Bytes the check did not keep cannot be keyed: work the answer out.
    let Some(drift) = home.kept().drift_digest(drifted) else {
        return os_sandboxed(home, host);
    };
    let mut digest = crate::integrity::sha256_hex(&lock);
    if !drift.is_empty() {
        digest = format!("{digest}+{drift}");
    }
    let path = home.root().join("sandboxed.json");
    let mut kept: SandboxedByLock = std::fs::read(&path)
        .ok()
        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
        .filter(|k: &SandboxedByLock| k.lock == digest)
        .unwrap_or_else(|| SandboxedByLock {
            lock: digest,
            hosts: std::collections::BTreeMap::new(),
        });
    if let Some(known) = kept.hosts.get(host.id()) {
        return *known;
    }
    let answer = os_sandboxed(home, host);
    kept.hosts.insert(host.id().to_owned(), answer);
    if let Ok(bytes) = serde_json::to_vec(&kept) {
        let _ = crate::home::write_private(&path, &bytes);
    }
    answer
}

/// `sandboxed.json`: [`os_sandboxed`] per host id, for the lock whose digest
/// it names.
#[derive(serde::Serialize, serde::Deserialize)]
struct SandboxedByLock {
    lock: String,
    hosts: std::collections::BTreeMap<String, bool>,
}

/// Whether OpenMoat's OS sandbox runs `host`'s shell commands here: the host
/// has one (its spec), it applies on this OS, and its settings are in place
/// and match the policy and the lock, as `status` reports `hook + OS sandbox`.
/// The guard reads it through [`os_sandboxed_by_lock`], only for a call that
/// runs code its session wrote (`session_code.rs`).
pub fn os_sandboxed(home: &Home, host: Host) -> bool {
    if host_sandbox::format(host).is_none() || host_sandbox::unavailable(host).is_some() {
        return false;
    }
    let plan = home
        .load_policy()
        .ok()
        .and_then(|p| Plan::new(home, &p).ok());
    let lock = Lock::load(&home.lock_path()).ok();
    sandbox(home, host, plan.as_ref(), lock.as_ref()).is_ok()
}

/// `Ok` when OpenMoat's sandbox for `host` is in place and matches the policy;
/// otherwise which of it is missing, off or drifted.
fn sandbox(
    home: &Home,
    host: Host,
    plan: Option<&Plan>,
    lock: Option<&Lock>,
) -> Result<(), String> {
    if host_sandbox::format(host).is_none() {
        return Err(format!(
            "no OS sandbox from OpenMoat; {} puts {} in one (docs/SANDBOX.md)",
            crate::commands::sandboxing_command(),
            host.display_name()
        ));
    }
    if let Some(note) = host_sandbox::unavailable(host) {
        return Err(note.to_owned());
    }
    let plan = plan.ok_or("the policy does not load, so the sandbox cannot be checked")?;
    match host_sandbox::problems(home, host, plan, lock) {
        Ok(Some(problems)) => match problems.as_slice() {
            [] => Ok(()),
            [only] => Err(format!("sandbox: {only}")),
            [first, rest @ ..] => Err(format!("sandbox: {first} (and {} more)", rest.len())),
        },
        Ok(None) => Err("sandbox settings missing".to_owned()),
        Err(error) => Err(format!("sandbox: {error:#}")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The answer kept under the lock's digest is used while the lock is the
    /// same, and worked out again once the lock changes.
    #[test]
    fn the_sandbox_answer_is_kept_by_the_lock_digest() {
        // Any host OpenMoat puts a sandbox in place for on this OS (named by its
        // spec, not by name); none on this OS means nothing to keep.
        let Some(host) = Host::ALL.into_iter().find(|h| {
            host_sandbox::format(*h).is_some() && host_sandbox::unavailable(*h).is_none()
        }) else {
            return;
        };
        let dir = tempfile::tempdir().unwrap();
        let home = Home::at(dir.path().join("state"));
        std::fs::create_dir_all(home.root()).unwrap();
        std::fs::write(home.lock_path(), b"lock one").unwrap();
        // An empty home has no sandbox settings in place.
        assert!(!os_sandboxed_by_lock(&home, host, &[]));
        let kept = home.root().join("sandboxed.json");
        let text = std::fs::read_to_string(&kept).unwrap();
        assert!(
            text.contains(&crate::integrity::sha256_hex(b"lock one")),
            "{text}"
        );
        // A kept answer is read back as it is while the lock is unchanged.
        std::fs::write(&kept, text.replace("false", "true")).unwrap();
        assert!(os_sandboxed_by_lock(&home, host, &[]));
        // A new lock makes the answer be worked out again.
        std::fs::write(home.lock_path(), b"lock two").unwrap();
        assert!(!os_sandboxed_by_lock(&home, host, &[]));
    }

    /// Drift the guard decides through leaves the lock as it was, but can
    /// change the answer (Claude Code's `blockReadsOutsideWorkingDirectories`
    /// turned off): the kept answer is not used for it.
    #[test]
    fn relaxed_drift_is_part_of_the_kept_answers_key() {
        let Some(host) = Host::ALL.into_iter().find(|h| {
            host_sandbox::format(*h).is_some() && host_sandbox::unavailable(*h).is_none()
        }) else {
            return;
        };
        let dir = tempfile::tempdir().unwrap();
        let home = Home::at(dir.path().join("state"));
        std::fs::create_dir_all(home.root()).unwrap();
        let settings = dir.path().join("settings.json");
        let on = r#"{"permissions":{"blockReadsOutsideWorkingDirectories":true}}"#;
        std::fs::write(&settings, on).unwrap();
        let lock = Lock::pin(Path::new("/x/moat"), std::slice::from_ref(&settings)).unwrap();
        lock.save(&home.lock_path()).unwrap();
        assert!(!os_sandboxed_by_lock(&home, host, &[]));
        // Say the answer kept for this lock is "sandboxed".
        let kept = home.root().join("sandboxed.json");
        let text = std::fs::read_to_string(&kept).unwrap();
        std::fs::write(&kept, text.replace("false", "true")).unwrap();
        assert!(os_sandboxed_by_lock(&home, host, &[]));

        // The setting is turned off; the lock is unchanged.
        let off = r#"{"permissions":{"blockReadsOutsideWorkingDirectories":false}}"#;
        std::fs::write(&settings, off).unwrap();
        let (drift, bytes) = lock.verify_keeping(&[], &[]);
        assert!(
            matches!(drift.as_slice(), [Drift::KeysChanged(..)]),
            "{drift:?}"
        );
        let checked = home.keeping(bytes);
        assert!(
            !os_sandboxed_by_lock(&checked, host, &drift),
            "the answer kept for the lock is not the answer for the drifted settings"
        );
        // Without the bytes the check read, nothing kept is used either.
        assert!(!os_sandboxed_by_lock(&home, host, &drift));
        // The drift's own answer is kept under its own key and read back.
        let text = std::fs::read_to_string(&kept).unwrap();
        assert!(text.contains('+'), "{text}");
    }

    #[test]
    fn hosts_without_an_openmoat_sandbox_are_never_sandboxed() {
        let dir = tempfile::tempdir().unwrap();
        let home = Home::at(dir.path().join("state"));
        // decided from the spec alone, before any host file is read
        for host in Host::ALL.into_iter().filter(|h| h.spec().sandbox.is_none()) {
            assert!(!os_sandboxed(&home, host), "{}", host.id());
        }
    }

    #[test]
    fn an_installed_hook_with_a_matching_sandbox_is_hook_and_os_sandbox() {
        let got = level(&HookState::Installed, true, false, || None, || Ok(()));
        assert_eq!(got, (Level::HookAndOsSandbox, None));
    }

    #[test]
    fn an_installed_hook_without_the_sandbox_is_hook_only_and_says_why() {
        let got = level(
            &HookState::Installed,
            true,
            false,
            || None,
            || Err("sandbox: drifted".to_owned()),
        );
        assert_eq!(got, (Level::HookOnly, Some("sandbox: drifted".to_owned())));
    }

    #[test]
    fn an_installed_hook_the_agent_settings_turn_off_is_not_protected() {
        let got = level(
            &HookState::Installed,
            true,
            false,
            || Some("hook turned off by settings.json: `disableAllHooks`".to_owned()),
            || panic!("sandbox read for a hook that is off"),
        );
        assert_eq!(got.0, Level::NotProtected);
        assert_eq!(
            got.1.as_deref(),
            Some("hook turned off by settings.json: `disableAllHooks`")
        );
    }

    /// ADR-042: an agent that ran since its plugin was written, without the
    /// plugin reporting, is not protected, whatever its sandbox.
    #[test]
    fn an_installed_plugin_the_agent_did_not_load_is_not_protected() {
        let why =
            "plugin not loaded since it was written 2026-10-27 10:00 UTC, though pi has run since";
        let got = level(
            &HookState::Installed,
            true,
            false,
            || Some(why.to_owned()),
            || panic!("sandbox read for a hook that does not run"),
        );
        assert_eq!(got, (Level::NotProtected, Some(why.to_owned())));
        let fails_open = HookState::FailsOpen("lacks `fail_closed: true`".to_owned());
        let got = level(&fails_open, true, false, || None, || Ok(()));
        assert_eq!(got.0, Level::NotProtected);
    }

    #[test]
    fn a_missing_stale_or_unreadable_hook_is_not_protected() {
        let stale = HookState::Stale {
            command: "/gone/moat".to_owned(),
        };
        let unreadable = HookState::Unreadable("bad json".to_owned());
        for (hook, why) in [
            (HookState::Missing, "hook missing"),
            (stale, "hook out of date"),
            (unreadable, "hook file unreadable"),
        ] {
            let got = level(
                &hook,
                true,
                false,
                || panic!("settings read without a hook"),
                || panic!("sandbox read without a hook"),
            );
            assert_eq!(got, (Level::NotProtected, Some(why.to_owned())));
        }
    }

    #[test]
    fn an_agent_left_out_or_absent_keeps_its_own_line() {
        assert_eq!(
            level(&HookState::Missing, true, true, || None, || Ok(())),
            (Level::NotSetUp, None)
        );
        assert_eq!(
            level(&HookState::Missing, false, false, || None, || Ok(())),
            (Level::NotFound, None)
        );
    }

    #[test]
    fn summary_skips_agents_with_their_own_line() {
        let protection = |level, reason: Option<&str>| Protection {
            host: "codex",
            name: "Codex",
            level,
            reason: reason.map(str::to_owned),
            gaps: "",
            limit: None,
        };
        assert_eq!(
            protection(Level::HookOnly, Some("why"))
                .summary()
                .as_deref(),
            Some("hook only: why")
        );
        assert_eq!(protection(Level::NotSetUp, None).summary(), None);
    }
}