openmoat 0.8.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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! The policy lock: a pinned set of file digests the kernel refuses to run without.
//!
//! `moat init` pins the policy and every host hook file it installed. `moat guard`
//! recomputes the digests on every call and denies everything when a pinned file
//! changed or disappeared, so an agent that edits `~/.moat/policy.yaml` or removes
//! a hook gets a `deny`, not a quieter kernel. Only a human can re-pin
//! (`moat init`, or `moat doctor --accept` from a terminal). Drift of only the
//! Claude Code settings `relaxed.rs` lists is reported the same way, but the
//! guard keeps deciding through it.

use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context as _, Result, bail};
use openmoat_core::{Decision, Verdict};
use serde::{Deserialize, Serialize};

use crate::home::{Home, write_private};
use crate::install::{HookState, HostConfig};

mod digest;
mod keys;
mod relaxed;

pub use digest::sha256_hex;
use digest::{ReadOnce, digest, digests, is_claude_settings};
pub use relaxed::{check, split};

const LOCK_VERSION: u32 = 1;
/// Rule id of every decision the lock forces.
pub const INTEGRITY_RULE: &str = "kernel-integrity";

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lock {
    pub version: u32,
    pub pinned_at_ms: i64,
    /// Absolute path of the `moat` binary the hooks point at.
    pub binary: String,
    /// Canonical path → lowercase hex SHA-256 of the file contents (of a Claude
    /// Code `settings.json`, without its cosmetic `theme` key; see `digest.rs`).
    pub entries: BTreeMap<String, String>,
    /// Codex `config.toml` → SHA-256 of the part OpenMoat owns (its permissions
    /// profile, ADR-018). Codex edits the rest of that file itself.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub codex_profiles: BTreeMap<String, String>,
    /// Pinned JSON or TOML file → top-level key → SHA-256 of its value, so drift
    /// can name the keys that changed. Locks written before #288 have none.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub keys: BTreeMap<String, BTreeMap<String, String>>,
    /// Claude Code `settings.json` → `permissions` key → SHA-256 of its value,
    /// so the guard can tell a changed toggle from a changed hook (`relaxed.rs`).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub permission_keys: BTreeMap<String, BTreeMap<String, String>>,
    /// MCP server definitions a person accepted (`mcp/pins.rs`). `None` in a
    /// lock from before MCP pinning; their drift holds back only that server.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mcp_servers: Option<crate::mcp::Pins>,
}

/// Which hook files a re-pin covers, besides OpenMoat's own state files.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookPins {
    /// `moat init`: the hook files already pinned plus every hook file installed
    /// under this shell's environment.
    Adopt,
    /// `moat allow`, `moat doctor --accept`: the hook files already pinned. The
    /// hook file an agent reads depends on the agent's environment
    /// (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `CURSOR_CONFIG_DIR`), which need not be
    /// this shell's, so a re-pin must not decide again which files those are.
    Keep,
}

/// Pin everything the kernel trusts: policy, environment snapshot, approval
/// files and the host hook files `hooks` selects. Used by `init`,
/// `doctor --accept` and `allow`, which are the only human paths that may re-pin.
pub fn repin(home: &Home, binary: &Path, hooks: HookPins) -> Result<Lock> {
    repin_with(home, binary, hooks, &[], &[])
}

/// [`repin`], also pinning `files` whole and the Codex profiles in
/// `codex_configs`: the files `moat sandbox sync` just wrote.
pub fn repin_with(
    home: &Home,
    binary: &Path,
    hooks: HookPins,
    files: &[PathBuf],
    codex_configs: &[PathBuf],
) -> Result<Lock> {
    let mut paths = state_files(home);
    paths.extend_from_slice(files);
    let mut profiles = codex_configs.to_vec();
    let lock_path = home.lock_path();
    let had_lock = lock_path.exists();
    let mut mcp_servers = None;
    if had_lock {
        match (Lock::load(&lock_path), hooks) {
            (Ok(old), _) => {
                // Only `moat mcp accept` and `moat doctor --accept` change these.
                mcp_servers = old.mcp_servers;
                paths.extend(old.entries.into_keys().map(PathBuf::from));
                profiles.extend(old.codex_profiles.into_keys().map(PathBuf::from));
            }
            (Err(e), HookPins::Keep) => {
                return Err(
                    e.context("cannot tell which hook files are pinned; run `openmoat init`")
                );
            }
            // `init` is how a person recovers from an unreadable lock.
            (Err(_), HookPins::Adopt) => {}
        }
    }
    // Without a lock there is nothing to keep, so this environment's hooks it is.
    if hooks == HookPins::Adopt || !had_lock {
        paths.extend(installed_hook_files(home, binary)?);
        profiles.extend(crate::sandbox::install::codex_profile_files(home)?);
        paths.extend(crate::sandbox::install::cursor_sandbox_files(home)?);
    }
    let mut lock = Lock::pin(binary, &paths)?;
    lock.mcp_servers = mcp_servers;
    for path in profiles.iter().filter(|p| p.is_file()) {
        let digest = crate::sandbox::install::codex_part_digest(path)?;
        lock.codex_profiles.insert(key(path), digest);
    }
    lock.save(&lock_path)?;
    Ok(lock)
}

/// OpenMoat's own files, pinned by every lock whatever the environment.
fn state_files(home: &Home) -> Vec<PathBuf> {
    vec![
        home.policy_path(),
        home.environment_path(),
        home.grants_path(),
        home.overlay_path(),
        home.trust_path(),
        home.hosts_path(),
    ]
}

/// The signing files only `moat policy trust-key`, `install` and `remove` pin
/// (ADR-031): the allowed signers, the pack record and each installed pack.
/// Another re-pin keeps their pins and never adds them, so a file planted
/// there is never trusted by a re-pin made for something else.
pub fn signing_files(home: &Home) -> Vec<PathBuf> {
    let mut files = vec![home.signers_path(), home.packs_path()];
    files.extend(crate::packs::installed_files(home));
    files
}

/// Hook files with a current `moat` hook under this shell's environment.
pub fn installed_hook_files(home: &Home, binary: &Path) -> Result<Vec<PathBuf>> {
    let mut paths = Vec::new();
    for host in openmoat_hosts::Host::ALL {
        let config = HostConfig::for_host(home, host)?;
        if config.state(binary) == HookState::Installed {
            if host == openmoat_hosts::Host::OpenClaw {
                paths.push(crate::install::openclaw::plugin_path(&config));
            }
            paths.push(config.settings_path);
        }
    }
    Ok(paths)
}

/// How this shell's hook files differ from the ones the lock pins.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct HookPinGap {
    /// Installed here but not pinned: changes to them go unseen until `moat init`.
    pub unpinned: Vec<PathBuf>,
    /// Pinned but not this shell's: another environment's hook files, still verified.
    pub elsewhere: Vec<PathBuf>,
}

impl HookPinGap {
    pub fn new(lock: &Lock, home: &Home, installed: &[PathBuf]) -> Self {
        // This shell's Cursor `sandbox.json` is pinned whole, but is no hook file.
        let mut state = state_files(home);
        state.extend(signing_files(home));
        state.extend(crate::sandbox::install::cursor_sandbox_files(home).unwrap_or_default());
        let state: Vec<String> = state.iter().map(|p| key(p)).collect();
        let here: Vec<String> = installed.iter().map(|p| key(p)).collect();
        Self {
            unpinned: installed
                .iter()
                .filter(|p| !lock.pins(p))
                .cloned()
                .collect(),
            elsewhere: lock
                .entries
                .keys()
                .filter(|k| !state.contains(k) && !here.contains(k))
                .map(PathBuf::from)
                .collect(),
        }
    }
}

/// `Some(deny)` when the pinned policy or hook files changed since `moat init`.
pub fn violation(home: &Home) -> Result<Option<Decision>> {
    let lock_path = home.lock_path();
    if !lock_path.is_file() {
        bail!(
            "no policy lock at {}; run `openmoat init`",
            lock_path.display()
        );
    }
    Ok(deny_for(&Lock::load(&lock_path)?.verify()))
}

/// The `kernel-integrity` deny naming `drift`; `None` when there is none.
fn deny_for(drift: &[Drift]) -> Option<Decision> {
    if drift.is_empty() {
        return None;
    }
    let mut decision = Decision::new(Verdict::Deny);
    decision.rules.push(INTEGRITY_RULE.to_owned());
    for d in drift {
        decision.reasons.push(d.to_string());
    }
    decision.reasons.push(
        "run `openmoat doctor` to inspect; `openmoat doctor --accept` or `openmoat init` to re-pin"
            .to_owned(),
    );
    Some(decision)
}

/// Refuse a person's command that re-pins while pinned files drifted: the
/// re-pin would accept a tampered file unseen. `doing` completes "refusing to …".
pub fn refuse_drift(home: &Home, doing: &str) -> Result<()> {
    if let Some(deny) = violation(home)? {
        bail!(
            "refusing to {doing} while the policy lock shows drift ({INTEGRITY_RULE}):\n  {}",
            deny.reasons.join("\n  ")
        );
    }
    Ok(())
}

/// Forget the pins of `paths`, the host files `moat uninstall` just undid, and
/// pin `rewritten` whole, the files a person's command just wrote (`hosts.json`,
/// or a pinned file `moat mcp wrap` changed). Every other pin stays as it is,
/// so no drift elsewhere is accepted.
pub fn unpin(home: &Home, paths: &[PathBuf], rewritten: &[PathBuf]) -> Result<()> {
    let lock_path = home.lock_path();
    if !lock_path.is_file() {
        return Ok(());
    }
    let mut lock = Lock::load(&lock_path)?;
    for path in paths.iter().map(|p| key(p)) {
        lock.codex_profiles.remove(&path);
    }
    for path in paths.iter().chain(rewritten).map(|p| key(p)) {
        lock.entries.remove(&path);
        lock.keys.remove(&path);
        lock.permission_keys.remove(&path);
    }
    let fresh = Lock::pin(Path::new(&lock.binary), rewritten)?;
    lock.entries.extend(fresh.entries);
    lock.keys.extend(fresh.keys);
    lock.permission_keys.extend(fresh.permission_keys);
    lock.save(&lock_path)
}

/// One way a pinned file differs from the lock.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Drift {
    Modified(PathBuf),
    /// Modified, with the top-level keys that differ from the pin.
    KeysChanged(PathBuf, String),
    Missing(PathBuf),
    Unreadable(PathBuf, String),
    /// A staged copy (`proposal`) that would change the pinned `target`.
    Proposed {
        proposal: PathBuf,
        target: PathBuf,
    },
}

impl fmt::Display for Drift {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Modified(p) => write!(f, "{} was modified", p.display()),
            Self::KeysChanged(p, keys) => write!(f, "{} was modified: {keys}", p.display()),
            Self::Missing(p) => write!(f, "{} is missing", p.display()),
            Self::Unreadable(p, why) => write!(f, "{} is unreadable: {why}", p.display()),
            Self::Proposed { proposal, target } => write!(
                f,
                "{} would change {}",
                proposal.display(),
                target.display()
            ),
        }
    }
}

impl Lock {
    /// Pin the current contents of `paths`. Files that do not exist are skipped;
    /// callers pass only files they just wrote or verified.
    pub fn pin(binary: &Path, paths: &[PathBuf]) -> Result<Self> {
        let (mut entries, mut keys, mut permission_keys) =
            (BTreeMap::new(), BTreeMap::new(), BTreeMap::new());
        for path in paths {
            if path.is_file() {
                entries.insert(key(path), digest(path, is_claude_settings(path))?);
                if let Some(digests) = keys::key_digests(path) {
                    keys.insert(key(path), digests);
                }
                if let Some(digests) = relaxed::permission_digests(path) {
                    permission_keys.insert(key(path), digests);
                }
            }
        }
        Ok(Self {
            version: LOCK_VERSION,
            pinned_at_ms: crate::time::now_ms(),
            binary: binary.to_string_lossy().into_owned(),
            entries,
            codex_profiles: BTreeMap::new(),
            keys,
            permission_keys,
            mcp_servers: None,
        })
    }

    pub fn load(path: &Path) -> Result<Self> {
        let bytes = fs::read(path).with_context(|| format!("reading lock {}", path.display()))?;
        Self::parse(&bytes, path)
    }

    /// The lock in `bytes`, read from `path` (named in errors).
    pub fn parse(bytes: &[u8], path: &Path) -> Result<Self> {
        let lock: Self = serde_json::from_slice(bytes)
            .with_context(|| format!("parsing lock {}", path.display()))?;
        if lock.version != LOCK_VERSION {
            bail!(
                "lock {} has version {}; this build supports {LOCK_VERSION}",
                path.display(),
                lock.version
            );
        }
        Ok(lock)
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        let text = serde_json::to_string_pretty(self)? + "\n";
        write_private(path, text.as_bytes())
    }

    /// Compare pinned digests with the files on disk. Empty means intact.
    pub fn verify(&self) -> Vec<Drift> {
        self.verify_keeping(None).0
    }

    /// [`Lock::verify`], also returning the bytes of the pinned file `keep`
    /// that were checked, when it is pinned and intact. Each file is read once,
    /// so a caller that parses these bytes uses exactly what matched the pin,
    /// even when the file changes after the check.
    pub fn verify_keeping(&self, keep: Option<&Path>) -> (Vec<Drift>, Option<Vec<u8>>) {
        let keep = keep.map(key);
        let mut kept = None;
        let mut drift = Vec::new();
        for (stored, expected) in &self.entries {
            // A stored key is canonical as of pinning. If it no longer maps to
            // itself, a directory on the way was moved or replaced by a link:
            // the file the kernel will read is not the one that was pinned.
            if key(Path::new(stored)) != *stored {
                drift.push(Drift::Modified(PathBuf::from(stored)));
                continue;
            }
            match self.check_one(Path::new(stored), expected) {
                Ok(read) if keep.as_ref() == Some(stored) => kept = Some(read.contents),
                Ok(_) => {}
                Err(found) => drift.push(found),
            }
        }
        let profiles = self.codex_profiles.iter().filter_map(|(stored, expected)| {
            let path = PathBuf::from(stored);
            if key(&path) != *stored {
                return Some(Drift::Modified(path));
            }
            if fs::symlink_metadata(&path).is_err() {
                return Some(Drift::Missing(path));
            }
            match crate::sandbox::install::codex_part_digest(&path) {
                Ok(actual) if &actual == expected => None,
                Ok(_) => Some(Drift::Modified(path)),
                Err(e) => Some(Drift::Unreadable(path, format!("{e:#}"))),
            }
        });
        drift.extend(profiles);
        (drift, kept)
    }

    /// Whether the lock pins the moat profile in the Codex config at `path`.
    pub fn pins_codex_profile(&self, path: &Path) -> bool {
        self.codex_profiles.contains_key(&key(path))
    }

    /// Drift for one pinned file, or `None` when it is intact or not pinned.
    pub fn verify_one(&self, path: &Path) -> Option<Drift> {
        let expected = self.entries.get(&key(path))?;
        self.check_one(&PathBuf::from(key(path)), expected).err()
    }

    /// The pinned file at `path`, read once and matching `expected`, or how it
    /// differs.
    fn check_one(&self, path: &Path, expected: &str) -> Result<ReadOnce, Drift> {
        if fs::symlink_metadata(path).is_err() {
            return Err(Drift::Missing(path.to_path_buf()));
        }
        let read = ReadOnce::from(path)
            .map_err(|e| Drift::Unreadable(path.to_path_buf(), format!("{e:#}")))?;
        if read
            .digests(is_claude_settings(path))
            .iter()
            .any(|d| d == expected)
        {
            Ok(read)
        } else {
            Err(self.modified(path.to_path_buf()))
        }
    }

    /// Drift that renaming `proposal` over the pinned `target` would leave: the
    /// target's own drift, else any difference between the proposal and the
    /// pinned contents. `None` when the target is not pinned.
    pub fn verify_proposal(&self, proposal: &Path, target: &Path) -> Option<Drift> {
        let expected = self.entries.get(&key(target))?;
        if let Some(drift) = self.verify_one(target) {
            return Some(drift);
        }
        match digests(proposal, is_claude_settings(target)) {
            Ok(actual) if actual.contains(expected) => None,
            Ok(_) => Some(Drift::Proposed {
                proposal: proposal.to_path_buf(),
                target: PathBuf::from(key(target)),
            }),
            Err(e) => Some(Drift::Unreadable(proposal.to_path_buf(), format!("{e:#}"))),
        }
    }

    /// [`Drift::KeysChanged`] when the pin recorded the file's top-level keys
    /// and some differ now, else [`Drift::Modified`].
    fn modified(&self, path: PathBuf) -> Drift {
        let now = keys::key_digests(&path);
        let pinned = self.keys.get(&key(&path));
        match pinned.zip(now).and_then(|(p, n)| keys::describe(p, &n)) {
            Some(keys) => Drift::KeysChanged(path, keys),
            None => Drift::Modified(path),
        }
    }

    pub fn pins(&self, path: &Path) -> bool {
        self.entries.contains_key(&key(path))
    }
}

/// Identity of a pinned file: its canonical directory plus its own name. The
/// leaf is deliberately not resolved, so a file that is later replaced by a
/// symlink keeps the same key and is compared, not forgotten. The parent is
/// canonicalised so `/var/...` and `/private/var/...` agree on macOS and a
/// deleted file still has a key.
fn key(path: &Path) -> String {
    let located = match (path.parent(), path.file_name()) {
        (Some(parent), Some(name)) => {
            fs::canonicalize(parent).map_or_else(|_| path.to_path_buf(), |p| p.join(name))
        }
        _ => path.to_path_buf(),
    };
    let text = located.to_string_lossy();
    if cfg!(windows) {
        crate::context::strip_verbatim(&text)
    } else {
        text.into_owned()
    }
}

#[cfg(test)]
mod tests;