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
448
449
//! Agent settings a repository carries: Claude Code's `.claude/settings.json`
//! (shared with the project) and `.claude/settings.local.json` (the person's
//! own), GitHub Copilot CLI's `.github/copilot/settings*.json` and the hook
//! files in its `.github/hooks`, Gemini CLI's `.gemini/settings.json`, and pi's
//! `.pi/settings.json` and `.pi/extensions`, as each host's spec names them
//! (`HostSpec::project_settings`); and what the person's own settings file of
//! each of those hosts sets that turns OpenMoat's hook off ([`switched_off`]).
//!
//! OpenMoat pins only the user settings file, which holds its hooks. Claude
//! Code merges the project and local files over it, and a key set there wins:
//! `"disableAllHooks": true` in a project file turns OpenMoat's hooks off with
//! every other hook, and a project's own `hooks`, `env` or command helpers run
//! programs or redirect the agent. Repository content is the attacker's
//! (`docs/THREAT_MODEL.md` ยง2), so a `ConfigChange` that would load such a file
//! mid-session is blocked, and `moat doctor` and `moat status` warn about one
//! Claude Code loaded at start.

use std::fs;
use std::io::{self, Read as _};
use std::path::{Path, PathBuf};

use anyhow::{Context as _, Result, bail};
use openmoat_hosts::{Host, ProjectSettingsSpec, SettingsFormat};
use serde_json::{Map, Value};

use crate::home::Home;
use crate::install::HostConfig;

/// The rule a blocked project settings change and its audit row carry.
pub const RULE: &str = "project-settings";

/// A settings file larger than this is refused unread rather than parsed.
const MAX_BYTES: u64 = 1024 * 1024;

/// Keys that name a command Claude Code runs, each with what it is for.
const COMMAND_KEYS: [(&str, &str); 7] = [
    ("apiKeyHelper", "the command that makes the API key"),
    (
        "awsAuthRefresh",
        "the command that refreshes AWS credentials",
    ),
    (
        "awsCredentialExport",
        "the command that exports AWS credentials",
    ),
    (
        "otelHeadersHelper",
        "the command that makes telemetry headers",
    ),
    ("statusLine", "a status line command"),
    ("subagentStatusLine", "a subagent status line command"),
    ("fileSuggestion", "a file suggestion command"),
];

/// What a project settings file sets that could switch OpenMoat off, run a
/// program or redirect the agent; empty when it sets none of that.
///
/// Permission lists and modes are left out on purpose: they decide only
/// whether Claude Code asks the person itself about a call the hook left to
/// it, and Claude Code runs the `PreToolUse` hook in every mode, so OpenMoat's
/// deny and ask stand whatever a project allows. The tool calls of MCP servers
/// a project adds are held by their own pins (ADR-026), but Claude Code starts
/// a server it is told to enable when settings load, which runs its command,
/// so turning project servers on is named.
#[must_use]
pub fn findings(root: &Map<String, Value>) -> Vec<String> {
    let mut found = Vec::new();
    if root
        .get("disableAllHooks")
        .is_some_and(|v| v != &Value::Bool(false))
    {
        found.push("`disableAllHooks` turns off every hook, OpenMoat's included".to_owned());
    }
    if set(root.get("hooks")) {
        found.push("`hooks` adds hooks that run commands".to_owned());
    }
    if set(root.get("env")) {
        found.push(
            "`env` sets environment variables for the session (an API endpoint, a key, a proxy)"
                .to_owned(),
        );
    }
    for (key, what) in COMMAND_KEYS {
        if set(root.get(key)) {
            found.push(format!("`{key}` sets {what}"));
        }
    }
    if root
        .get("enabledPlugins")
        .and_then(Value::as_object)
        .is_some_and(|plugins| plugins.values().any(|v| v != &Value::Bool(false)))
    {
        found.push("`enabledPlugins` turns on plugins, which can carry hooks".to_owned());
    }
    if set(root.get("extraKnownMarketplaces")) {
        found.push(
            "`extraKnownMarketplaces` adds plugin marketplaces, whose plugins can carry hooks"
                .to_owned(),
        );
    }
    for key in ["enableAllProjectMcpServers", "enabledMcpjsonServers"] {
        if set(root.get(key)) {
            found.push(format!(
                "`{key}` starts MCP servers the project's `.mcp.json` names, which runs their \
                 commands (OpenMoat still holds back their tool calls)"
            ));
        }
    }
    if root.get("sandbox").is_some_and(|v| !only_enables(v)) {
        found.push("`sandbox` changes Claude Code's sandbox settings".to_owned());
    }
    found
}

/// How to read one settings file of a host: its format, the directory its
/// relative entries resolve from, and OpenMoat's hook file, which an entry
/// may name to turn it off (pi).
#[derive(Debug, Clone, Copy)]
pub struct Reading<'a> {
    pub format: SettingsFormat,
    pub base: &'a Path,
    pub hook_file: &'a Path,
}

impl Reading<'_> {
    /// What a project's file `root` sets that [`findings`] names, in this format.
    fn findings(&self, root: &Map<String, Value>) -> Vec<String> {
        match self.format {
            SettingsFormat::ClaudeCode => findings(root),
            SettingsFormat::Gemini => gemini::findings(root),
            SettingsFormat::Pi => pi::findings(root, self.base, self.hook_file),
            SettingsFormat::Codex => codex::findings(root),
            SettingsFormat::Cursor => cursor_findings(root),
        }
    }

    /// What the person's own file `root` sets that turns OpenMoat's hook off.
    fn switched_off(&self, root: &Map<String, Value>) -> Vec<String> {
        match self.format {
            SettingsFormat::ClaudeCode => findings(root)
                .into_iter()
                .filter(|found| found.starts_with("`disableAllHooks`"))
                .collect(),
            SettingsFormat::Gemini => gemini::switched_off(root),
            SettingsFormat::Pi => pi::switched_off(root, self.base, self.hook_file),
            SettingsFormat::Codex => codex::switched_off(root),
            SettingsFormat::Cursor => Vec::new(),
        }
    }
}

/// A project's Cursor `hooks.json`: its hooks run beside OpenMoat's.
fn cursor_findings(root: &Map<String, Value>) -> Vec<String> {
    if set(root.get("hooks")) {
        vec!["`hooks` adds hooks that run commands".to_owned()]
    } else {
        Vec::new()
    }
}

/// What `host`'s own settings file in its configuration directory
/// ([`ProjectSettingsSpec::user_file`]) sets that turns OpenMoat's hook off,
/// or why it could not be read; `None` when it is absent or turns nothing off.
/// The host reads it at every start, so a hook that is installed and pinned
/// may still never run.
#[must_use]
pub fn switched_off(host: Host, config_dir: &Path, hook_file: &Path) -> Option<String> {
    let spec = host.spec().project_settings.as_ref()?;
    let path = config_dir.join(spec.user_file);
    let reading = Reading {
        format: spec.format,
        base: config_dir,
        hook_file,
    };
    let found = match read_bytes(&path) {
        Ok(None) => return None,
        Ok(Some(bytes)) => match parse(&path, &bytes) {
            Ok(root) => reading.switched_off(&root),
            Err(e) => vec![format!("cannot be checked: {e:#}")],
        },
        Err(e) => vec![format!("cannot be checked: {e:#}")],
    };
    (!found.is_empty()).then(|| format!("{}: {}", path.display(), found.join("; ")))
}

/// A value that sets something: not `null`, `false`, `""`, `{}` or `[]`.
fn set(value: Option<&Value>) -> bool {
    match value {
        None | Some(Value::Null | Value::Bool(false)) => false,
        Some(Value::String(s)) => !s.is_empty(),
        Some(Value::Object(o)) => !o.is_empty(),
        Some(Value::Array(a)) => !a.is_empty(),
        Some(_) => true,
    }
}

/// `{"enabled": true}` (or nothing at all): a project may turn the sandbox on,
/// never widen or turn off what it allows.
fn only_enables(sandbox: &Value) -> bool {
    match sandbox {
        Value::Null => true,
        Value::Object(o) => o
            .iter()
            .all(|(k, v)| k == "enabled" && v == &Value::Bool(true)),
        _ => false,
    }
}

/// The bytes of `path`, or `None` when there is no such file (a deleted file
/// loads nothing); an error for anything else that cannot be read and for a
/// file larger than [`MAX_BYTES`].
fn read_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
    let file = match fs::File::open(path) {
        Ok(file) => file,
        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
    };
    let mut bytes = Vec::new();
    file.take(MAX_BYTES + 1)
        .read_to_end(&mut bytes)
        .with_context(|| format!("reading {}", path.display()))?;
    if bytes.len() as u64 > MAX_BYTES {
        bail!("{} is larger than {MAX_BYTES} bytes", path.display());
    }
    Ok(Some(bytes))
}

/// The settings object `bytes` (read from `path`) hold; an error for text that
/// is not one JSON object.
fn parse(path: &Path, bytes: &[u8]) -> Result<Map<String, Value>> {
    if path.extension().is_some_and(|e| e == "toml") {
        let text = std::str::from_utf8(bytes)
            .with_context(|| format!("{} is not UTF-8", path.display()))?;
        let doc: toml_edit::DocumentMut = text
            .parse()
            .with_context(|| format!("{} is not valid TOML", path.display()))?;
        return match crate::mcp::servers::toml_to_json(doc.as_item()) {
            Value::Object(root) => Ok(root),
            _ => bail!("{} is not a TOML table", path.display()),
        };
    }
    match serde_json::from_slice(bytes) {
        Ok(Value::Object(root)) => Ok(root),
        Ok(_) => bail!("{} is not a JSON object", path.display()),
        Err(e) => Err(e).with_context(|| format!("{} is not valid JSON", path.display())),
    }
}

/// What the bytes of one Claude Code file set that [`findings`] names, or why
/// they could not be read; `None` when the file is absent or sets nothing risky.
fn judge(path: &Path, bytes: Option<&[u8]>) -> Option<String> {
    judge_with(path, bytes, findings)
}

/// [`judge`] with `found` naming what is risky.
fn judge_with(
    path: &Path,
    bytes: Option<&[u8]>,
    found: impl Fn(&Map<String, Value>) -> Vec<String>,
) -> Option<String> {
    match bytes.map(|bytes| parse(path, bytes)) {
        None => None,
        Some(Ok(root)) => {
            let found = found(&root);
            (!found.is_empty()).then(|| found.join("; "))
        }
        Some(Err(e)) => Some(format!("cannot be checked: {e:#}")),
    }
}

/// What reading one Claude Code file found: the risky keys it sets, or why it
/// could not be read. `None` when the file is absent or sets nothing risky.
#[cfg(test)]
fn check(path: &Path) -> Option<String> {
    check_as(path, findings)
}

/// What reading one file found, `found` naming what is risky in its format:
/// what it sets, or why it could not be read; `None` when the file is absent
/// or sets nothing risky.
fn check_as(path: &Path, found: impl Fn(&Map<String, Value>) -> Vec<String>) -> Option<String> {
    match read_bytes(path) {
        Ok(bytes) => judge_with(path, bytes.as_deref(), found),
        Err(e) => Some(format!("cannot be checked: {e:#}")),
    }
}

/// What reading one Claude Code file found, for a decision: a file that passes is read once more just before
/// the decision, and one whose bytes changed in between (or that appeared or
/// went) is reported, since what the host loads may not be what was checked.
#[must_use]
pub fn check_unchanged(path: &Path) -> Option<String> {
    check_between(path, || {})
}

/// [`check_unchanged`] with `between` run after the first read.
fn check_between(path: &Path, between: impl FnOnce()) -> Option<String> {
    let first = match read_bytes(path) {
        Ok(bytes) => bytes,
        Err(e) => return Some(format!("cannot be checked: {e:#}")),
    };
    if let Some(found) = judge(path, first.as_deref()) {
        return Some(found);
    }
    between();
    match read_bytes(path) {
        Ok(second) if second == first => None,
        Ok(_) => Some("changed while OpenMoat was checking it".to_owned()),
        Err(e) => Some(format!("cannot be checked: {e:#}")),
    }
}

/// The project settings files a host may load for a session in `cwd`: those
/// of `cwd` and of its project root (`project.rs`) in the directory `spec`
/// names, named `name` (one of [`ProjectSettingsSpec::files`]), existing or
/// not. A directory that cannot be a project root (the home directory, which
/// holds the user settings, or an ancestor of it) has none.
#[must_use]
pub fn candidates(
    cwd: &Path,
    user_home: &Path,
    spec: &ProjectSettingsSpec,
    name: &str,
) -> Vec<PathBuf> {
    project_dirs(cwd, user_home)
        .into_iter()
        .map(|dir| dir.join(spec.dir).join(name))
        .collect()
}

/// `cwd` and its project root, each only when it can be a project root.
fn project_dirs(cwd: &Path, user_home: &Path) -> Vec<PathBuf> {
    let mut dirs: Vec<PathBuf> = vec![cwd.to_path_buf()];
    if let Some(root) = crate::project::root_of(cwd, user_home)
        && !dirs.contains(&root)
    {
        dirs.push(root);
    }
    dirs.retain(|dir| crate::project::trusted(dir, user_home));
    dirs
}

/// Every file in `dir` with one of `extensions`, and every directory in it
/// holding an `index` file with one (pi loads `index.ts` of a directory),
/// sorted; none when `dir` cannot be read.
fn hook_files(dir: &Path, extensions: &[&str]) -> Vec<PathBuf> {
    let Ok(entries) = fs::read_dir(dir) else {
        return Vec::new();
    };
    let known = |path: &Path| {
        path.extension()
            .is_some_and(|e| extensions.iter().any(|x| e == *x))
    };
    let mut files: Vec<PathBuf> = entries
        .filter_map(|entry| Some(entry.ok()?.path()))
        .filter(|path| {
            (known(path) && path.is_file())
                || (path.is_dir()
                    && extensions
                        .iter()
                        .any(|x| path.join(format!("index.{x}")).is_file()))
        })
        .collect();
    files.sort();
    files
}

/// One warning per project settings file `host`'s spec names for `cwd` that
/// sets something [`findings`] names or cannot be read, and one per hook file
/// in its project hook directory, for `moat doctor` and `moat status`. The
/// host loads these when a session starts, where no `ConfigChange` fires, so
/// OpenMoat can only point at them. `hook_file` is OpenMoat's own, which a
/// pi project entry may name to turn it off.
#[must_use]
pub fn warnings(cwd: &Path, user_home: &Path, host: Host, hook_file: &Path) -> Vec<String> {
    let Some(spec) = host.spec().project_settings.as_ref() else {
        return Vec::new();
    };
    let row = |path: &Path, found: &str| {
        format!(
            "{}: {found}. A {} session started here loads it; review the file \
             and remove what you did not add",
            path.display(),
            host.display_name()
        )
    };
    let mut rows: Vec<String> = Vec::new();
    for name in spec.files() {
        for path in candidates(cwd, user_home, spec, name) {
            let base = path.parent().unwrap_or(&path).to_path_buf();
            let reading = Reading {
                format: spec.format,
                base: &base,
                hook_file,
            };
            if let Some(found) = check_as(&path, |root| reading.findings(root)) {
                rows.push(row(&path, &found));
            }
        }
    }
    if let Some(dir) = &spec.hook_dir {
        let what = match spec.format {
            SettingsFormat::Pi => "is an extension pi loads with the agent's rights",
            SettingsFormat::ClaudeCode
            | SettingsFormat::Gemini
            | SettingsFormat::Codex
            | SettingsFormat::Cursor => "adds hooks that run commands",
        };
        for path in project_dirs(cwd, user_home)
            .into_iter()
            .flat_map(|root| hook_files(&root.join(dir.path), dir.extensions))
        {
            rows.push(row(&path, what));
        }
    }
    rows
}

/// One [`warnings`] line and the host whose project settings it is about.
pub struct Warning {
    pub host: Host,
    pub text: String,
}

/// [`warnings`] for the current directory, for every host with project
/// settings in its spec, for `moat doctor` and `moat status`; empty when the
/// directory or the home directory cannot be found.
#[must_use]
pub fn warnings_here(home: &Home) -> Vec<Warning> {
    let (Ok(cwd), Ok(user_home)) = (std::env::current_dir(), crate::home::user_home()) else {
        return Vec::new();
    };
    Host::ALL
        .into_iter()
        .filter_map(|host| Some((host, HostConfig::for_host(home, host).ok()?)))
        .flat_map(|(host, config)| {
            warnings(&cwd, &user_home, host, &config.settings_path)
                .into_iter()
                .map(move |text| Warning { host, text })
        })
        .collect()
}

mod codex;
mod gemini;
mod pi;

#[cfg(test)]
mod tests;