openmoat 0.12.1

Policy checks for AI coding agents: decides allow, defer, ask 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.
//! Drift in a Claude Code `settings.json`, or in the keys a host's spec lists
//! as relaxed (`spec_keys.rs`), that leaves every guard decision as it was, so
//! the guard keeps deciding each call instead of denying them all.
//!
//! Such drift is still drift: `moat status` and `moat doctor` report it, and
//! only a person re-pins it (`moat doctor --accept`). What may drift is an
//! explicit allowlist of keys. Any other key, a file that no longer parses, a
//! symlink, a removed rule OpenMoat wrote, or a lock without the baseline to
//! compare against counts as tampering, and the guard denies every call.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use anyhow::{Result, bail};
use openmoat_core::Decision;
use serde_json::{Map, Value};

use super::digest::is_claude_settings;
use super::keys::{differing, object_digests};
use super::spec_keys;
use super::{Drift, Kept, Lock, deny_for};
use crate::home::Home;
use crate::install::Recorded;

/// Top-level keys of a Claude Code `settings.json` that may change while the
/// guard keeps deciding. Each changes only how Claude Code looks, words its
/// replies or asks the person itself. None runs a program, loads code, sets
/// the endpoint, or changes a hook, and the hook still decides every call.
/// `theme` is left out of the digest altogether (`digest.rs`); the model the
/// person picks is in [`MODEL_KEYS`].
const RELAXED_KEYS: &[&str] = &[
    // The JSON schema an editor validates the file with; Claude Code ignores it.
    "$schema",
    // Whether and how long the model thinks: same model, same endpoint.
    "alwaysThinkingEnabled",
    "effortLevel",
    // The text Claude Code adds to commits and pull requests.
    "attribution",
    "includeCoAuthoredBy",
    // Not `autoMode`: its rules tell Claude Code's auto-mode classifier which
    // calls to run unasked, and a call the hook defers is then run on their
    // word, as under a `defaultMode` that does not ask (`PROMPTING_MODES`).
    // How long Claude Code keeps its transcripts; OpenMoat's audit log is its own.
    "cleanupPeriodDays",
    // The language and style of replies: wording only.
    "language",
    "outputStyle",
    // Display only: motion, spinner, turn timing, the terminal's progress bar.
    "prefersReducedMotion",
    "showTurnDuration",
    "spinnerTipsEnabled",
    "spinnerTipsOverride",
    "spinnerVerbs",
    "terminalProgressBarEnabled",
    // Whether Claude Code's file picker lists ignored files; the hook decides
    // every read whatever the picker shows.
    "respectGitignore",
];

/// Whether a value has the shape a key allows.
type Shape = fn(&Value) -> bool;

/// Top-level keys Claude Code writes itself when the person switches models
/// (`/model`, #591), each with the only shape its value may take. The model
/// is a name: the endpoint it is sent to (`env`), the key (`apiKeyHelper`) and
/// every hook stay pinned, so it changes which model answers, never what runs
/// or what OpenMoat allows. A value of any other shape, an object or a list,
/// is drift like any other key's. A removed key has no value and may go.
const MODEL_KEYS: &[(&str, Shape)] = &[
    ("model", Value::is_string),
    ("switchModelsOnFlag", is_scalar),
];

/// The top-level key whose own keys are checked one by one.
const PERMISSIONS: &str = "permissions";
/// The list in [`PERMISSIONS`] that holds the rules OpenMoat wrote.
const DENY: &str = "deny";

/// Keys of `permissions` that may change: Claude Code's own permission lists
/// and a mode that keeps its prompts. They decide only whether Claude Code
/// asks the person about a call the hook left to it; Claude Code runs the
/// `PreToolUse` hook in every mode and a hook deny is final whatever these say.
///
/// Left out on purpose: `additionalDirectories`, which also adds directories
/// Claude Code's own sandbox lets commands write, so it can widen an OS
/// boundary the guard does not see; and a [`MODE`] other than
/// [`PROMPTING_MODES`].
const RELAXED_PERMISSIONS: &[&str] = &[
    // Kept relaxed on purpose: the person's own allow list, which Claude Code
    // reads only for calls the hook defers.
    "allow",
    "ask",
    // Only while every rule OpenMoat wrote stays (`keeps_owned_rules`).
    DENY,
    // Only to a mode in `PROMPTING_MODES`, or removed (`mode_relaxed`).
    MODE,
    // Narrows what sandboxed commands and file tools may read. Turning it off
    // widens only the OS sandbox: no guard decision reads the sandbox's state,
    // and `moat status` shows the agent as `hook only` while it is off.
    "blockReadsOutsideWorkingDirectories",
];

/// The `permissions` key naming the mode Claude Code starts in.
const MODE: &str = "defaultMode";

/// The modes in which Claude Code still asks the person before a call the
/// hook deferred runs: `default` asks, `plan` runs nothing, `dontAsk` refuses
/// what no allow rule names. `acceptEdits` writes files unasked, `auto` lets a
/// classifier run calls unasked and `bypassPermissions` runs every call
/// unasked, so a change to one of them (or to any value not listed here) is
/// drift: a deferred call would then run with nobody asked.
const PROMPTING_MODES: &[&str] = &["default", "plan", "dontAsk"];

/// The lock as `moat guard` decides by it.
#[derive(Debug, Default)]
pub struct Checked {
    /// The `kernel-integrity` deny, when anything drifted beyond the allowlist.
    pub deny: Option<Decision>,
    /// Drift the guard decides through: allowlisted keys of a Claude Code
    /// settings file, or a host's relaxed keys, only. Reported, never accepted.
    pub relaxed: Vec<Drift>,
}

/// What [`check`] read, so the caller decides by the same bytes it checked.
#[derive(Debug)]
pub struct Verified {
    /// The lock, read and parsed once.
    pub lock: Lock,
    /// The state directory, reading each pinned state file that matched its
    /// pin from the bytes that matched (`kept.rs`), not from disk again.
    pub home: Home,
    /// The deny, or the settings drift decided through.
    pub checked: Checked,
}

/// [`super::violation`] for `moat guard` and `moat policy check`: no deny
/// while every drift is allowlisted settings drift, which is returned instead.
pub fn check(home: &Home) -> Result<Verified> {
    let lock_path = home.lock_path();
    if !lock_path.is_file() {
        bail!(
            "no policy lock at {}; run `openmoat init`",
            lock_path.display()
        );
    }
    let lock = Lock::load(&lock_path)?;
    let (drift, home) = super::verify_state(home, &lock);
    let checked = split(&home, &lock, drift);
    Ok(Verified {
        lock,
        home,
        checked,
    })
}

/// Sort `drift`, found by `lock`, into a deny or relaxed drift. Relaxed only
/// when all of it is: one tampered file denies every call, as it always did.
/// A settings file's drift is sorted by the bytes the check read, which
/// `home` keeps ([`super::verify_state`]); without them it denies.
pub fn split(home: &Home, lock: &Lock, drift: Vec<Drift>) -> Checked {
    if drift.is_empty() {
        return Checked::default();
    }
    // `hosts.json` is pinned as well: when it drifted, that drift denies.
    let kept = home.kept();
    let relaxed = Recorded::load(home).is_ok_and(|r| {
        drift
            .iter()
            .all(|d| is_relaxed(lock, d, &r.claude_rules, kept))
    });
    if relaxed {
        Checked {
            deny: None,
            relaxed: drift,
        }
    } else {
        Checked {
            deny: deny_for(&drift),
            relaxed: Vec::new(),
        }
    }
}

/// The digests of the `permissions` keys of the Claude Code settings file at
/// `path`, pinned with it; `None` for any other file or one without them.
pub(super) fn permission_digests(path: &Path) -> Option<BTreeMap<String, String>> {
    let root = settings_object(path)?;
    object_digests(root.get(PERMISSIONS)?.as_object()?, false)
}

/// Whether `drift` touches only allowlisted keys of a Claude Code settings
/// file, or only the relaxed keys of a host whose spec lists them, `owned`
/// being the `permissions.deny` rules OpenMoat wrote in Claude Code's and
/// `kept` holding the file's bytes as the check read them.
fn is_relaxed(lock: &Lock, drift: &Drift, owned: &[String], kept: &Kept) -> bool {
    // A plain `Modified` has no keys to compare: no baseline, no object, a link.
    let Drift::KeysChanged(path, _) = drift else {
        return false;
    };
    let stored = path.to_string_lossy();
    // A host's own list replaces Claude Code's for its file.
    if let Some(keys) = spec_keys::listed(path) {
        return lock
            .relaxed_rest
            .get(stored.as_ref())
            .zip(kept.drifted(path))
            .is_some_and(|(pinned, bytes)| spec_keys::only_relaxed(path, bytes, keys, pinned));
    }
    let Some(pinned) = lock.keys.get(stored.as_ref()) else {
        return false;
    };
    // Kept only for a regular Claude Code settings file.
    let Some(root) = kept
        .drifted(path)
        .and_then(|bytes| settings_root(path, bytes))
    else {
        return false;
    };
    let Some(now) = object_digests(&root, true) else {
        return false;
    };
    let pinned_permissions = lock.permission_keys.get(stored.as_ref());
    let had_permissions = pinned.contains_key(PERMISSIONS);
    differing(pinned, &now).all(|key| {
        RELAXED_KEYS.contains(&key.as_str())
            || model_key_relaxed(key, &root)
            || (key == PERMISSIONS
                && permissions_relaxed(pinned_permissions, had_permissions, &root, owned))
    })
}

/// Whether `key` is one of [`MODEL_KEYS`] and its value in `root`, if any,
/// has the shape that key allows.
fn model_key_relaxed(key: &str, root: &Map<String, Value>) -> bool {
    MODEL_KEYS
        .iter()
        .any(|(name, shape)| *name == key && root.get(key).is_none_or(shape))
}

/// A string, number or boolean: a value with no structure inside it.
fn is_scalar(value: &Value) -> bool {
    value.is_string() || value.is_number() || value.is_boolean()
}

/// The settings object at `path`, read once; `None` unless it is a regular
/// Claude Code settings file holding a JSON object.
fn settings_object(path: &Path) -> Option<Map<String, Value>> {
    if !is_claude_settings(path) || !fs::symlink_metadata(path).ok()?.file_type().is_file() {
        return None;
    }
    settings_root(path, &fs::read(path).ok()?)
}

/// The JSON object in `bytes`, read from `path`; `None` unless `path` is a
/// Claude Code settings file and `bytes` hold an object.
fn settings_root(path: &Path, bytes: &[u8]) -> Option<Map<String, Value>> {
    if !is_claude_settings(path) {
        return None;
    }
    match serde_json::from_slice(bytes).ok()? {
        Value::Object(root) => Some(root),
        _ => None,
    }
}

/// Whether only [`RELAXED_PERMISSIONS`] keys of `permissions` changed and the
/// rules OpenMoat wrote are all still denied.
fn permissions_relaxed(
    pinned: Option<&BTreeMap<String, String>>,
    had_permissions: bool,
    root: &Map<String, Value>,
    owned: &[String],
) -> bool {
    let empty = Map::new();
    let now = match root.get(PERMISSIONS) {
        None => &empty,
        Some(Value::Object(permissions)) => permissions,
        Some(_) => return false,
    };
    // Without the pinned keys (a lock written before them, or a pinned value
    // that was no object) nothing tells a toggle from a removed rule.
    let none = BTreeMap::new();
    let pinned = match pinned {
        Some(pinned) => pinned,
        None if !had_permissions => &none,
        None => return false,
    };
    let Some(digests) = object_digests(now, false) else {
        return false;
    };
    let mut changed = differing(pinned, &digests);
    changed.all(|key| {
        RELAXED_PERMISSIONS.contains(&key.as_str()) && (key != MODE || mode_relaxed(now))
    }) && (pinned.get(DENY) == digests.get(DENY) || keeps_owned_rules(now, owned))
}

/// Whether the changed [`MODE`] is removed or one of [`PROMPTING_MODES`].
fn mode_relaxed(permissions: &Map<String, Value>) -> bool {
    permissions
        .get(MODE)
        .is_none_or(|mode| mode.as_str().is_some_and(|m| PROMPTING_MODES.contains(&m)))
}

/// Whether `permissions.deny` still holds every rule OpenMoat wrote. With no
/// record of them a changed list cannot be told from a removed rule, so no.
fn keeps_owned_rules(permissions: &Map<String, Value>, owned: &[String]) -> bool {
    let Some(list) = permissions.get(DENY).and_then(Value::as_array) else {
        return false;
    };
    !owned.is_empty()
        && owned
            .iter()
            .all(|rule| list.iter().any(|v| v.as_str() == Some(rule.as_str())))
}

#[cfg(test)]
mod tests;