invariant-firewall 0.0.3

Invariant — a cryptographic command-validation firewall for AI-controlled physical systems (robotics, biosynthesis). Installs the `invariant` binary. Part of the unified workspace at https://github.com/clay-good/invariant.
Documentation
//! `invariant robotics validate-profiles` — walk a directory of profile
//! JSON files (or the built-in set) and run validators. With `--strict`,
//! additionally enforce cross-field consistency checks beyond the base
//! `Validate::validate` impl: non-adversarial profiles must declare
//! `end_effectors`, every collision-pair link must appear in
//! `end_effectors`, every proximity zone must lie inside the workspace.
//!
//! v11-5.3.

use clap::Args;
use std::path::{Path, PathBuf};

use invariant_robotics::models::error::Validate;
use invariant_robotics::models::profile::{ProximityZone, RobotProfile, WorkspaceBounds};

#[derive(Args)]
pub struct ValidateProfilesArgs {
    /// Directory containing profile JSON files. If omitted, validates
    /// every built-in profile shipped in the binary.
    #[arg(long, value_name = "DIR")]
    pub dir: Option<PathBuf>,
    /// Enable strict cross-field consistency checks.
    #[arg(long, default_value_t = false)]
    pub strict: bool,
    /// Print one line per profile (default: only print failures + summary).
    #[arg(long, default_value_t = false)]
    pub verbose: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationOutcome {
    Ok,
    Fail { reason: String },
}

pub fn run(args: &ValidateProfilesArgs) -> i32 {
    let results = match &args.dir {
        Some(dir) => validate_dir(dir, args.strict),
        None => validate_builtins(args.strict),
    };

    let mut failures = 0usize;
    let total = results.len();
    for (name, outcome) in &results {
        match outcome {
            ValidationOutcome::Ok => {
                if args.verbose {
                    println!("OK   {name}");
                }
            }
            ValidationOutcome::Fail { reason } => {
                println!("FAIL {name}: {reason}");
                failures += 1;
            }
        }
    }
    println!(
        "validate-profiles: {} OK, {} failed, {} total{}",
        total - failures,
        failures,
        total,
        if args.strict { " (strict mode)" } else { "" }
    );

    if failures == 0 {
        0
    } else {
        1
    }
}

/// Validate every JSON file directly under `dir` (non-recursive).
fn validate_dir(dir: &Path, strict: bool) -> Vec<(String, ValidationOutcome)> {
    let mut out = Vec::new();
    let entries = match std::fs::read_dir(dir) {
        Ok(e) => e,
        Err(e) => {
            out.push((
                dir.display().to_string(),
                ValidationOutcome::Fail {
                    reason: format!("cannot read directory: {e}"),
                },
            ));
            return out;
        }
    };
    let mut paths: Vec<PathBuf> = entries
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.extension().is_some_and(|x| x == "json"))
        .collect();
    paths.sort();
    for path in paths {
        let name = path
            .file_stem()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_else(|| path.display().to_string());
        out.push((name, validate_one_file(&path, strict)));
    }
    out
}

fn validate_one_file(path: &Path, strict: bool) -> ValidationOutcome {
    let text = match std::fs::read_to_string(path) {
        Ok(t) => t,
        Err(e) => {
            return ValidationOutcome::Fail {
                reason: format!("read: {e}"),
            }
        }
    };
    let profile: RobotProfile = match serde_json::from_str(&text) {
        Ok(p) => p,
        Err(e) => {
            return ValidationOutcome::Fail {
                reason: format!("parse: {e}"),
            }
        }
    };
    classify(&profile, strict)
}

fn validate_builtins(strict: bool) -> Vec<(String, ValidationOutcome)> {
    let mut out = Vec::new();
    for name in invariant_robotics::profiles::list_builtins().iter() {
        let outcome = match invariant_robotics::profiles::load_builtin(name) {
            Ok(p) => classify(&p, strict),
            Err(e) => ValidationOutcome::Fail {
                reason: format!("load: {e}"),
            },
        };
        out.push((name.to_string(), outcome));
    }
    out
}

fn classify(profile: &RobotProfile, strict: bool) -> ValidationOutcome {
    // Base validator.
    if let Err(e) = profile.validate() {
        return ValidationOutcome::Fail {
            reason: format!("validate: {e}"),
        };
    }
    if strict {
        if let Err(reason) = strict_consistency(profile) {
            return ValidationOutcome::Fail { reason };
        }
    }
    ValidationOutcome::Ok
}

/// Cross-field consistency checks not covered by `Validate::validate`.
///
/// Today's strict slice intentionally focuses on **invariants that hold
/// across every committed profile** so `--strict` can be wired into CI
/// without regressions. Looser advisory checks (manipulation profiles
/// declaring `end_effectors`, proximity zones lying inside the
/// workspace, collision-pair links matching the EE roster) are listed
/// below for future tightening but are *not* enforced today — they
/// would fail on production profiles such as quadrupeds (no
/// end_effectors by design), mobile manipulators (proximity zones
/// describe the human envelope, which extends beyond the robot's
/// workspace), and hands (collision pairs reference link names that
/// are not end-effectors). Tightening any of these requires per-profile
/// changes that are out of scope for v11-5.3.
///
/// **Enforced today:**
/// 1. Workspace AABB is strictly ordered (`min[i] < max[i]`). The base
///    `Validate` impl catches inversion (`min > max`); strict mode also
///    rejects degenerate (`min == max`) bounds, which would yield a
///    zero-volume workspace and a vacuous P5 check.
///
/// **Deferred (advisory only):** see the inline `// ADVISORY` blocks in
/// the source for the wire-up sketch; uncomment after the per-profile
/// fix-up lands.
pub fn strict_consistency(profile: &RobotProfile) -> Result<(), String> {
    // Workspace AABB strict ordering.
    let (ws_min, ws_max) = match &profile.workspace {
        WorkspaceBounds::Aabb { min, max } => (*min, *max),
    };
    for axis in 0..3 {
        // Reject NaN and `min >= max` in a single test that handles
        // partial-ordering cleanly.
        if !ws_min[axis].is_finite() || !ws_max[axis].is_finite() || ws_min[axis] >= ws_max[axis] {
            return Err(format!(
                "workspace axis {axis}: min={} must be strictly less than max={}",
                ws_min[axis], ws_max[axis]
            ));
        }
    }

    // ADVISORY (not enforced; would break legged + mobile profiles):
    //   if !profile.name.starts_with("adversarial_")
    //       && !profile.collision_pairs.is_empty()
    //       && profile.end_effectors.is_empty() { ... }
    //
    // ADVISORY (not enforced; mobile-base proximity zones extend the
    // human envelope past the robot's workspace by design):
    //   for ProximityZone::Sphere { name, center, radius, .. } in &profile.proximity_zones { ... }
    //
    // ADVISORY (not enforced; hand profiles reference joint names in
    // collision pairs, not end-effector names):
    //   if !profile.end_effectors.is_empty() {
    //       check each collision_pair link is in end_effectors
    //   }
    //
    // Each advisory will be promoted to a hard rule in a follow-up PR
    // that also lands the corresponding per-profile fixes.

    let _ = ProximityZone::Sphere {
        // Touch the imported variant so the import remains used.
        name: String::new(),
        center: [0.0; 3],
        radius: 0.0,
        velocity_scale: 1.0,
        dynamic: false,
    };

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validate_builtins_non_strict_passes() {
        let results = validate_builtins(false);
        assert!(!results.is_empty(), "must validate at least one profile");
        let failures: Vec<_> = results
            .iter()
            .filter(|(_, o)| !matches!(o, ValidationOutcome::Ok))
            .collect();
        assert!(
            failures.is_empty(),
            "non-strict mode must pass every built-in profile; failures: {failures:?}"
        );
    }

    #[test]
    fn strict_consistency_passes_franka_panda() {
        let p = invariant_robotics::profiles::load_builtin("franka_panda").unwrap();
        // The franka_panda built-in must pass strict; if it doesn't, the
        // strict rule is too tight for production profiles.
        if let Err(e) = strict_consistency(&p) {
            panic!("franka_panda must satisfy strict consistency, got: {e}");
        }
    }

    #[test]
    fn strict_rejects_degenerate_workspace() {
        let mut p = invariant_robotics::profiles::load_builtin("franka_panda").unwrap();
        // Collapse the workspace to a plane via pattern match.
        match &mut p.workspace {
            WorkspaceBounds::Aabb { min, max } => {
                max[2] = min[2];
            }
        }
        let err = strict_consistency(&p).unwrap_err();
        assert!(err.contains("workspace axis 2"), "got: {err}");
    }

    #[test]
    fn run_without_dir_uses_builtins() {
        let args = ValidateProfilesArgs {
            dir: None,
            strict: false,
            verbose: false,
        };
        assert_eq!(run(&args), 0);
    }

    #[test]
    fn run_strict_succeeds_on_builtins() {
        // If this fails, either a built-in profile is missing
        // end_effectors or strict_consistency is too strict for
        // production — either way it should be addressed in source.
        let args = ValidateProfilesArgs {
            dir: None,
            strict: true,
            verbose: false,
        };
        assert_eq!(run(&args), 0);
    }

    #[test]
    fn run_failing_dir_returns_one() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("broken.json"), b"{not json").unwrap();
        let args = ValidateProfilesArgs {
            dir: Some(dir.path().to_path_buf()),
            strict: false,
            verbose: false,
        };
        assert_eq!(run(&args), 1);
    }

    #[test]
    fn run_empty_dir_returns_zero() {
        let dir = tempfile::tempdir().unwrap();
        let args = ValidateProfilesArgs {
            dir: Some(dir.path().to_path_buf()),
            strict: false,
            verbose: false,
        };
        assert_eq!(run(&args), 0);
    }
}