assay-cli 5.5.2

Policy-as-code gate for MCP agent tool calls, with verifiable evidence and Linux kernel enforcement.
use crate::exit_codes;
use assay_core::errors::diagnostic::{exit_class, Diagnostic, ExitClass, Severity};
use std::path::{Path, PathBuf};

/// The CLI spelling of a diagnostic severity.
///
/// An unrecognized value becomes `error`, not `note`. The previous fallback was
/// the least severe level, so a misspelled or newly introduced severity dropped
/// out of the set `decide_exit` inspects and could turn a failing run into exit
/// 0 (#2025). The vocabulary itself lives in `assay-core` beside `Diagnostic`,
/// because a second copy of it had already drifted (#2033).
pub fn normalize_severity(s: &str) -> &'static str {
    Severity::parse(s)
        .map(Severity::as_cli_str)
        .unwrap_or("error")
}

pub fn infer_policy_path(assay_yaml: &Path) -> Option<PathBuf> {
    let s = std::fs::read_to_string(assay_yaml).ok()?;
    let doc: serde_yaml::Value = serde_yaml::from_str(&s).ok()?;
    let m = doc.as_mapping()?;
    let v = m.get(serde_yaml::Value::String("policy".into()))?;
    let p = v.as_str()?;
    Some(PathBuf::from(p))
}

/// The process exit code for a set of diagnostics.
///
/// The class of each code comes from the registry that defines it. This function used to infer the
/// class by matching code prefixes, which made a code's spelling load-bearing for exit semantics
/// and had already drifted: `E_TRACE_MISS` and `E_PATH_NOT_FOUND` exited 1 and 2 respectively.
///
/// That pairing named the wrong two codes, and ADR-046 corrects it. `E_TRACE_MISS` is a coverage
/// miss -- `providers/trace.rs:37` builds it with "prompt not found in loaded traces", so the file
/// loaded and a prompt is absent from it. The pair that genuinely describes one condition is
/// `E_PATH_NOT_FOUND` and `E_TRACE_NOT_FOUND`, and they stay two codes because they land in two
/// artifacts; what the class table fixes is that they now exit the same way.
pub fn decide_exit(diags: &[Diagnostic]) -> i32 {
    let mut saw_error = false;
    let mut saw_config = false;

    for d in diags {
        if normalize_severity(&d.severity) != "error" {
            continue;
        }
        saw_error = true;
        match exit_class(&d.code) {
            ExitClass::Config => saw_config = true,
            // An unregistered code is not evidence of a config fault. `assay_core::validate`
            // forwards policy-engine verdict codes verbatim (`E_ARG_SCHEMA` and friends), and those
            // describe a test outcome. Registering them is #2027; until then they land here, which
            // is the same exit code the prefix match gave them.
            ExitClass::Test | ExitClass::Unregistered => {}
        }
    }

    if !saw_error {
        return exit_codes::OK;
    }
    if saw_config {
        exit_codes::CONFIG_ERROR
    } else {
        exit_codes::TEST_FAILED
    }
}

/// The process exit code for a repair that could not be applied.
///
/// A patch that fails to write is a fault in the tree the caller pointed at, so it gets the same
/// class a config fault gets, and `assay fix` has answered it that way since before `assay doctor
/// --fix` existed. This function exists so the two commands read one answer: they held two literals
/// for one condition, `2` in `commands/fix.rs` and `1` in `commands/doctor/fixes.rs`, and the second
/// was the only `1` in the system for it.
///
/// It is deliberately not [`decide_exit`] over the surviving diagnostics, and the reason is
/// forward-looking rather than observable. The two agree for every input that can reach a failed
/// repair: on the doctor path an op is only ever offered for `E_PATH_NOT_FOUND` or `E_TRACE_MISS`,
/// both error-severity and both registered [`ExitClass::Config`], so a tree where a write can fail
/// already answers `2`. That agreement is a property of today's class table, not of the two
/// questions — `decide_exit` answers what the report found, this answers what happened to an
/// operation the process attempted. Coupling them would let a future op producer at `warn`, or a
/// re-registration of an existing code, silently turn a failed write into exit `0`: a failed
/// operation reported as a clean result, which `AGENTS.md` forbids whether or not anyone can reach
/// it. An earlier version of this comment claimed the difference was observable on an advisory-only
/// tree. It is not — such a tree is offered no repair at all, so none can fail.
///
/// The reason-code registry has no code for the condition, so this cannot yet come from
/// `ReasonCode::exit_code` the way an unloadable config does. That registry is frozen against
/// SPEC-PR-Gate-Outputs-v1 and adding to it is a spec change, not a bug fix; #2208 already owns the
/// broader question of which CLI literals belong in it.
pub fn decide_repair_failure_exit() -> i32 {
    exit_codes::CONFIG_ERROR
}

#[cfg(test)]
mod decide_exit_tests {
    use super::*;
    use assay_core::errors::diagnostic::{codes, ERROR_EXIT_CLASSES};

    fn err(code: &str) -> Diagnostic {
        Diagnostic::new(code, "test")
    }

    /// The defect this function was rewritten for: one missing-trace condition is reported under
    /// two codes depending on which lookup found it, and they used to exit 1 and 2.
    #[test]
    fn missing_trace_exits_the_same_under_either_code() {
        assert_eq!(
            decide_exit(&[err(codes::E_TRACE_MISS)]),
            decide_exit(&[err(codes::E_PATH_NOT_FOUND)]),
        );
    }

    /// The four codes the prefix list missed. Each is declared an error in the registry and each
    /// used to exit 1.
    #[test]
    fn codes_the_prefix_list_missed_now_exit_config() {
        for code in [
            codes::E_TRACE_MISS,
            codes::E_TRACE_INVALID,
            codes::E_REPLAY_STRICT_MISSING,
            codes::E_EMB_DIMS,
        ] {
            assert_eq!(
                decide_exit(&[err(code)]),
                exit_codes::CONFIG_ERROR,
                "{code} is a registered error code and must not exit as a test failure",
            );
        }
    }

    #[test]
    fn every_registered_error_code_exits_config() {
        for (code, _) in ERROR_EXIT_CLASSES {
            assert_eq!(
                decide_exit(&[err(code)]),
                exit_codes::CONFIG_ERROR,
                "{code}",
            );
        }
    }

    /// Policy-engine verdict codes reach `validate` unregistered. They describe a test outcome, and
    /// this is the exit code they had before the rewrite.
    #[test]
    fn unregistered_code_exits_test_failure() {
        assert_eq!(decide_exit(&[err("E_ARG_SCHEMA")]), exit_codes::TEST_FAILED);
        assert_eq!(decide_exit(&[err("E_UNKNOWN")]), exit_codes::TEST_FAILED);
    }

    /// A config fault anywhere in the set decides, matching the previous `any` semantics.
    #[test]
    fn one_config_code_among_test_codes_decides() {
        let diags = vec![err("E_ARG_SCHEMA"), err(codes::E_CFG_PARSE)];
        assert_eq!(decide_exit(&diags), exit_codes::CONFIG_ERROR);
    }

    #[test]
    fn warnings_and_empty_sets_exit_ok() {
        assert_eq!(decide_exit(&[]), exit_codes::OK);
        let warn = err(codes::W_CFG_VACUOUS_EXPECTED).with_severity("warn");
        assert_eq!(decide_exit(&[warn]), exit_codes::OK);
    }

    /// `E_TRACE_SCHEMA` was one of the four prefixes and has never named a code. Nothing may depend
    /// on it, and a code that merely starts with a config-looking prefix is no longer special.
    #[test]
    fn spelling_no_longer_decides() {
        assert_eq!(
            decide_exit(&[err("E_TRACE_SCHEMA")]),
            exit_codes::TEST_FAILED
        );
        assert_eq!(
            decide_exit(&[err("E_CFG_INVENTED")]),
            exit_codes::TEST_FAILED
        );
    }
}

#[cfg(test)]
mod severity_exit_tests {
    use super::{decide_exit, normalize_severity};
    use assay_core::errors::diagnostic::Diagnostic;

    #[test]
    fn an_unknown_severity_does_not_silently_pass() {
        // Previously `normalize_severity` returned "note" here, `decide_exit`
        // saw no error, and the command exited 0 on a diagnostic nobody could
        // classify (#2025).
        assert_eq!(normalize_severity("cirtical"), "error");
        let diag = Diagnostic {
            code: "E_CFG_PARSE".into(),
            severity: "cirtical".into(),
            source: "test".into(),
            message: "unclassifiable".into(),
            context: serde_json::Value::Null,
            fix_steps: vec![],
        };
        assert_ne!(decide_exit(&[diag]), 0, "an unknown severity exited 0");
    }

    #[test]
    fn known_severities_are_unchanged() {
        assert_eq!(normalize_severity("Warning"), "warn");
        assert_eq!(normalize_severity("INFO"), "note");
        assert_eq!(normalize_severity("error"), "error");
    }
}