use std::path::Path;
use anyhow::Result;
use openmoat_hosts::Host;
use serde::Serialize;
use crate::home::Home;
use crate::install::{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 unsupported = config
.host_present()
.then(|| crate::install::host_version::unsupported(host))
.flatten();
let (level, reason) = match unsupported {
Some(why) => (Level::NotProtected, Some(why)),
None => level(
&config.state(binary),
config.host_present(),
recorded.skipped(host),
|| {
crate::project_settings::switched_off(
host,
config.dir(),
&config.settings_path,
)
.map(|off| format!("hook turned off by {off}"))
.or_else(|| {
crate::heartbeat::check(home, &config).and_then(|loaded| {
crate::heartbeat::not_loaded(host.display_name(), &loaded)
})
})
},
|| sandbox(home, host, plan.as_ref(), lock.as_ref()),
),
};
Ok(Protection {
host: host.id(),
name: host.display_name(),
level,
reason,
gaps: host.spec().gaps,
})
})
.collect()
}
fn level(
hook: &HookState,
present: bool,
skipped: bool,
not_running: impl FnOnce() -> Option<String>,
sandbox: impl FnOnce() -> Result<(), String>,
) -> (Level, Option<String>) {
let not_protected = |why: &str| (Level::NotProtected, Some(why.to_owned()));
match hook {
HookState::Installed => match not_running() {
Some(why) => (Level::NotProtected, Some(why)),
None => 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"),
HookState::Unapproved(_) => not_protected("hook not approved by the agent"),
HookState::FailsOpen(_) => not_protected("hook runs the call when it fails"),
}
}
pub fn os_sandboxed_by_lock(home: &Home, host: Host) -> bool {
if host_sandbox::format(host).is_none() || host_sandbox::unavailable(host).is_some() {
return false;
}
let Ok(lock) = std::fs::read(home.lock_path()) else {
return os_sandboxed(home, host);
};
let digest = crate::integrity::sha256_hex(&lock);
let path = home.root().join("sandboxed.json");
let mut kept: SandboxedByLock = std::fs::read(&path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.filter(|k: &SandboxedByLock| k.lock == digest)
.unwrap_or_else(|| SandboxedByLock {
lock: digest,
hosts: std::collections::BTreeMap::new(),
});
if let Some(known) = kept.hosts.get(host.id()) {
return *known;
}
let answer = os_sandboxed(home, host);
kept.hosts.insert(host.id().to_owned(), answer);
if let Ok(bytes) = serde_json::to_vec(&kept) {
let _ = crate::home::write_private(&path, &bytes);
}
answer
}
#[derive(serde::Serialize, serde::Deserialize)]
struct SandboxedByLock {
lock: String,
hosts: std::collections::BTreeMap<String, bool>,
}
pub fn os_sandboxed(home: &Home, host: Host) -> bool {
if host_sandbox::format(host).is_none() || host_sandbox::unavailable(host).is_some() {
return false;
}
let plan = home
.load_policy()
.ok()
.and_then(|p| Plan::new(home, &p).ok());
let lock = Lock::load(&home.lock_path()).ok();
sandbox(home, host, plan.as_ref(), lock.as_ref()).is_ok()
}
fn sandbox(
home: &Home,
host: Host,
plan: Option<&Plan>,
lock: Option<&Lock>,
) -> Result<(), String> {
if host_sandbox::format(host).is_none() {
return Err(format!(
"no OS sandbox from OpenMoat; `openmoat run` puts {} in one (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:#}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sandbox_answer_is_kept_by_the_lock_digest() {
let Some(host) = Host::ALL.into_iter().find(|h| {
host_sandbox::format(*h).is_some() && host_sandbox::unavailable(*h).is_none()
}) else {
return;
};
let dir = tempfile::tempdir().unwrap();
let home = Home::at(dir.path().join("state"));
std::fs::create_dir_all(home.root()).unwrap();
std::fs::write(home.lock_path(), b"lock one").unwrap();
assert!(!os_sandboxed_by_lock(&home, host));
let kept = home.root().join("sandboxed.json");
let text = std::fs::read_to_string(&kept).unwrap();
assert!(
text.contains(&crate::integrity::sha256_hex(b"lock one")),
"{text}"
);
std::fs::write(&kept, text.replace("false", "true")).unwrap();
assert!(os_sandboxed_by_lock(&home, host));
std::fs::write(home.lock_path(), b"lock two").unwrap();
assert!(!os_sandboxed_by_lock(&home, host));
}
#[test]
fn hosts_without_an_openmoat_sandbox_are_never_sandboxed() {
let dir = tempfile::tempdir().unwrap();
let home = Home::at(dir.path().join("state"));
for host in Host::ALL.into_iter().filter(|h| h.spec().sandbox.is_none()) {
assert!(!os_sandboxed(&home, host), "{}", host.id());
}
}
#[test]
fn an_installed_hook_with_a_matching_sandbox_is_hook_and_os_sandbox() {
let got = level(&HookState::Installed, true, false, || None, || 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,
|| None,
|| Err("sandbox: drifted".to_owned()),
);
assert_eq!(got, (Level::HookOnly, Some("sandbox: drifted".to_owned())));
}
#[test]
fn an_installed_hook_the_agent_settings_turn_off_is_not_protected() {
let got = level(
&HookState::Installed,
true,
false,
|| Some("hook turned off by settings.json: `disableAllHooks`".to_owned()),
|| panic!("sandbox read for a hook that is off"),
);
assert_eq!(got.0, Level::NotProtected);
assert_eq!(
got.1.as_deref(),
Some("hook turned off by settings.json: `disableAllHooks`")
);
}
#[test]
fn an_installed_plugin_the_agent_did_not_load_is_not_protected() {
let why =
"plugin not loaded since it was written 2026-10-27 10:00 UTC, though pi has run since";
let got = level(
&HookState::Installed,
true,
false,
|| Some(why.to_owned()),
|| panic!("sandbox read for a hook that does not run"),
);
assert_eq!(got, (Level::NotProtected, Some(why.to_owned())));
let fails_open = HookState::FailsOpen("lacks `fail_closed: true`".to_owned());
let got = level(&fails_open, true, false, || None, || Ok(()));
assert_eq!(got.0, Level::NotProtected);
}
#[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!("settings read without a hook"),
|| 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, || None, || Ok(())),
(Level::NotSetUp, None)
);
assert_eq!(
level(&HookState::Missing, false, false, || None, || 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);
}
}