Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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"
        );
    }
}