openmoat 0.12.0

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.
//! OpenMoat's rules in the `permissions.deny` list of Claude Code's user
//! settings, which it shares with the user's own. A rule is OpenMoat's when
//! `hosts.json` records adding it, so `moat uninstall` takes no rule of the
//! user's, even one spelled the same; versions before that record are known
//! by the spelling of their Linux rules.

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

use super::Generated;
use super::settings::{is_linux_spelling, object_at, strip_deny_rules};

/// Replace the `permissions.deny` rules OpenMoat added with `generated`'s.
/// A rule the list already holds is the user's and stays out of `owned`, so
/// [`super::remove`] never takes it.
pub(super) fn write_deny_rules(
    top: &mut Map<String, Value>,
    generated: &Generated,
    owned: &mut Vec<String>,
) -> Result<()> {
    if let Some(Value::Object(permissions)) = top.get_mut("permissions") {
        strip_deny_rules(permissions, owned);
    }
    owned.clear();
    if generated.deny_rules.is_empty() {
        return Ok(());
    }
    let deny = object_at(top, "permissions")?
        .entry("deny")
        .or_insert_with(|| json!([]));
    let Value::Array(deny) = deny else {
        bail!("`permissions.deny` in the settings file is not an array");
    };
    for rule in &generated.deny_rules {
        if !deny.iter().any(|r| r == rule.as_str()) {
            deny.push(json!(rule));
            owned.push(rule.clone());
        }
    }
    Ok(())
}

/// Whether `rule` is OpenMoat's: one it recorded adding (`owned`), or one
/// spelled the way versions before that record wrote theirs, on Linux only.
pub(super) fn is_owned_rule(rule: &Value, owned: &[String]) -> bool {
    rule.as_str().is_some_and(|r| owned.iter().any(|o| o == r)) || is_linux_spelling(rule)
}

/// Whether `permissions.deny` holds every rule of `generated` and no other
/// rule of OpenMoat's (`owned`, or an earlier version's Linux spelling).
pub(super) fn deny_rules_in_sync(root: &Value, generated: &Generated, owned: &[String]) -> bool {
    let deny: Vec<&Value> = root
        .pointer("/permissions/deny")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .collect();
    generated
        .deny_rules
        .iter()
        .all(|rule| deny.iter().any(|r| *r == rule.as_str()))
        && deny
            .iter()
            .filter(|rule| is_owned_rule(rule, owned))
            .all(|rule| generated.deny_rules.iter().any(|g| *rule == g.as_str()))
}

/// [`apply`](super::apply) for a host without its sandbox (native Windows): only the
/// `permissions.deny` rules, which Claude Code applies itself on every OS, and
/// none of the sandbox keys an older version wrote. Returns whether anything
/// changed.
pub fn apply_rules(
    root: &mut Value,
    generated: &Generated,
    owned: &mut Vec<String>,
) -> Result<bool> {
    let before = root.clone();
    super::remove(root, owned);
    let Value::Object(top) = root else {
        bail!("the settings file is not a JSON object");
    };
    write_deny_rules(top, generated, owned)?;
    Ok(*root != before)
}

#[cfg(test)]
mod tests;