crates/nornir-guard/src/guard.rs
//! AI-agent guard rails.
//!
//! Enforces the `[guard].forbidden` list from `nornir.toml` at the
//! filesystem level via `chmod -w`. A misbehaving agent (or a tired
//! human) trying to edit a protected file gets `EACCES` rather than
//! silently corrupting append-only history or generated docs.
//!
//! Operations:
//! - [`apply`] — chmod -w on every forbidden path that exists
//! - [`release`] — chmod +w on every forbidden path (for intentional edits)
//! - [`status`] — report current writable bit for each path
//!
//! Tamper-evidence (lock-down integrity):
//! - [`manifest`] — snapshot {exists, mode, sha256} per forbidden path
//! - [`write_manifest`]/[`read_manifest`] — persist/load the snapshot
//! - [`verify`] — recompute and diff against a recorded manifest
//! - [`intact`] — gate form: `Err` if any drift (perm or content)
//! - [`policy_json`] — host-consumable forbidden-list export
//!
//! `chmod -w` is advisory: the same agent that holds the bit can flip it
//! back. The manifest makes such tampering **non-deniable** — a release
//! gate (`Gates.guard_intact`) recomputes the hashes and fails if a
//! protected file's permissions or contents drifted out of band.
//!
//! All paths in the `forbidden` list are interpreted relative to the
//! supplied `workspace_root` (typically the dir containing
//! `workspace_holger/`, `holger/`, `znippy/`, ...).
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PathStatus {
pub path: PathBuf,
pub exists: bool,
pub writable: bool,
pub changed: bool,
}
pub fn status(workspace_root: &Path, forbidden: &[String]) -> Vec<PathStatus> {
forbidden
.iter()
.map(|rel| {
let p = workspace_root.join(rel);
let (exists, writable) = inspect(&p);
PathStatus {
path: p,
exists,
writable,
changed: false,
}
})
.collect()
}
pub fn apply(workspace_root: &Path, forbidden: &[String]) -> Result<Vec<PathStatus>> {
chmod_each(workspace_root, forbidden, false)
}
pub fn release(workspace_root: &Path, forbidden: &[String]) -> Result<Vec<PathStatus>> {
chmod_each(workspace_root, forbidden, true)
}
fn inspect(p: &Path) -> (bool, bool) {
match std::fs::metadata(p) {
Ok(m) => (true, !m.permissions().readonly()),
Err(_) => (false, false),
}
}
#[cfg(unix)]
fn chmod_each(
workspace_root: &Path,
forbidden: &[String],
writable: bool,
) -> Result<Vec<PathStatus>> {
use std::os::unix::fs::PermissionsExt;
let mut out = Vec::new();
for rel in forbidden {
let p = workspace_root.join(rel);
if !p.exists() {
out.push(PathStatus {
path: p,
exists: false,
writable: false,
changed: false,
});
continue;
}
let meta = std::fs::metadata(&p).with_context(|| format!("stat {}", p.display()))?;
let before = !meta.permissions().readonly();
let mut perms = meta.permissions();
let mode = perms.mode();
let new_mode = if writable {
mode | 0o200 // u+w
} else {
mode & !0o222 // strip write everywhere
};
perms.set_mode(new_mode);
std::fs::set_permissions(&p, perms).with_context(|| format!("chmod {}", p.display()))?;
let (_, after) = inspect(&p);
out.push(PathStatus {
path: p,
exists: true,
writable: after,
changed: before != after,
});
}
Ok(out)
}
#[cfg(not(unix))]
fn chmod_each(
workspace_root: &Path,
forbidden: &[String],
writable: bool,
) -> Result<Vec<PathStatus>> {
let mut out = Vec::new();
for rel in forbidden {
let p = workspace_root.join(rel);
if !p.exists() {
out.push(PathStatus {
path: p,
exists: false,
writable: false,
changed: false,
});
continue;
}
let meta = std::fs::metadata(&p)?;
let before = !meta.permissions().readonly();
let mut perms = meta.permissions();
perms.set_readonly(!writable);
std::fs::set_permissions(&p, perms)?;
let (_, after) = inspect(&p);
out.push(PathStatus {
path: p,
exists: true,
writable: after,
changed: before != after,
});
}
Ok(out)
}
// ---------------------------------------------------------------------------
// Tamper-evidence: manifest, verify, policy export.
// ---------------------------------------------------------------------------
/// Recorded state of one forbidden path at `guard_apply` time.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ManifestEntry {
/// Workspace-root-relative path, exactly as listed in `[guard].forbidden`.
pub rel: String,
pub exists: bool,
/// True if the path is a directory (content is not hashed).
pub is_dir: bool,
/// Unix permission bits (`st_mode & 0o7777`). `0` on non-unix.
pub mode: u32,
/// Lowercase hex sha256 of file contents; `None` for dirs/missing.
pub sha256: Option<String>,
}
/// A snapshot of every forbidden path, persisted so later drift is
/// non-deniable. Append-only by convention: each `guard_apply` rewrites
/// it, but the warehouse/git history retains prior versions.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Manifest {
pub recorded_at: chrono::DateTime<chrono::Utc>,
pub entries: Vec<ManifestEntry>,
}
/// What changed for one path between the recorded manifest and now.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum Drift {
/// Recorded as existing, now gone.
Vanished,
/// Recorded as absent, now present.
Appeared,
/// Permission bits changed (e.g. someone re-granted write).
Mode { recorded: u32, current: u32 },
/// File contents changed (sha256 mismatch).
Content {
recorded: Option<String>,
current: Option<String>,
},
}
/// Per-path verification result.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VerifyStatus {
pub rel: String,
pub drift: Vec<Drift>,
}
impl VerifyStatus {
pub fn ok(&self) -> bool {
self.drift.is_empty()
}
}
/// Canonical on-disk location of the persisted manifest.
pub fn manifest_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(".nornir").join("guard-manifest.json")
}
/// Canonical on-disk location of the host-consumable policy export.
pub fn policy_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(".nornir").join("guard-policy.json")
}
/// Compute a fresh manifest for the forbidden set (does not write it).
pub fn manifest(workspace_root: &Path, forbidden: &[String]) -> Manifest {
let entries = forbidden
.iter()
.map(|rel| entry_for(&workspace_root.join(rel), rel))
.collect();
Manifest {
recorded_at: chrono::Utc::now(),
entries,
}
}
fn entry_for(abs: &Path, rel: &str) -> ManifestEntry {
match std::fs::symlink_metadata(abs) {
Err(_) => ManifestEntry {
rel: rel.to_string(),
exists: false,
is_dir: false,
mode: 0,
sha256: None,
},
Ok(meta) => {
let is_dir = meta.is_dir();
let mode = mode_of(&meta);
let sha256 = if is_dir || meta.file_type().is_symlink() {
None
} else {
sha256_file(abs).ok()
};
ManifestEntry {
rel: rel.to_string(),
exists: true,
is_dir,
mode,
sha256,
}
}
}
}
#[cfg(unix)]
fn mode_of(meta: &std::fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o7777
}
#[cfg(not(unix))]
fn mode_of(meta: &std::fs::Metadata) -> u32 {
if meta.permissions().readonly() {
0o444
} else {
0o644
}
}
fn sha256_file(path: &Path) -> Result<String> {
nornir_hash::sha256_file(path).with_context(|| format!("hash {}", path.display()))
}
/// Persist a manifest to `manifest_path(workspace_root)` (creates `.nornir/`).
pub fn write_manifest(workspace_root: &Path, m: &Manifest) -> Result<PathBuf> {
let path = manifest_path(workspace_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(m).context("serialize guard manifest")?;
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
/// Load a previously written manifest.
pub fn read_manifest(workspace_root: &Path) -> Result<Manifest> {
let path = manifest_path(workspace_root);
let text = std::fs::read_to_string(&path).with_context(|| {
format!(
"read guard manifest {} (run guard_apply first)",
path.display()
)
})?;
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
/// Recompute current state and diff it against `recorded`. Returns one
/// [`VerifyStatus`] per recorded entry (drift empty == intact).
pub fn verify(workspace_root: &Path, recorded: &Manifest) -> Vec<VerifyStatus> {
recorded
.entries
.iter()
.map(|rec| {
let cur = entry_for(&workspace_root.join(&rec.rel), &rec.rel);
let mut drift = Vec::new();
match (rec.exists, cur.exists) {
(true, false) => drift.push(Drift::Vanished),
(false, true) => drift.push(Drift::Appeared),
(false, false) => {}
(true, true) => {
if rec.mode != cur.mode {
drift.push(Drift::Mode {
recorded: rec.mode,
current: cur.mode,
});
}
if !rec.is_dir && !cur.is_dir && rec.sha256 != cur.sha256 {
drift.push(Drift::Content {
recorded: rec.sha256.clone(),
current: cur.sha256.clone(),
});
}
}
}
VerifyStatus {
rel: rec.rel.clone(),
drift,
}
})
.collect()
}
/// Gate form of [`verify`]: `Ok(())` if every path is intact, else an
/// `Err` naming the drifted paths. Suitable for a release gate.
pub fn intact(workspace_root: &Path, recorded: &Manifest) -> Result<()> {
let report = verify(workspace_root, recorded);
let drifted: Vec<&VerifyStatus> = report.iter().filter(|v| !v.ok()).collect();
if drifted.is_empty() {
return Ok(());
}
let detail = drifted
.iter()
.map(|v| format!("{} ({} drift)", v.rel, v.drift.len()))
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"guard manifest drift on {} path(s): {detail}",
drifted.len()
)
}
/// Host-consumable policy: the forbidden list + the manifest path, so an
/// agent runtime or sibling MCP can be configured to deny writes.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Policy {
pub forbidden: Vec<String>,
pub manifest: PathBuf,
pub note: String,
}
/// Build and write `guard-policy.json` next to the manifest.
pub fn write_policy(workspace_root: &Path, forbidden: &[String]) -> Result<PathBuf> {
let policy = Policy {
forbidden: forbidden.to_vec(),
manifest: manifest_path(workspace_root),
note: "Paths are workspace-root-relative and chmod -w by nornir guard. \
Deny writes from agent runtimes; nornir's guard_intact gate verifies \
sha256+mode at release time."
.to_string(),
};
let path = policy_path(workspace_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(&policy).context("serialize guard policy")?;
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
/// Convenience for `guard_apply`: chmod -w, snapshot a manifest, export
/// the policy file. Returns the per-path chmod report.
pub fn apply_and_record(workspace_root: &Path, forbidden: &[String]) -> Result<Vec<PathStatus>> {
let report = apply(workspace_root, forbidden)?;
let m = manifest(workspace_root, forbidden);
write_manifest(workspace_root, &m)?;
write_policy(workspace_root, forbidden)?;
Ok(report)
}
/// `guard status` as a uniform [`nornir_cli_outcome::CommandOutcome`] — the CLI
/// twin of the guard rail's writable-bit report. `report` is already projected
/// from the canonical source (fat: [`status`]; thin: the `Guard.Status` RPC's
/// rows mapped into [`PathStatus`]) — this only SHAPES + renders, never inspects
/// the filesystem itself. `ok ⟺ ≥1 forbidden path actually exists on disk`: an
/// empty `[guard].forbidden` list or an all-missing set means there is nothing
/// being guarded, which is RED for the autopilot (a guard that protects nothing
/// is not a true/sannr resting state). AUT9.
pub fn status_outcome(report: &[PathStatus]) -> nornir_cli_outcome::CommandOutcome {
use nornir_cli_outcome::CommandOutcome;
let present = report.iter().filter(|s| s.exists).count();
if present == 0 {
return CommandOutcome::fail(
"guard status",
"no forbidden paths present — `[guard].forbidden` is empty or every \
protected path is missing; nothing is being guarded",
);
}
let locked = report.iter().filter(|s| s.exists && !s.writable).count();
let mut human = format!("{:<10} {:<10} {:<10} path", "exists", "writable", "changed");
for s in report {
human.push_str(&format!(
"\n{:<10} {:<10} {:<10} {}",
yn_bool(s.exists),
yn_bool(s.writable),
yn_bool(s.changed),
s.path.display()
));
}
human.push_str(&format!(
"\n{present} forbidden path(s) present, {locked} locked (read-only)"
));
CommandOutcome::ok("guard status", serde_json::json!(report), human)
}
/// `yes`/`no` for a bool — the human-table cell renderer (kept local so the
/// builder owns its whole rendering and the bin's `yn` need not be a dep).
fn yn_bool(b: bool) -> &'static str {
if b {
"yes"
} else {
"no"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(p: &Path, s: &str) {
std::fs::write(p, s).unwrap();
}
#[test]
fn manifest_then_verify_clean() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.txt"), "hello");
std::fs::create_dir(root.join("d")).unwrap();
let forbidden = vec![
"a.txt".to_string(),
"d".to_string(),
"missing.txt".to_string(),
];
let m = manifest(root, &forbidden);
let report = verify(root, &m);
assert!(
report.iter().all(|v| v.ok()),
"freshly recorded tree must be intact"
);
assert!(intact(root, &m).is_ok());
}
#[test]
fn verify_detects_content_drift() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.txt"), "hello");
let forbidden = vec!["a.txt".to_string()];
let m = manifest(root, &forbidden);
write(&root.join("a.txt"), "tampered");
let report = verify(root, &m);
let a = report.iter().find(|v| v.rel == "a.txt").unwrap();
assert!(matches!(a.drift.as_slice(), [Drift::Content { .. }]));
assert!(intact(root, &m).is_err());
}
#[test]
fn verify_detects_vanish_and_appear() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.txt"), "hi");
let forbidden = vec!["a.txt".to_string(), "later.txt".to_string()];
let m = manifest(root, &forbidden);
std::fs::remove_file(root.join("a.txt")).unwrap();
write(&root.join("later.txt"), "now here");
let report = verify(root, &m);
let a = report.iter().find(|v| v.rel == "a.txt").unwrap();
let l = report.iter().find(|v| v.rel == "later.txt").unwrap();
assert!(matches!(a.drift.as_slice(), [Drift::Vanished]));
assert!(matches!(l.drift.as_slice(), [Drift::Appeared]));
}
#[cfg(unix)]
#[test]
fn verify_detects_mode_drift() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let f = root.join("a.txt");
write(&f, "x");
let mut perms = std::fs::metadata(&f).unwrap().permissions();
perms.set_mode(0o444);
std::fs::set_permissions(&f, perms).unwrap();
let forbidden = vec!["a.txt".to_string()];
let m = manifest(root, &forbidden);
// Re-grant write (the classic tamper).
let mut perms = std::fs::metadata(&f).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&f, perms).unwrap();
let report = verify(root, &m);
let a = report.iter().find(|v| v.rel == "a.txt").unwrap();
assert!(a.drift.iter().any(|d| matches!(d, Drift::Mode { .. })));
}
#[test]
fn write_and_read_manifest_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.txt"), "hello");
let forbidden = vec!["a.txt".to_string()];
let report = apply_and_record(root, &forbidden).unwrap();
assert_eq!(report.len(), 1);
assert!(manifest_path(root).exists());
assert!(policy_path(root).exists());
let loaded = read_manifest(root).unwrap();
assert!(intact(root, &loaded).is_ok());
}
// ── AUT9: status_outcome sannr check (inject real PathStatus, assert verdict) ──
#[test]
fn status_outcome_empty_or_all_missing_is_red() {
// No forbidden paths at all ⇒ nothing guarded ⇒ RED.
let o = status_outcome(&[]);
assert_eq!(o.command, "guard status");
assert!(
!o.is_sannr(),
"an empty guard report is RED for the autopilot"
);
// Every forbidden path missing ⇒ still nothing guarded ⇒ RED.
let all_missing = vec![PathStatus {
path: PathBuf::from("/nope/gone.md"),
exists: false,
writable: false,
changed: false,
}];
let o = status_outcome(&all_missing);
assert!(
!o.is_sannr(),
"all-missing forbidden set guards nothing ⇒ RED"
);
}
#[test]
fn status_outcome_with_present_paths_is_sannr() {
let report = vec![
PathStatus {
path: PathBuf::from("/ws/.nornir/warehouse"),
exists: true,
writable: false, // locked
changed: true,
},
PathStatus {
path: PathBuf::from("/ws/CHANGELOG.md"),
exists: true,
writable: true,
changed: false,
},
PathStatus {
path: PathBuf::from("/ws/missing.md"),
exists: false,
writable: false,
changed: false,
},
];
let o = status_outcome(&report);
assert!(
o.is_sannr(),
"≥1 present forbidden path is a true (sannr) outcome"
);
let arr = o.data.as_array().unwrap();
assert_eq!(
arr.len(),
3,
"data carries every reported path, including missing"
);
assert_eq!(arr[0]["exists"], serde_json::json!(true));
assert_eq!(arr[0]["writable"], serde_json::json!(false));
assert!(
o.human.contains("1 locked"),
"human summary counts the locked path"
);
}
}