openmoat 0.1.0-alpha.0

Policy guard for AI coding agents: decides allow, 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.
//! Human approvals that outlive one prompt.
//!
//! Two files under `~/.moat`, both pinned by the policy lock so an agent cannot
//! grant itself anything:
//! - `approvals.json`: session grants, exact command for one host session, valid for
//!   [`GRANT_TTL_MS`] after they are written;
//! - `policy.d/approved.yaml`: permanent allow rules appended by `moat allow --always`,
//!   merged into the user policy at load time so `policy.yaml` is never rewritten.

use std::fs;
use std::path::Path;

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

use crate::home::write_private;
use crate::time;

const GRANTS_VERSION: u32 = 1;
const OVERLAY_VERSION: u32 = 1;
pub const OVERLAY_PREFIX: &str = "approved-";
/// How long a session grant stays valid. Hosts do not tell OpenMoat when a session
/// ends, so a fixed lifetime is what bounds a grant.
pub const GRANT_TTL_MS: i64 = 24 * 60 * 60 * 1000;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Grant {
    pub host: String,
    pub session_id: String,
    pub command: String,
    /// Milliseconds since the epoch. A grant written without one reads as 0,
    /// long expired: a grant whose age is unknown must not stay valid forever.
    #[serde(default)]
    pub granted_at_ms: i64,
}

impl Grant {
    /// Valid for [`GRANT_TTL_MS`] from its creation. A creation time in the
    /// future (a clock set back, a hand edit) is not valid either.
    #[must_use]
    pub fn active(&self, now_ms: i64) -> bool {
        (0..GRANT_TTL_MS).contains(&now_ms.saturating_sub(self.granted_at_ms))
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Grants {
    pub version: u32,
    pub entries: Vec<Grant>,
}

impl Default for Grants {
    fn default() -> Self {
        Self {
            version: GRANTS_VERSION,
            entries: Vec::new(),
        }
    }
}

impl Grants {
    /// Missing file means no grants; a malformed or newer file is an error.
    pub fn load(path: &Path) -> Result<Self> {
        if !path.exists() {
            return Ok(Self::default());
        }
        let text =
            fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
        let grants: Self =
            serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
        if grants.version != GRANTS_VERSION {
            bail!(
                "{} has version {}; this build supports {GRANTS_VERSION}",
                path.display(),
                grants.version
            );
        }
        Ok(grants)
    }

    /// Write the file, dropping grants that expired by `now_ms` so it does not
    /// grow without bound.
    pub fn save(&mut self, path: &Path, now_ms: i64) -> Result<()> {
        self.entries.retain(|g| g.active(now_ms));
        write_private(
            path,
            (serde_json::to_string_pretty(self)? + "\n").as_bytes(),
        )
    }

    /// Grant `command` to the host session as of `now_ms`; granting it again
    /// restarts its lifetime.
    pub fn grant(&mut self, host: &str, session_id: &str, command: &str, now_ms: i64) {
        let command = command.trim();
        self.entries
            .retain(|g| !(g.host == host && g.session_id == session_id && g.command == command));
        self.entries.push(Grant {
            host: host.to_owned(),
            session_id: session_id.to_owned(),
            command: command.to_owned(),
            granted_at_ms: now_ms,
        });
    }

    /// Exact command match for this host session among grants still valid at
    /// `now_ms`; no prefix or glob semantics.
    #[must_use]
    pub fn matches(&self, host: &str, session_id: &str, command: &str, now_ms: i64) -> bool {
        let command = command.trim();
        self.active(now_ms)
            .any(|g| g.host == host && g.session_id == session_id && g.command == command)
    }

    /// Grants still valid at `now_ms`.
    pub fn active(&self, now_ms: i64) -> impl Iterator<Item = &Grant> {
        self.entries.iter().filter(move |g| g.active(now_ms))
    }
}

/// Allow rules appended by `moat allow --always`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Overlay {
    pub version: u32,
    #[serde(default)]
    pub allow: Vec<RuleGroup>,
}

impl Default for Overlay {
    fn default() -> Self {
        Self {
            version: OVERLAY_VERSION,
            allow: Vec::new(),
        }
    }
}

impl Overlay {
    pub fn load(path: &Path) -> Result<Self> {
        if !path.exists() {
            return Ok(Self::default());
        }
        let text =
            fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
        let overlay: Self = serde_yaml_ng::from_str(&text)
            .with_context(|| format!("parsing {}", path.display()))?;
        if overlay.version != OVERLAY_VERSION {
            bail!(
                "{} has version {}; this build supports {OVERLAY_VERSION}",
                path.display(),
                overlay.version
            );
        }
        if let Some(bad) = overlay
            .allow
            .iter()
            .find(|g| !g.id.starts_with(OVERLAY_PREFIX))
        {
            bail!(
                "{}: rule `{}` must have an id starting with `{OVERLAY_PREFIX}`",
                path.display(),
                bad.id
            );
        }
        Ok(overlay)
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        let text = format!(
            "# Permanent allow rules added with `moat allow --always`. Edit or delete freely,\n\
             # then run `moat doctor --accept`.\n{}",
            serde_yaml_ng::to_string(self)?
        );
        write_private(path, text.as_bytes())
    }

    /// Append an allow rule for one shell command, matched literally: glob
    /// characters in the command are not wildcards in the rule. Shell rules are
    /// prefixes, so extra arguments after the approved command are accepted.
    pub fn allow_command(&mut self, command: &str) -> Result<&RuleGroup> {
        let pattern = openmoat_core::literal_shell_pattern(command)
            .with_context(|| format!("cannot approve `{command}`"))?;
        // One past the highest existing number: ids stay unique after a person
        // deletes an earlier rule, and a duplicate id would fail the policy lint.
        let n = self
            .allow
            .iter()
            .filter_map(|g| g.id.strip_prefix(OVERLAY_PREFIX)?.parse::<u64>().ok())
            .max()
            .unwrap_or(0)
            + 1;
        self.allow.push(RuleGroup {
            id: format!("{OVERLAY_PREFIX}{n}"),
            reason: Some(format!(
                "approved with `moat allow --always` on {}",
                time::timestamp(time::now_ms())
            )),
            shell: vec![pattern],
            ..RuleGroup::default()
        });
        Ok(&self.allow[self.allow.len() - 1])
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn overlay_ids_stay_unique_after_a_deletion() {
        let mut o = Overlay::default();
        for c in ["a", "b", "c"] {
            o.allow_command(c).unwrap();
        }
        o.allow.remove(0);
        assert_eq!(o.allow_command("d").unwrap().id, "approved-4");
        let ids: std::collections::BTreeSet<_> = o.allow.iter().map(|g| &g.id).collect();
        assert_eq!(ids.len(), o.allow.len());
    }

    /// 2026-10-03 00:00:00 UTC; tests pass time in, they never read the clock.
    const NOW: i64 = 1_790_985_600_000;

    #[test]
    fn grants_match_exactly_per_host_session() {
        let mut g = Grants::default();
        g.grant("claude-code", "s1", " npm install left-pad ", NOW);
        g.grant("claude-code", "s1", "npm install left-pad", NOW);
        assert_eq!(g.entries.len(), 1, "duplicates collapse");
        assert!(g.matches("claude-code", "s1", "npm install left-pad", NOW));
        assert!(!g.matches("claude-code", "s2", "npm install left-pad", NOW));
        assert!(!g.matches("cursor", "s1", "npm install left-pad", NOW));
        assert!(!g.matches("claude-code", "s1", "npm install left-pad --save", NOW));
    }

    #[test]
    fn grants_expire_after_the_ttl() {
        let mut g = Grants::default();
        g.grant("codex", "k1", "cargo add serde", NOW);
        let ok = |g: &Grants, at| g.matches("codex", "k1", "cargo add serde", at);
        assert!(ok(&g, NOW + GRANT_TTL_MS - 1));
        assert!(!ok(&g, NOW + GRANT_TTL_MS), "expired at the TTL");
        assert!(!ok(&g, NOW - 1), "a grant from the future is not valid");

        g.grant("codex", "k1", "cargo add serde", NOW + GRANT_TTL_MS);
        assert_eq!(g.entries.len(), 1);
        assert!(ok(&g, NOW + GRANT_TTL_MS + 1), "granting again restarts it");
    }

    #[test]
    fn a_grant_without_a_timestamp_is_expired() {
        let g: Grants = serde_json::from_str(
            r#"{"version":1,"entries":[{"host":"codex","session_id":"k1","command":"ls"}]}"#,
        )
        .unwrap();
        assert_eq!(g.entries[0].granted_at_ms, 0);
        assert!(!g.matches("codex", "k1", "ls", NOW));
    }

    #[test]
    fn saving_prunes_expired_grants() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("approvals.json");
        let mut g = Grants::default();
        g.grant("codex", "old", "ls", NOW - GRANT_TTL_MS);
        g.grant("codex", "new", "ls", NOW - 1);
        assert_eq!(g.active(NOW).count(), 1);
        g.save(&path, NOW).unwrap();
        let loaded = Grants::load(&path).unwrap();
        assert_eq!(loaded.entries.len(), 1);
        assert_eq!(loaded.entries[0].session_id, "new");
    }

    #[test]
    fn grants_and_overlay_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        let grants_path = dir.path().join("approvals.json");
        let mut g = Grants::default();
        g.grant("codex", "k1", "cargo add serde", NOW);
        g.save(&grants_path, NOW).unwrap();
        assert_eq!(Grants::load(&grants_path).unwrap(), g);
        assert_eq!(
            Grants::load(&dir.path().join("missing.json")).unwrap(),
            Grants::default()
        );

        let overlay_path = dir.path().join("approved.yaml");
        let mut o = Overlay::default();
        assert_eq!(
            o.allow_command("npm install left-pad").unwrap().id,
            "approved-1"
        );
        assert_eq!(
            o.allow_command("pip install requests").unwrap().id,
            "approved-2"
        );
        o.save(&overlay_path).unwrap();
        let loaded = Overlay::load(&overlay_path).unwrap();
        assert_eq!(loaded.allow.len(), 2);
        assert_eq!(loaded.allow[1].shell, ["pip install requests"]);
        assert!(
            loaded.allow[0]
                .reason
                .as_deref()
                .unwrap()
                .contains("moat allow --always")
        );

        fs::write(
            &overlay_path,
            "version: 1\nallow:\n  - id: sneaky\n    shell: ['*']\n",
        )
        .unwrap();
        assert!(
            Overlay::load(&overlay_path)
                .unwrap_err()
                .to_string()
                .contains("approved-")
        );
    }
}