openmoat 0.11.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.
//! The scenarios of ADR-039 and the benign category decided in-process: the
//! default policy, the files each session wrote and the session-code check,
//! as `openmoat guard` applies them, without hook payloads or an audit log on
//! disk the end-to-end bench needs. Each runs with and without an OS sandbox.

use openmoat_audit::{NewEvent, Store};
use openmoat_core::{Action, CompiledPolicy, Decision, EvalContext, NoResolver, Policy, Verdict};
use openmoat_hosts::Host;

use super::super::scenario::{Call, Scenario, scenarios};
use crate::home::Home;
use crate::realpath::FsPathResolver;
use crate::session_code;
use crate::taint::provenance;

const HOME: &str = "/Users/me";

/// The scenarios whose steps depend on what the session wrote.
const SESSION_CODE: &[&str] = &[
    "agent-written-script-reads-the-ssh-key",
    "agent-written-shell-script-runs",
    "agent-written-script-through-inline-code",
    "inline-code-appends-to-a-shell-start-up-file",
    "inline-code-builds-the-secret-path",
    "inline-code-builds-the-token-name-or-host",
    "interpreter-inline-code",
];

fn action(call: &Call, proj: &str) -> Option<Action> {
    let path = |p: &str| match p.strip_prefix("~/") {
        Some(rest) => format!("{HOME}/{rest}"),
        None => format!("{proj}/{p}"),
    };
    Some(match call {
        Call::Shell(command) => Action::Shell {
            command: command.clone(),
        },
        Call::Read(p) => Action::FsRead { path: path(p) },
        Call::Write(p) | Call::Edit(p) => Action::FsWrite { path: path(p) },
        Call::Fetch(url) => Action::Fetch { url: url.clone() },
        Call::Mcp { .. } => return None,
    })
}

/// Every mismatch of `scenario` on a host that is `sandboxed` or not.
fn run(scenario: &Scenario, sandboxed: bool) -> Vec<String> {
    let dir = tempfile::tempdir().unwrap();
    let proj = crate::context::path_string(&dir.path().canonicalize().unwrap()) + "/proj";
    let home = Home::at(dir.path().join("state"));
    let store = Store::open(&dir.path().join("audit.db")).unwrap();
    let policy = Policy::parse(openmoat_core::DEFAULT_POLICY).unwrap();
    let ctx = EvalContext {
        home: HOME.to_owned(),
        project: Some(proj.clone()),
        real_home: None,
        real_project: None,
        moved_dirs: Vec::new(),
        cwd: proj.clone(),
        case_insensitive_paths: false,
    };
    let compiled = CompiledPolicy::compile(&policy, &ctx).unwrap();
    let host = Host::GeminiCli;
    let mut problems = Vec::new();
    for (i, step) in scenario.steps.iter().enumerate() {
        let Some(action) = action(&step.call().unwrap(), &proj) else {
            return Vec::new();
        };
        let decided = compiled.decide_with(&action, &NoResolver, &FsPathResolver);
        let seen = provenance::observe(
            &store,
            &compiled,
            &provenance::Call {
                session: "s",
                action: &action,
                content: None,
                untrusted: None,
            },
        )
        .unwrap();
        let call = session_code::Call {
            home: &home,
            host,
            session: "s",
            action: &action,
            cwd: &proj,
            user_home: HOME,
            writes: &seen.written,
            within_run: false,
        };
        let decision: Decision = session_code::checked(
            &call,
            &compiled,
            &store,
            decided,
            (crate::time::now_ms(), &|| sandboxed),
        )
        .unwrap();
        let id = store
            .record(&NewEvent {
                host: host.id(),
                session_id: "s",
                call_id: None,
                cwd: None,
                tool: "t",
                action: Some(&action),
                decision: &decision,
                latency_us: 0,
            })
            .unwrap();
        if decision.verdict != Verdict::Deny {
            seen.write(&store, id, host.id(), "s").unwrap();
        }
        let (expect, rule) = step.expected(sandboxed);
        let rule_ok = rule.is_none_or(|r| decision.rules.iter().any(|d| d == r));
        if decision.verdict != expect || !rule_ok {
            problems.push(format!(
                "{} step {i} (sandboxed: {sandboxed}): expected {expect:?} {rule:?}, got {:?} {:?}",
                scenario.id, decision.verdict, decision.rules
            ));
        }
    }
    problems
}

#[test]
fn session_code_and_benign_scenarios_hold_in_process() {
    let mut problems = Vec::new();
    let mut ran = 0;
    for (category, scenario) in scenarios().unwrap() {
        if category != "benign" && !SESSION_CODE.contains(&scenario.id.as_str()) {
            continue;
        }
        for sandboxed in [false, true] {
            ran += 1;
            problems.extend(run(&scenario, sandboxed));
        }
    }
    assert!(ran >= 2 * SESSION_CODE.len(), "{ran}");
    assert!(problems.is_empty(), "{problems:#?}");
}