use std::path::Path;
use anyhow::Result;
use openmoat_hosts::Host;
use serde::Serialize;
use crate::home::Home;
use crate::install::{CONTINUE_CLI_WARNING, HookState, HostConfig, Recorded};
use crate::integrity::Lock;
use crate::sandbox::{Plan, install as host_sandbox};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Level {
HookAndOsSandbox,
HookOnly,
NotProtected,
NotSetUp,
NotFound,
}
impl Level {
pub fn label(self) -> &'static str {
match self {
Self::HookAndOsSandbox => "hook + OS sandbox",
Self::HookOnly => "hook only",
Self::NotProtected => "not protected",
Self::NotSetUp => "not set up",
Self::NotFound => "host not found",
}
}
pub fn hooked(self) -> bool {
matches!(self, Self::HookAndOsSandbox | Self::HookOnly)
}
}
#[derive(Debug, Serialize)]
pub struct Protection {
pub host: &'static str,
pub name: &'static str,
pub level: Level,
pub reason: Option<String>,
pub gaps: &'static str,
}
impl Protection {
pub fn summary(&self) -> Option<String> {
if matches!(self.level, Level::NotSetUp | Level::NotFound) {
return None;
}
Some(match &self.reason {
Some(reason) => format!("{}: {reason}", self.level.label()),
None => self.level.label().to_owned(),
})
}
}
pub fn report(home: &Home, binary: &Path) -> Result<Vec<Protection>> {
let plan = home
.load_policy()
.ok()
.and_then(|p| Plan::new(home, &p).ok());
let lock = Lock::load(&home.lock_path()).ok();
let recorded = Recorded::load(home).unwrap_or_default();
Host::ALL
.into_iter()
.map(|host| {
let config = HostConfig::for_host(home, host)?;
let (level, reason) = level(
&config.state(binary),
config.host_present(),
recorded.skipped(host),
|| sandbox(home, host, plan.as_ref(), lock.as_ref()),
);
Ok(Protection {
host: host.id(),
name: host.display_name(),
level,
reason,
gaps: gaps(host),
})
})
.collect()
}
fn level(
hook: &HookState,
present: bool,
skipped: bool,
sandbox: impl FnOnce() -> Result<(), String>,
) -> (Level, Option<String>) {
let not_protected = |why: &str| (Level::NotProtected, Some(why.to_owned()));
match hook {
HookState::Installed => match sandbox() {
Ok(()) => (Level::HookAndOsSandbox, None),
Err(why) => (Level::HookOnly, Some(why)),
},
HookState::Missing if !present => (Level::NotFound, None),
HookState::Missing if skipped => (Level::NotSetUp, None),
HookState::Missing => not_protected("hook missing"),
HookState::Stale { .. } => not_protected("hook out of date"),
HookState::Unreadable(_) => not_protected("hook file unreadable"),
}
}
fn sandbox(
home: &Home,
host: Host,
plan: Option<&Plan>,
lock: Option<&Lock>,
) -> Result<(), String> {
if !host_sandbox::HOSTS.contains(&host) {
return Err(format!(
"no OS sandbox from OpenMoat; `moat run` covers the {} CLI (docs/SANDBOX.md)",
host.display_name()
));
}
if let Some(note) = host_sandbox::unavailable(host) {
return Err(note.to_owned());
}
let plan = plan.ok_or("the policy does not load, so the sandbox cannot be checked")?;
match host_sandbox::problems(home, host, plan, lock) {
Ok(Some(problems)) => match problems.as_slice() {
[] => Ok(()),
[only] => Err(format!("sandbox: {only}")),
[first, rest @ ..] => Err(format!("sandbox: {first} (and {} more)", rest.len())),
},
Ok(None) => Err("sandbox settings missing".to_owned()),
Err(error) => Err(format!("sandbox: {error:#}")),
}
}
fn gaps(host: Host) -> &'static str {
match host {
Host::ClaudeCode => {
"file tools, WebFetch and MCP servers run outside the OS sandbox; calls run \
unchecked if the hook binary is missing or crashes"
}
Host::Codex => {
"web search and hosted tools are not hooked; asks become denies; calls run \
unchecked if the hook binary is missing or crashes"
}
Host::Cursor => {
"commands Cursor runs outside its sandbox (a rerun or call its Auto-review \
classifier approves, Run Everything mode, the CLI without `--sandbox enabled`) \
are not confined; file-tool asks become denies; Task and tools that name no path \
are not hooked; a failed hook blocks the call"
}
Host::Continue => CONTINUE_CLI_WARNING,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_installed_hook_with_a_matching_sandbox_is_hook_and_os_sandbox() {
let got = level(&HookState::Installed, true, false, || Ok(()));
assert_eq!(got, (Level::HookAndOsSandbox, None));
}
#[test]
fn an_installed_hook_without_the_sandbox_is_hook_only_and_says_why() {
let got = level(&HookState::Installed, true, false, || {
Err("sandbox: drifted".to_owned())
});
assert_eq!(got, (Level::HookOnly, Some("sandbox: drifted".to_owned())));
}
#[test]
fn a_missing_stale_or_unreadable_hook_is_not_protected() {
let stale = HookState::Stale {
command: "/gone/moat".to_owned(),
};
let unreadable = HookState::Unreadable("bad json".to_owned());
for (hook, why) in [
(HookState::Missing, "hook missing"),
(stale, "hook out of date"),
(unreadable, "hook file unreadable"),
] {
let got = level(&hook, true, false, || panic!("sandbox read without a hook"));
assert_eq!(got, (Level::NotProtected, Some(why.to_owned())));
}
}
#[test]
fn an_agent_left_out_or_absent_keeps_its_own_line() {
assert_eq!(
level(&HookState::Missing, true, true, || Ok(())),
(Level::NotSetUp, None)
);
assert_eq!(
level(&HookState::Missing, false, false, || Ok(())),
(Level::NotFound, None)
);
}
#[test]
fn summary_skips_agents_with_their_own_line() {
let protection = |level, reason: Option<&str>| Protection {
host: "codex",
name: "Codex",
level,
reason: reason.map(str::to_owned),
gaps: "",
};
assert_eq!(
protection(Level::HookOnly, Some("why"))
.summary()
.as_deref(),
Some("hook only: why")
);
assert_eq!(protection(Level::NotSetUp, None).summary(), None);
}
}