openmoat 0.13.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.
//! Merging the generated keys into Cursor's `sandbox.json`, and reading them
//! back for `moat doctor`.

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

use super::Generated;

/// Every top-level key [`apply`] may write, with the sub-keys it owns in an object.
const OWNED: &[(&str, &[&str])] = &[
    ("type", &[]),
    ("readBoundary", &[]),
    ("additionalReadPaths", &[]),
    ("additionalReadwritePaths", &[]),
    ("networkPolicy", &["default", "allow", "deny"]),
];

/// Merge `generated` into the `sandbox.json` document `root`, keeping every key
/// OpenMoat does not own. Returns whether anything changed.
pub fn apply(root: &mut Value, generated: &Generated) -> Result<bool> {
    let Value::Object(top) = root else {
        bail!("the sandbox file is not a JSON object");
    };
    let before = top.clone();
    for (key, value) in &generated.settings {
        match value {
            Value::Object(owned) => {
                let entry = top
                    .entry(key.as_str())
                    .or_insert_with(|| Value::Object(Map::new()));
                let Value::Object(nested) = entry else {
                    bail!("`{key}` in the sandbox file is not an object");
                };
                for (sub, v) in owned {
                    nested.insert(sub.clone(), v.clone());
                }
            }
            other => {
                top.insert(key.clone(), other.clone());
            }
        }
    }
    Ok(*top != before)
}

/// The inverse of [`apply`]: remove every key OpenMoat writes, then an object
/// that leaves empty. Works without a policy, so `moat uninstall` never needs
/// one. Returns whether anything was removed.
pub fn remove(root: &mut Value) -> bool {
    let Value::Object(top) = root else {
        return false;
    };
    let mut removed = false;
    for (key, subs) in OWNED {
        if subs.is_empty() {
            removed |= top.remove(*key).is_some();
        } else if let Some(Value::Object(nested)) = top.get_mut(*key) {
            let before = nested.len();
            nested.retain(|sub, _| !subs.contains(&sub.as_str()));
            if nested.len() != before {
                removed = true;
                if nested.is_empty() {
                    top.remove(*key);
                }
            }
        }
    }
    removed
}

/// Whether every key OpenMoat owns in `root` has its generated value.
pub fn in_sync(root: &Value, generated: &Generated) -> bool {
    generated.settings.iter().all(|(key, value)| match value {
        Value::Object(owned) => owned
            .iter()
            .all(|(sub, v)| root.pointer(&format!("/{key}/{sub}")) == Some(v)),
        other => root.get(key) == Some(other),
    })
}

/// Settings in `root` that weaken the sandbox, worded for `moat doctor`.
pub fn weaknesses(root: &Value) -> Vec<String> {
    let mut out = Vec::new();
    if root.get("type").and_then(Value::as_str) == Some("insecure_none") {
        out.push("type is insecure_none: commands run without Cursor's sandbox".to_owned());
    }
    if root.get("readBoundary").and_then(Value::as_str) != Some("workspace") {
        out.push(
            "readBoundary is not workspace: sandboxed commands read the whole disk, ~/.ssh included"
                .to_owned(),
        );
    }
    if root
        .pointer("/networkPolicy/default")
        .and_then(Value::as_str)
        == Some("allow")
    {
        out.push("networkPolicy.default is allow: sandboxed commands reach every host".to_owned());
    }
    if root
        .get("additionalReadonlyPaths")
        .and_then(Value::as_array)
        .is_some_and(|a| !a.is_empty())
    {
        out.push(
            "additionalReadonlyPaths is not empty: sandboxed commands read those paths".to_owned(),
        );
    }
    out
}

#[cfg(test)]
mod tests {
    use openmoat_core::DEFAULT_POLICY;
    use serde_json::json;

    use super::super::tests::generated;
    use super::*;

    #[test]
    fn apply_keeps_user_keys_and_is_idempotent() {
        let out = generated(DEFAULT_POLICY);
        let mut root = json!({
            "enableSharedBuildCache": true,
            "networkPolicy": { "default": "allow", "extra": 1 },
        });
        assert!(!in_sync(&root, &out));
        assert!(apply(&mut root, &out).unwrap());
        assert!(
            !apply(&mut root, &out).unwrap(),
            "second apply changes nothing"
        );
        assert!(in_sync(&root, &out));
        assert_eq!(root["enableSharedBuildCache"], json!(true));
        assert_eq!(root["networkPolicy"]["extra"], json!(1));
        assert_eq!(root["networkPolicy"]["default"], json!("deny"));
        assert!(weaknesses(&root).is_empty(), "{:?}", weaknesses(&root));
        assert!(apply(&mut json!([]), &out).is_err());
        assert!(apply(&mut json!({"networkPolicy": 1}), &out).is_err());
    }

    #[test]
    fn remove_undoes_apply_and_keeps_user_keys() {
        let original = json!({ "disableTmpWrite": true, "networkPolicy": { "extra": 1 } });
        let mut root = original.clone();
        apply(&mut root, &generated(DEFAULT_POLICY)).unwrap();
        assert!(remove(&mut root));
        assert_eq!(root, original);
        assert!(!remove(&mut root));

        let mut root = json!({});
        apply(&mut root, &generated(DEFAULT_POLICY)).unwrap();
        assert!(remove(&mut root));
        assert_eq!(root, json!({}), "every key apply writes is owned");
    }

    #[test]
    fn weaknesses_name_every_weakened_setting() {
        let out = generated(DEFAULT_POLICY);
        let mut root = json!({});
        apply(&mut root, &out).unwrap();
        root["type"] = json!("insecure_none");
        root["readBoundary"] = json!("system");
        root["networkPolicy"]["default"] = json!("allow");
        root["additionalReadonlyPaths"] = json!(["/Users/me/.aws"]);
        let found = weaknesses(&root).join("\n");
        for key in [
            "insecure_none",
            "readBoundary",
            "networkPolicy.default",
            "additionalReadonlyPaths",
        ] {
            assert!(found.contains(key), "{key}: {found}");
        }
        assert!(!in_sync(&root, &out));
    }
}