From 0e8735518249bf4da054174c2f675894f2249db6 Mon Sep 17 00:00:00 2001 From: CLAUDE <355856132+HoTT-stuff@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:07:41 +0000 Subject: [PATCH 1/8] feat(ponsbot): phase 0 of the scanner cutover (adapter, parity fixes, rulings) Phase 0 of docs/migrations/scanner-cutover-ponsbot-panicbot.adoc. Nothing is enrolled, no grid row or workflow lane is removed. ponsbot (bots/ponsbot): thin adapter, `ponsbot --format json scan ` runs pons at the commit in canon/pin.toml as a subprocess and writes nothing into the tree. Exit 0/1 with a valid report -> ran; exit 2 -> error with pons' message; missing binary -> not_run; timeout, unreadable report or a pons at another commit -> error. Never a pass. Each finding keeps tier and evidence class; SPECULATIVE is carried at info, never graded as blocking (D1). Driver: ponsbot arm, build line (--pons), tests including the real renderer. BotId::Ponsbot exists but is not in all(): no fleet dispatch yet. panicbot: the parity cases from design section 5 found three real holes, fixed at the source: the timeout was never enforced, a non-zero exit with parseable stdout was accepted, and a report that analysed nothing was an empty ran. pin.toml gains the panic-attack build commit (value from the standards lane). Design doc records D0, D1 and the two gating rulings (2026-11-12): pons gates, panic-attack does not; the PA_GATES carry-over is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkERqsQWMogHRpM2z8 --- .github/workflows/rust.yml | 4 + bots/panicbot/canon/pin.toml | 19 + bots/panicbot/src/scanner.rs | 276 +++++++++--- bots/panicbot/tests/envelope_parity_test.rs | 137 ++++++ bots/ponsbot/Cargo.lock | 416 ++++++++++++++++++ bots/ponsbot/Cargo.toml | 28 ++ bots/ponsbot/canon/pin.toml | 24 + bots/ponsbot/src/cli.rs | 57 +++ bots/ponsbot/src/lib.rs | 14 + bots/ponsbot/src/main.rs | 66 +++ bots/ponsbot/src/pin.rs | 50 +++ bots/ponsbot/src/scanner.rs | 373 ++++++++++++++++ bots/ponsbot/src/translator.rs | 151 +++++++ bots/ponsbot/tests/adapter_test.rs | 301 +++++++++++++ docs/REPORT-BACK.adoc | 13 +- .../scanner-cutover-ponsbot-panicbot.adoc | 120 +++-- scripts/fleet-report-back-build.sh | 29 +- scripts/fleet-report-back.sh | 44 +- shared-context/src/bot.rs | 20 +- shared-context/tests/ponsbot_test.rs | 21 + tests/fleet-report-back-build-test.sh | 44 ++ tests/fleet-report-back-test.sh | 135 ++++++ tests/panic-attack-rule-ids-test.sh | 2 +- 23 files changed, 2237 insertions(+), 107 deletions(-) create mode 100644 bots/panicbot/tests/envelope_parity_test.rs create mode 100644 bots/ponsbot/Cargo.lock create mode 100644 bots/ponsbot/Cargo.toml create mode 100644 bots/ponsbot/canon/pin.toml create mode 100644 bots/ponsbot/src/cli.rs create mode 100644 bots/ponsbot/src/lib.rs create mode 100644 bots/ponsbot/src/main.rs create mode 100644 bots/ponsbot/src/pin.rs create mode 100644 bots/ponsbot/src/scanner.rs create mode 100644 bots/ponsbot/src/translator.rs create mode 100644 bots/ponsbot/tests/adapter_test.rs create mode 100644 shared-context/tests/ponsbot_test.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 60c08477..0470a463 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -49,6 +49,10 @@ jobs: # in CI, so its private PA numbering went unnoticed until 2026-11-04. - module: panicbot dir: bots/panicbot + # ponsbot: the pons adapter (cutover design phase 0); tested here with + # a stub pons, the real-binary test runs when PONSBOT_REAL_PONS is set. + - module: ponsbot + dir: bots/ponsbot - module: idris-dependents dir: idris-dependents defaults: diff --git a/bots/panicbot/canon/pin.toml b/bots/panicbot/canon/pin.toml index 6797af3d..231da2ed 100644 --- a/bots/panicbot/canon/pin.toml +++ b/bots/panicbot/canon/pin.toml @@ -14,3 +14,22 @@ path = "data/canonical-rule-ids.json" commit = "5175a681f9a0c82313f06e3c2cba8089f4ecfb6b" pinned = "2026-11-04" sha256 = "28ffe510535e0bc6cccc92845411d6496fe9270cce58364f4d3507c5c08337d4" + +# The panic-attack BUILD commit (design section 5: this file becomes the single +# panic-attack pin). Value and provenance, not invented: it is the commit the +# standards Hypatia lane runs, `panic-ref`'s default in metadatastician/standards +# `.github/workflows/hypatia-scan-reusable.yml` and `PANIC_ATTACK_SHA` in its +# `fork-governance.yml` (standards main f0b3cd4, 2026-11-12). +# +# NOT YET CONSUMED: the workflows still carry their own copies, and they +# disagree. Alignment is a later phase, decided by the owner: +# - gitbot-fleet `.github/workflows/fleet-report-back-reusable.yml`: +# b689a74d2762a1c19839ae3fbd59b7fe5ef920ea (2026-10-08, older; its comment +# claims it is kept equal to the Hypatia lane's pin, it is not) +# - standards `.github/workflows/deep-recon-reusable.yml` `panic-rev`: +# b5530834a839734d1d181bfe6edb0d43d4233cf4 (2026-10-25) +# The rule-ID table above was taken at 5175a681 and is a separate pin. +[build] +repo = "hyperpolymath/panic-attack" +commit = "410a8f821ea8297833162565b50a298f787815da" +pinned = "2026-11-12" diff --git a/bots/panicbot/src/scanner.rs b/bots/panicbot/src/scanner.rs index 6dd45caa..39d4a2a8 100644 --- a/bots/panicbot/src/scanner.rs +++ b/bots/panicbot/src/scanner.rs @@ -182,23 +182,6 @@ pub struct DiagnosticsReport { pub messages: Vec, } -/// The top-level JSON envelope from `panic-attack assail`. -/// -/// panic-attack wraps AssailReport inside an AssaultReport. When using -/// `--output-format json`, the outer envelope may or may not be present -/// depending on which subcommand was invoked. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct AssaultReportEnvelope { - /// The static analysis report (always present for `assail`). - assail_report: Option, - /// Direct weak_points at top level (alternative format). - #[serde(default)] - weak_points: Vec, - /// Direct fields for when the report IS the assail report. - program_path: Option, - language: Option, -} - /// Run `panic-attack assail --output-format json`. /// /// Returns the parsed AssailReport containing all detected weak points. @@ -208,32 +191,87 @@ pub fn run_assail(target: &Path, timeout: Duration) -> Result { // `--headless` selects CI-safe output (JSON to stdout, no interactive // prompts). Required for panic-attack >= 2.5: without it the default // `--report-view accordion` renders a human report, not parseable JSON. - let output = invoke_panic_attack( + let run = run_panic_attack_raw( &["assail", &target.display().to_string(), "--headless"], timeout, ) .context("Failed to run panic-attack assail")?; + // A non-zero exit is an error, whatever stdout holds: the estate's direct + // lane treats any non-zero `assail` exit as `error`, and a scan that died + // part-way can still have printed a parseable prefix. (assail exits 0 with + // findings; it exits non-zero on failure or, with --fail-on-incomplete, + // on incomplete coverage.) + if !run.status.success() { + bail!( + "panic-attack assail exited with {}: {}", + run.status, + first_diagnostic(&run.stderr) + ); + } + if run.stdout.trim().is_empty() { + bail!( + "panic-attack assail exited 0 without a report (empty output). stderr: {}", + first_diagnostic(&run.stderr) + ); + } + parse_assail_output(&run.stdout) +} + +/// Parse and VALIDATE the stdout of a successful `assail` run. +/// +/// Exit 0 is not evidence that anything was analysed. Mirroring the estate's +/// direct lanes (standards `hypatia-scan-reusable.yml`, `deep-recon-reusable.yml`), +/// a report is accepted only when it has a `weak_points` array, every weak +/// point carries a known severity, and `statistics.total_lines` is a positive +/// number. Anything else is an error, never an empty `ran` (cutover design +/// section 5; a crashed or empty scanner must not summarise itself green). +pub fn parse_assail_output(raw: &str) -> Result { // Even in --headless mode panic-attack prints a human banner line // (e.g. "Running assail analysis on: .") to stdout before the JSON // document. Slice from the first JSON delimiter so serde sees pure JSON. - let json = extract_json(&output); - - // Try parsing as AssaultReport envelope first - if let Ok(envelope) = serde_json::from_str::(json) { - if let Some(report) = envelope.assail_report { - return Ok(report); - } - // Flat format — the JSON IS the assail report - if envelope.program_path.is_some() { - return serde_json::from_str::(json) - .context("Failed to parse assail report (flat format)"); + let json = extract_json(raw); + + let value: serde_json::Value = serde_json::from_str(json) + .context("panic-attack assail exited 0 without a valid report (not JSON)")?; + // Handles both envelope formats: `{"assail_report": {...}}` and flat. + let report_value = value + .get("assail_report") + .filter(|v| v.is_object()) + .unwrap_or(&value); + + let weak_points = report_value + .get("weak_points") + .and_then(|w| w.as_array()) + .context("panic-attack assail exited 0 without a valid report (no weak_points array)")?; + for (i, wp) in weak_points.iter().enumerate() { + let sev = wp.get("severity").and_then(|s| s.as_str()).unwrap_or(""); + if !matches!(sev, "Low" | "Medium" | "High" | "Critical") { + bail!("panic-attack assail exited 0 without a valid report (weak point {i} has severity {sev:?})"); } } + let total_lines = report_value + .pointer("/statistics/total_lines") + .and_then(|n| n.as_f64()) + .unwrap_or(0.0); + if total_lines <= 0.0 { + bail!( + "panic-attack assail exited 0 but analysed nothing (statistics.total_lines is missing or not positive)" + ); + } - // Direct parse as AssailReport - serde_json::from_str::(json) - .context("Failed to parse panic-attack assail output as JSON") + serde_json::from_value::(report_value.clone()) + .context("panic-attack assail exited 0 without a valid report (unexpected shape)") +} + +/// First nonblank line of captured stderr, at most 240 characters. +fn first_diagnostic(stderr: &str) -> String { + stderr + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(|l| l.chars().take(240).collect()) + .unwrap_or_else(|| "no diagnostic".to_string()) } /// Slice a captured stdout buffer down to its JSON payload. @@ -404,15 +442,21 @@ fn find_panic_attack_binary() -> Result { ) } -/// Invoke `panic-attack` as a subprocess with JSON output. +/// Captured result of one `panic-attack` run. +struct RawRun { + status: std::process::ExitStatus, + stdout: String, + stderr: String, +} + +/// Run `panic-attack --output-format json` with an ENFORCED timeout. /// -/// Handles: -/// - Binary not found (clear error message) -/// - Subprocess timeout (configurable, default 300s) -/// - Non-zero exit codes (returns stdout anyway, as panic-attack may produce -/// findings even on non-zero exit) -/// - Invalid/empty output (error) -fn invoke_panic_attack(args: &[&str], timeout: Duration) -> Result { +/// Handles: binary not found (clear error), a timeout (the child is killed and +/// the call fails: a hung scanner must not hang the lane or read as a run). +/// The exit status is returned for the caller to judge. +fn run_panic_attack_raw(args: &[&str], timeout: Duration) -> Result { + use std::io::Read; + let binary = find_panic_attack_binary()?; tracing::debug!( @@ -425,50 +469,82 @@ fn invoke_panic_attack(args: &[&str], timeout: Duration) -> Result { cmd.args(args); cmd.arg("--output-format"); cmd.arg("json"); - - // Capture stdout and stderr + cmd.stdin(std::process::Stdio::null()); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - let child = cmd + let mut child = cmd .spawn() .with_context(|| format!("Failed to spawn panic-attack at {}", binary.display()))?; - let output = child - .wait_with_output() - .context("Failed to wait for panic-attack subprocess")?; + // Drain both pipes on threads so a large report cannot fill a pipe and + // block the child while this thread waits on the deadline. + let mut out_pipe = child.stdout.take().expect("stdout is piped"); + let mut err_pipe = child.stderr.take().expect("stderr is piped"); + let out_t = std::thread::spawn(move || { + let mut b = Vec::new(); + let _ = out_pipe.read_to_end(&mut b); + b + }); + let err_t = std::thread::spawn(move || { + let mut b = Vec::new(); + let _ = err_pipe.read_to_end(&mut b); + b + }); + + let deadline = std::time::Instant::now() + timeout; + let status = loop { + match child + .try_wait() + .context("Failed to wait for panic-attack subprocess")? + { + Some(s) => break s, + None if std::time::Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + bail!( + "panic-attack did not finish within {}s (timed out and killed)", + timeout.as_secs() + ); + } + None => std::thread::sleep(Duration::from_millis(20)), + } + }; - // Log stderr for debugging (panic-attack may emit progress info there) - let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&out_t.join().unwrap_or_default()).to_string(); + let stderr = String::from_utf8_lossy(&err_t.join().unwrap_or_default()).to_string(); if !stderr.is_empty() { tracing::debug!("panic-attack stderr: {}", stderr.trim()); } + Ok(RawRun { + status, + stdout, + stderr, + }) +} - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); +/// Invoke `panic-attack` for the subcommands other than `assail`. +/// +/// Non-zero exit with output is tolerated here (diagnostics and some report +/// commands exit non-zero while still printing a usable document); `assail` +/// does NOT use this path, it is strict (see [`run_assail`]). +fn invoke_panic_attack(args: &[&str], timeout: Duration) -> Result { + let run = run_panic_attack_raw(args, timeout)?; - if stdout.trim().is_empty() { + if run.stdout.trim().is_empty() { bail!( "panic-attack produced empty output (exit code: {}). stderr: {}", - output.status, - stderr.trim() + run.status, + run.stderr.trim() ); } - - // Non-zero exit is not necessarily an error — panic-attack exits non-zero - // when it finds critical issues, but still produces valid JSON output. - if !output.status.success() { + if !run.status.success() { tracing::warn!( - "panic-attack exited with code {} (findings may still be valid)", - output.status + "panic-attack exited with code {} (output may still be valid)", + run.status ); } - - // Ignore timeout for now since std::process::Command doesn't have native - // timeout support. The timeout parameter is reserved for future use with - // tokio::process::Command or manual polling. - let _ = timeout; - - Ok(stdout) + Ok(run.stdout) } #[cfg(test)] @@ -497,6 +573,7 @@ mod tests { "program_path": "/tmp/test", "language": "rust", "frameworks": [], + "statistics": {"total_lines": 120}, "weak_points": [ { "category": "UnsafeCode", @@ -508,8 +585,7 @@ mod tests { } }"#; - let envelope: AssaultReportEnvelope = serde_json::from_str(json).unwrap(); - let report = envelope.assail_report.unwrap(); + let report = parse_assail_output(json).unwrap(); assert_eq!(report.weak_points.len(), 1); assert_eq!(report.weak_points[0].category, "UnsafeCode"); assert_eq!(report.language, "rust"); @@ -638,3 +714,69 @@ mod tests { assert!(wp.recommended_attack.is_empty()); } } + +#[cfg(test)] +mod parity_tests { + //! Envelope parity with the estate's direct lanes (cutover design section 5): + //! a report that is not exactly a real scan is an error, never an empty + //! `ran`. + use super::*; + + const GOOD: &str = r#"Running assail analysis on: . +{"program_path":"/t","language":"rust","statistics":{"total_lines":10},"weak_points":[ + {"category":"PanicPath","location":"a.rs:1","severity":"Medium","description":"unwrap"}]}"#; + + #[test] + fn a_real_report_with_its_banner_parses() { + assert_eq!(parse_assail_output(GOOD).unwrap().weak_points.len(), 1); + } + + #[test] + fn a_clean_real_scan_is_accepted_as_zero_findings() { + let r = parse_assail_output( + r#"{"program_path":"/t","language":"rust","statistics":{"total_lines":10},"weak_points":[]}"#, + ) + .unwrap(); + assert!(r.weak_points.is_empty()); + } + + #[test] + fn exit_0_without_a_valid_report_is_an_error() { + for bad in [ + "", + "Running assail analysis on: .\n", + "not json at all", + "{}", + "[]", + r#"{"statistics":{"total_lines":10}}"#, + r#"{"program_path":"/t","language":"x","statistics":{"total_lines":10},"weak_points":"none"}"#, + ] { + assert!( + parse_assail_output(bad).is_err(), + "accepted as a report: {bad:?}" + ); + } + } + + #[test] + fn a_scan_that_analysed_nothing_is_an_error_not_an_empty_ran() { + for stats in [ + r#""statistics":{"total_lines":0}"#, + r#""statistics":{}"#, + r#""statistics":null"#, + r#""other":1"#, + ] { + let json = + format!(r#"{{"program_path":"/t","language":"x",{stats},"weak_points":[]}}"#); + let e = parse_assail_output(&json).unwrap_err().to_string(); + assert!(e.contains("analysed nothing"), "{stats}: {e}"); + } + } + + #[test] + fn a_weak_point_with_an_unknown_severity_is_an_error() { + let json = r#"{"program_path":"/t","language":"x","statistics":{"total_lines":5}, + "weak_points":[{"category":"c","severity":"Catastrophic","description":"d"}]}"#; + assert!(parse_assail_output(json).is_err()); + } +} diff --git a/bots/panicbot/tests/envelope_parity_test.rs b/bots/panicbot/tests/envelope_parity_test.rs new file mode 100644 index 00000000..db960233 --- /dev/null +++ b/bots/panicbot/tests/envelope_parity_test.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Envelope parity with the estate's direct panic-attack lanes (cutover design +//! section 5). `panicbot --format json scan` must never print a findings array +//! for a scan that did not really run: each failure mode below must exit +//! non-zero with an empty stdout (the report-back driver turns that into an +//! `error` row carrying stderr's first line), and only a real report may +//! produce an array. +//! +//! panic-attack is a stub shell script on PATH here; this proves panicbot's +//! handling, not panic-attack's analysis. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::{Command, Output}; +use tempfile::TempDir; + +fn stub(dir: &Path, body: &str) { + let p = dir.join("panic-attack"); + fs::write(&p, format!("#!/usr/bin/env bash\n{body}\n")).unwrap(); + fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); +} + +fn scan(bin_dir: &Path, tree: &Path) -> Output { + let home = TempDir::new().unwrap(); + Command::new(env!("CARGO_BIN_EXE_panicbot")) + .env_clear() + .env("PATH", format!("{}:/usr/bin:/bin", bin_dir.display())) + .env("HOME", home.path()) + .env("RUST_LOG", "off") + .args(["--format", "json", "scan"]) + .arg(tree) + .output() + .unwrap() +} + +fn tree_with_timeout(secs: Option) -> TempDir { + let t = TempDir::new().unwrap(); + fs::write(t.path().join("a.rs"), "fn main() {}\n").unwrap(); + if let Some(s) = secs { + let d = t.path().join(".machine_readable/bot_directives"); + fs::create_dir_all(&d).unwrap(); + fs::write( + d.join("panicbot.a2ml"), + format!("[config]\ntimeout-seconds = {s}\n"), + ) + .unwrap(); + } + t +} + +const REAL: &str = r#"Running assail analysis on: . +{"program_path":"/t","language":"rust","statistics":{"total_lines":10},"weak_points":[{"category":"PanicPath","location":"a.rs:1","severity":"Medium","description":"unwrap"}]}"#; + +/// The scan did not run: non-zero exit, no findings array on stdout, a reason on stderr. +fn assert_not_a_run(o: &Output, reason_contains: &str) { + assert!( + !o.status.success(), + "exit {:?}, stdout: {}", + o.status.code(), + String::from_utf8_lossy(&o.stdout) + ); + let stdout = String::from_utf8_lossy(&o.stdout); + assert!( + serde_json::from_str::(&stdout) + .map(|v| !v.is_array()) + .unwrap_or(true), + "stdout must not be a findings array: {stdout}" + ); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!( + stderr.contains(reason_contains), + "stderr lacks {reason_contains:?}: {stderr}" + ); +} + +#[test] +fn not_installed_is_not_a_run() { + let empty = TempDir::new().unwrap(); // no stub + let o = scan(empty.path(), tree_with_timeout(None).path()); + assert_not_a_run(&o, "not found"); +} + +#[test] +fn timed_out_is_not_a_run_and_does_not_hang() { + let bin = TempDir::new().unwrap(); + stub(bin.path(), "exec sleep 30"); + let t = std::time::Instant::now(); + let o = scan(bin.path(), tree_with_timeout(Some(1)).path()); + assert!( + t.elapsed().as_secs() < 15, + "the timeout must be enforced by panicbot itself" + ); + assert_not_a_run(&o, "did not finish within 1s"); +} + +#[test] +fn exited_non_zero_is_not_a_run_even_with_a_valid_report_on_stdout() { + let bin = TempDir::new().unwrap(); + stub( + bin.path(), + &format!("cat <<'J'\n{REAL}\nJ\necho 'panic-attack: boom' >&2\nexit 3"), + ); + let o = scan(bin.path(), tree_with_timeout(None).path()); + assert_not_a_run(&o, "exited with"); +} + +#[test] +fn exit_0_without_a_valid_report_is_not_a_run() { + for (body, why) in [ + ("exit 0", "without a report"), + ( + "echo 'Running assail analysis on: .'; exit 0", + "without a valid report", + ), + ("echo '{}'; exit 0", "without a valid report"), + ("echo '[]'; exit 0", "without a valid report"), + ( + r#"echo '{"program_path":"/t","language":"x","statistics":{"total_lines":0},"weak_points":[]}'; exit 0"#, + "analysed nothing", + ), + ] { + let bin = TempDir::new().unwrap(); + stub(bin.path(), body); + let o = scan(bin.path(), tree_with_timeout(None).path()); + assert_not_a_run(&o, why); + } +} + +#[test] +fn a_real_report_is_a_run_with_its_findings() { + let bin = TempDir::new().unwrap(); + stub(bin.path(), &format!("cat <<'J'\n{REAL}\nJ\nexit 0")); + let o = scan(bin.path(), tree_with_timeout(None).path()); + let v: serde_json::Value = serde_json::from_slice(&o.stdout).expect("a findings array"); + assert!(v.is_array() && v.as_array().unwrap().len() == 1, "{v}"); +} diff --git a/bots/ponsbot/Cargo.lock b/bots/ponsbot/Cargo.lock new file mode 100644 index 00000000..2323ec69 --- /dev/null +++ b/bots/ponsbot/Cargo.lock @@ -0,0 +1,416 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ponsbot" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "tempfile", + "toml", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" +dependencies = [ + "serde_core", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml" +version = "1.2.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/bots/ponsbot/Cargo.toml b/bots/ponsbot/Cargo.toml new file mode 100644 index 00000000..2e8fbd46 --- /dev/null +++ b/bots/ponsbot/Cargo.toml @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MPL-2.0 +[package] +name = "ponsbot" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" +authors = ["Jonathan D.A. Jewell "] +description = "Thin adapter running pons asinorum at a pinned commit and reporting its findings to gitbot-fleet" +repository = "https://github.com/hyperpolymath/gitbot-fleet" +keywords = ["static-analysis", "audit", "gitbot-fleet"] +categories = ["development-tools"] + +[lib] +name = "ponsbot" +path = "src/lib.rs" + +[[bin]] +name = "ponsbot" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.6.1", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +toml = "1.1.2" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/bots/ponsbot/canon/pin.toml b/bots/ponsbot/canon/pin.toml new file mode 100644 index 00000000..d07af0f1 --- /dev/null +++ b/bots/ponsbot/canon/pin.toml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# The ONE pons asinorum commit ponsbot accepts. ponsbot compares it with the +# `tool.source_commit` pons prints in its own report and refuses to report +# (status error) when they differ or when pons does not say, so a drifted or +# unstamped binary can never produce a row. +# +# Provenance: the commit is `pons-ref`'s default in metadatastician/standards +# `.github/workflows/hypatia-scan-reusable.yml` (standards main f0b3cd4, +# 2026-11-12), the pin behind the grid's permanent pons row that ponsbot +# replaces. Not invented here. Known drift, not resolved in this file: +# `.github/workflows/deep-recon-reusable.yml` defaults `pons-rev` to the older +# 51b8920f541782ceb9465ea3f0d33c96104fc1fb (an ancestor of this commit). +# +# To re-pin: change `commit` and `pinned`, build pons there, run +# `cargo test` in bots/ponsbot. The workflows that build pons read this file +# once the cutover reaches them (docs/migrations/scanner-cutover-ponsbot-panicbot.adoc). +# +# pons publishes no rule-table digest, so none is pinned here (design 4). + +[source] +repo = "hyperpolymath/pons-asinorum" +commit = "bca1ea289a4f8a86bac6212731e6f8ec15c67ad4" +pinned = "2026-11-12" diff --git a/bots/ponsbot/src/cli.rs b/bots/ponsbot/src/cli.rs new file mode 100644 index 00000000..b30733ed --- /dev/null +++ b/bots/ponsbot/src/cli.rs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +//! CLI: `ponsbot [--format text|json] scan `. + +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +/// Ponsbot: gitbot-fleet adapter for pons asinorum. +#[derive(Parser, Debug)] +#[command(name = "ponsbot", version)] +#[command(about = "Runs pons asinorum at a pinned commit and reports its findings to gitbot-fleet")] +#[command( + long_about = "Ponsbot runs the `pons` scanner as a subprocess, never writes into the \ + scanned tree, and keeps each finding's tier and evidence class. Exit codes: 0 ran, no \ + blocking finding; 1 ran, at least one blocking finding; 2 error (pons failed, timed out, \ + or its report was unusable); 3 not run (pons is not installed). On 2 and 3 the JSON \ + output is an object {status, reason}, never a findings array." +)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, + + /// Output format (text, json). + #[arg(short, long, default_value = "text", global = true)] + pub format: OutputFormat, +} + +/// Output format for results. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum OutputFormat { + Text, + Json, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Scan a directory with pons and print the findings. + Scan { + /// Repository directory to scan. + #[arg(default_value = ".")] + dir: PathBuf, + + /// Seconds pons may run before ponsbot reports an error. + #[arg(long, default_value_t = 300)] + timeout: u64, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn test_cli_parses() { + Cli::command().debug_assert(); + } +} diff --git a/bots/ponsbot/src/lib.rs b/bots/ponsbot/src/lib.rs new file mode 100644 index 00000000..0440c8f9 --- /dev/null +++ b/bots/ponsbot/src/lib.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Ponsbot: a thin adapter that runs `pons` (pons asinorum) as a pinned +//! subprocess and reports its findings to the gitbot-fleet report-back driver. +//! +//! pons stays an external engine (owner ruling D0, 2026-11-12); nothing of its +//! analysis lives here. The adapter only +//! - finds and runs the pons binary, never writing into the scanned tree, +//! - maps pons' exit codes to a status that is never a pass ([`scanner`]), +//! - carries each finding's tier and evidence class ([`translator`]). + +pub mod cli; +pub mod pin; +pub mod scanner; +pub mod translator; diff --git a/bots/ponsbot/src/main.rs b/bots/ponsbot/src/main.rs new file mode 100644 index 00000000..79a81b45 --- /dev/null +++ b/bots/ponsbot/src/main.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MPL-2.0 +//! ponsbot entry point. See `lib.rs` and `cli.rs`. +//! +//! Output contract for `--format json` (what the report-back driver reads): +//! - ran: a JSON array of findings (possibly empty), exit 0 (no blocking +//! finding) or 1 (at least one blocking finding), +//! - error: `{"status":"error","reason":"..."}`, exit 2, +//! - not run: `{"status":"not_run","reason":"..."}`, exit 3. +//! +//! Nothing here ever prints an empty array for a scan that did not run. + +use clap::Parser; +use ponsbot::cli::{Cli, Command, OutputFormat}; +use ponsbot::scanner::{run_pons, Outcome}; +use ponsbot::{pin, translator}; +use std::process::ExitCode; +use std::time::Duration; + +fn main() -> ExitCode { + let cli = Cli::parse(); + let Command::Scan { dir, timeout } = cli.command; + let pin = pin::pinned(); + match run_pons(&dir, Duration::from_secs(timeout.max(1)), &pin.commit) { + Outcome::Ran(found) => { + let findings = translator::translate_all(&found); + match cli.format { + OutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&findings).expect("findings serialise") + ), + OutputFormat::Text => { + if findings.is_empty() { + println!("No findings (pons at {}).", &pin.commit[..12]); + } + for f in &findings { + println!( + "{}:{} [{}] {} {}", + f.file, f.line, f.rule_id, f.severity, f.message + ); + } + } + } + if findings.iter().any(|f| f.blocking) { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } + } + Outcome::Error(reason) => report_not_ran("error", &reason, cli.format, 2), + Outcome::NotRun(reason) => report_not_ran("not_run", &reason, cli.format, 3), + } +} + +fn report_not_ran(status: &str, reason: &str, format: OutputFormat, code: u8) -> ExitCode { + match format { + OutputFormat::Json => { + println!( + "{}", + serde_json::json!({ "status": status, "reason": reason }) + ) + } + OutputFormat::Text => println!("ponsbot: {status}: {reason}"), + } + eprintln!("ponsbot: {status}: {reason}"); + ExitCode::from(code) +} diff --git a/bots/ponsbot/src/pin.rs b/bots/ponsbot/src/pin.rs new file mode 100644 index 00000000..ddb7765a --- /dev/null +++ b/bots/ponsbot/src/pin.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The pinned pons commit, compiled in from `canon/pin.toml`. + +use serde::Deserialize; + +const PIN_TOML: &str = include_str!("../canon/pin.toml"); + +#[derive(Debug, Deserialize)] +struct PinFile { + source: Source, +} + +/// The `[source]` table of `canon/pin.toml`. +#[derive(Debug, Clone, Deserialize)] +pub struct Source { + pub repo: String, + pub commit: String, + pub pinned: String, +} + +/// The pin, parsed from the compiled-in `canon/pin.toml`. +/// +/// Panics only if the file shipped in this crate is malformed, which the +/// `pin_is_a_full_commit` test rejects before release. +pub fn pinned() -> Source { + toml::from_str::(PIN_TOML) + .expect("canon/pin.toml is well-formed") + .source +} + +/// True for a full 40-hex lowercase commit. +pub fn is_full_commit(s: &str) -> bool { + s.len() == 40 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pin_is_a_full_commit() { + let p = pinned(); + assert!( + is_full_commit(&p.commit), + "pin must be 40 hex: {}", + p.commit + ); + assert_eq!(p.repo, "hyperpolymath/pons-asinorum"); + } +} diff --git a/bots/ponsbot/src/scanner.rs b/bots/ponsbot/src/scanner.rs new file mode 100644 index 00000000..223c2d2f --- /dev/null +++ b/bots/ponsbot/src/scanner.rs @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Subprocess wrapper for the `pons` binary. +//! +//! Every path out of here is an [`Outcome`] that is `Ran`, `Error` or +//! `NotRun`. There is no "pass" outcome and no way to turn a failure into an +//! empty `Ran`: a report is accepted only if pons exited 0 or 1, the report +//! parses and is well-formed, and pons stamps the pinned commit. +//! +//! Exit-code mapping (design, section 4): +//! - exit 0 or 1 with a valid report: `Ran` (1 means pons' threshold was +//! reached; it is a finding, not a failure), +//! - exit 2 (invalid input, no applicable input, failed coverage): `Error` +//! carrying pons' own message, +//! - any other exit, a signal, a timeout, an unreadable report, a binary at a +//! different commit: `Error`, +//! - no binary: `NotRun`. +//! +//! pons runs with the scanned directory as its working directory and `.` as +//! the path; pons itself writes nothing (`scan` is read-only) and ponsbot adds +//! no exception mechanism: `.ponsignore` and `pons.toml` stay pons' business. +//! SPECULATIVE findings are not suppressed (no `--no-speculative`): they are +//! carried (owner ruling D1). + +use crate::translator::PonsFinding; +use serde_json::Value; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +/// What a scan amounted to. Never a bare pass. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + Ran(Vec), + Error(String), + NotRun(String), +} + +/// Environment variable naming an explicit pons binary. +pub const PONS_BIN_ENV: &str = "PONS_BIN"; + +/// Locate pons: `$PONS_BIN`, then PATH, then the locations the estate's +/// workflows install it to. Err carries the reason it was not found. +pub fn find_pons() -> Result { + if let Some(p) = std::env::var_os(PONS_BIN_ENV).filter(|p| !p.is_empty()) { + let p = PathBuf::from(p); + return if is_executable(&p) { + Ok(p) + } else { + Err(format!( + "{PONS_BIN_ENV} is set to {} but that is not an executable file", + p.display() + )) + }; + } + if let Some(path) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path) { + let cand = dir.join("pons"); + if is_executable(&cand) { + return Ok(cand); + } + } + } + if let Some(home) = std::env::var_os("HOME") { + for rel in [".pons/bin/pons", ".cargo/bin/pons"] { + let cand = Path::new(&home).join(rel); + if is_executable(&cand) { + return Ok(cand); + } + } + } + Err("pons is not installed (not on PATH, not at ~/.pons/bin/pons or ~/.cargo/bin/pons, PONS_BIN unset)".into()) +} + +fn is_executable(p: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + p.metadata() + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +/// Run pons on `dir`. `expected_commit` is the pin pons must report. +pub fn run_pons(dir: &Path, timeout: Duration, expected_commit: &str) -> Outcome { + if !dir.is_dir() { + return Outcome::Error(format!("{} is not a directory", dir.display())); + } + let bin = match find_pons() { + Ok(b) => b, + Err(reason) => return Outcome::NotRun(reason), + }; + let mut child = match Command::new(&bin) + .args(["scan", "--format", "json", "."]) + .current_dir(dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Outcome::NotRun(format!( + "pons binary {} vanished before it could run", + bin.display() + )) + } + Err(e) => return Outcome::Error(format!("could not start {}: {e}", bin.display())), + }; + // Drain both pipes on threads so a large report cannot fill a pipe and + // wedge the child before the deadline logic sees it. + let mut out_pipe = child.stdout.take().expect("piped"); + let mut err_pipe = child.stderr.take().expect("piped"); + let out_t = thread::spawn(move || { + let mut b = Vec::new(); + let _ = out_pipe.read_to_end(&mut b); + b + }); + let err_t = thread::spawn(move || { + let mut b = Vec::new(); + let _ = err_pipe.read_to_end(&mut b); + b + }); + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(s)) => break Ok(s), + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + break Err(()); + } + Ok(None) => thread::sleep(Duration::from_millis(20)), + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = out_t.join(); + let _ = err_t.join(); + return Outcome::Error(format!("could not wait for pons: {e}")); + } + } + }; + let stdout = String::from_utf8_lossy(&out_t.join().unwrap_or_default()).into_owned(); + let stderr = String::from_utf8_lossy(&err_t.join().unwrap_or_default()).into_owned(); + match status { + Err(()) => Outcome::Error(format!("pons did not finish within {}s", timeout.as_secs())), + Ok(s) => interpret(s.code(), &stdout, &stderr, expected_commit), + } +} + +/// First nonblank stderr line, at most 240 characters. +fn first_line(stderr: &str) -> String { + stderr + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(|l| l.chars().take(240).collect()) + .unwrap_or_else(|| "no diagnostic".into()) +} + +/// Map pons' exit status, stdout and stderr to an [`Outcome`]. Pure, so every +/// failure mode is unit-testable without a pons binary. +pub fn interpret(code: Option, stdout: &str, stderr: &str, expected_commit: &str) -> Outcome { + let code = match code { + Some(c) => c, + None => { + return Outcome::Error(format!( + "pons was terminated by a signal: {}", + first_line(stderr) + )) + } + }; + let report: Option = serde_json::from_str(stdout).ok(); + match code { + 0 | 1 => match validate(report.as_ref(), expected_commit) { + Ok(findings) => Outcome::Ran(findings), + Err(why) => Outcome::Error(format!("pons exited {code} without a valid report: {why}")), + }, + 2 => { + let coverage = report + .as_ref() + .and_then(|r| r.pointer("/scanned/coverage_status")) + .and_then(Value::as_str) + .map(|c| format!(" ({c})")) + .unwrap_or_default(); + Outcome::Error(format!("pons exited 2{coverage}: {}", first_line(stderr))) + } + n => Outcome::Error(format!("pons exited {n}: {}", first_line(stderr))), + } +} + +/// Validate a pons scan report and extract its findings. A report that is not +/// exactly what pons documents is an error, never an empty finding list. +fn validate(report: Option<&Value>, expected_commit: &str) -> Result, String> { + let r = report.ok_or("stdout is not JSON")?; + let tool = r + .get("tool") + .and_then(Value::as_object) + .ok_or("no tool block")?; + match tool.get("source_commit").and_then(Value::as_str) { + Some(c) if c == expected_commit => {} + Some(c) => { + return Err(format!("pons reports commit {c}, ponsbot pins {expected_commit}; refusing to report")) + } + None => { + return Err(format!("pons does not report its source commit, so the pin {expected_commit} cannot be verified")) + } + } + if tool.get("source_dirty").and_then(Value::as_str) == Some("true") { + return Err("pons was built from a dirty tree, so it is not the pinned commit".into()); + } + let arr = r + .get("findings") + .and_then(Value::as_array) + .ok_or("no findings array")?; + arr.iter().enumerate().map(|(i, f)| finding(i, f)).collect() +} + +fn finding(i: usize, f: &Value) -> Result { + let s = |k: &str| { + f.get(k) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("finding {i} has no string `{k}`")) + }; + let severity = s("severity")?.to_ascii_uppercase(); + if !matches!(severity.as_str(), "INFO" | "WARN" | "ERROR") { + return Err(format!("finding {i} has unknown severity {severity}")); + } + let loc = f + .get("location") + .ok_or_else(|| format!("finding {i} has no location"))?; + let file = loc + .get("file") + .and_then(Value::as_str) + .ok_or_else(|| format!("finding {i} has no location.file"))?; + let line = loc.get("line_start").and_then(Value::as_u64).unwrap_or(0); + let opt = |k: &str| f.get(k).and_then(Value::as_str).map(str::to_owned); + Ok(PonsFinding { + rule_id: s("rule_id")?, + severity, + tier: s("tier")?, + evidence: s("evidence")?.to_ascii_uppercase(), + file: file.strip_prefix("./").unwrap_or(file).to_owned(), + line, + message: s("message")?, + evidence_note: opt("evidence_note"), + counter_condition: opt("counter_condition"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PIN: &str = "bca1ea289a4f8a86bac6212731e6f8ec15c67ad4"; + + fn report(findings: &str) -> String { + format!( + r#"{{"tool":{{"name":"pons","source_commit":"{PIN}","source_dirty":"false"}},"findings":{findings}}}"# + ) + } + const ONE: &str = r#"[{"rule_id":"constant-condition","tier":"T0","evidence":"HEURISTIC","severity":"WARN","location":{"file":"./a.js","line_start":4},"message":"constant condition","evidence_note":"n"}]"#; + + #[test] + fn exit_0_with_findings_is_ran_and_keeps_tier_and_evidence() { + match interpret(Some(0), &report(ONE), "", PIN) { + Outcome::Ran(v) => { + assert_eq!(v.len(), 1); + assert_eq!( + (v[0].tier.as_str(), v[0].evidence.as_str()), + ("T0", "HEURISTIC") + ); + assert_eq!((v[0].file.as_str(), v[0].line), ("a.js", 4)); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn exit_1_with_findings_is_ran() { + assert!( + matches!(interpret(Some(1), &report(ONE), "", PIN), Outcome::Ran(v) if v.len() == 1) + ); + } + + #[test] + fn exit_2_is_error_with_pons_message() { + let o = interpret(Some(2), "", "pons: no such path: /x\n", PIN); + assert_eq!( + o, + Outcome::Error("pons exited 2: pons: no such path: /x".into()) + ); + } + + #[test] + fn exit_2_names_the_coverage_status_when_the_report_has_one() { + let out = r#"{"scanned":{"coverage_status":"no-applicable-input"}}"#; + match interpret(Some(2), out, "pons: scanned 0 files\n", PIN) { + Outcome::Error(r) => assert!( + r.contains("no-applicable-input") && r.contains("scanned 0 files"), + "{r}" + ), + o => panic!("{o:?}"), + } + } + + #[test] + fn exit_0_without_a_valid_report_is_error_not_empty_ran() { + for bad in [ + "", + "not json", + "{}", + "[]", + "42", + r#"{"tool":{},"findings":[]}"#, + ] { + assert!( + matches!(interpret(Some(0), bad, "", PIN), Outcome::Error(_)), + "{bad}" + ); + } + // a findings array that is missing is not an empty one + let no_findings = format!(r#"{{"tool":{{"source_commit":"{PIN}"}}}}"#); + assert!(matches!( + interpret(Some(0), &no_findings, "", PIN), + Outcome::Error(_) + )); + } + + #[test] + fn malformed_finding_is_error() { + let bad = r#"[{"rule_id":"x","tier":"T0","evidence":"HEURISTIC","severity":"LOUD","location":{"file":"a"},"message":"m"}]"#; + assert!( + matches!(interpret(Some(0), &report(bad), "", PIN), Outcome::Error(r) if r.contains("unknown severity")) + ); + let no_ev = r#"[{"rule_id":"x","tier":"T0","severity":"WARN","location":{"file":"a"},"message":"m"}]"#; + assert!( + matches!(interpret(Some(0), &report(no_ev), "", PIN), Outcome::Error(r) if r.contains("evidence")) + ); + } + + #[test] + fn wrong_or_missing_commit_is_error() { + let other = report("[]").replace(PIN, "0000000000000000000000000000000000000000"); + assert!( + matches!(interpret(Some(0), &other, "", PIN), Outcome::Error(r) if r.contains("refusing")) + ); + let none = r#"{"tool":{"name":"pons"},"findings":[]}"#; + assert!( + matches!(interpret(Some(0), none, "", PIN), Outcome::Error(r) if r.contains("cannot be verified")) + ); + let dirty = report("[]").replace("\"false\"", "\"true\""); + assert!( + matches!(interpret(Some(0), &dirty, "", PIN), Outcome::Error(r) if r.contains("dirty")) + ); + } + + #[test] + fn other_exits_and_signals_are_errors() { + assert!( + matches!(interpret(Some(101), &report("[]"), "panicked\n", PIN), Outcome::Error(r) if r.contains("101")) + ); + assert!(matches!(interpret(None, "", "", PIN), Outcome::Error(_))); + } + + #[test] + fn a_clean_valid_report_is_ran_with_no_findings() { + assert_eq!( + interpret(Some(0), &report("[]"), "", PIN), + Outcome::Ran(vec![]) + ); + } +} diff --git a/bots/ponsbot/src/translator.rs b/bots/ponsbot/src/translator.rs new file mode 100644 index 00000000..ffed1407 --- /dev/null +++ b/bots/ponsbot/src/translator.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Translate pons findings into the fleet's finding shape, carrying the tier +//! and the evidence class. +//! +//! Owner ruling D1 (2026-11-12): SPECULATIVE findings are carried, never +//! dropped, and the grid must never grade one as blocking. The grid reads only +//! `severity`, so a SPECULATIVE (or unrecognised) evidence class is capped at +//! `info` here; the original severity stays in `pons_severity`. Every other +//! pons `ERROR` keeps its blocking meaning (`error`), `WARN` is `warning`. +//! A heuristic is labelled as one in the message: it must never read as a +//! proof (pons ADR-0004). + +use serde::Serialize; + +/// A pons finding after validation (see `scanner`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PonsFinding { + pub rule_id: String, + pub severity: String, // INFO | WARN | ERROR, upper-cased + pub tier: String, + pub evidence: String, // HEURISTIC | DATAFLOW | PROTOCOL | SPECULATIVE (pons' words) + pub file: String, + pub line: u64, + pub message: String, + pub evidence_note: Option, + pub counter_condition: Option, +} + +/// One finding as printed on stdout. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct Finding { + pub rule_id: String, + /// Fleet word the grid grades: `error`, `warning` or `info`. + pub severity: String, + /// pons' own severity, unchanged (`ERROR`, `WARN`, `INFO`). + pub pons_severity: String, + pub tier: String, + pub evidence_class: String, + pub file: String, + pub line: u64, + pub message: String, + /// True only when the finding may block: pons `ERROR` with a graded class. + pub blocking: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence_note: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub counter_condition: Option, +} + +/// Evidence classes the grid may grade at their mapped severity. +fn is_graded_class(evidence: &str) -> bool { + matches!(evidence, "HEURISTIC" | "DATAFLOW" | "PROTOCOL") +} + +/// Translate one finding. Unknown evidence classes are treated like +/// SPECULATIVE (carried, capped at `info`): an unrecognised class must not be +/// able to block, and must not be dropped. +pub fn translate(f: &PonsFinding) -> Finding { + let graded = is_graded_class(&f.evidence); + let mapped = match f.severity.as_str() { + "ERROR" => "error", + "WARN" => "warning", + _ => "info", + }; + let severity = if graded { mapped } else { "info" }; + let label = match f.evidence.as_str() { + "HEURISTIC" => format!("{}, {}; a heuristic, not proof", f.tier, f.evidence), + "SPECULATIVE" => format!( + "{}, {}; carried, never graded as blocking", + f.tier, f.evidence + ), + e if !graded => format!( + "{}, {}; unrecognised evidence class, never graded as blocking", + f.tier, e + ), + _ => format!("{}, {}", f.tier, f.evidence), + }; + Finding { + rule_id: f.rule_id.clone(), + severity: severity.to_string(), + pons_severity: f.severity.clone(), + tier: f.tier.clone(), + evidence_class: f.evidence.clone(), + file: f.file.clone(), + line: f.line, + message: format!("{} ({})", f.message, label), + blocking: graded && severity == "error", + evidence_note: f.evidence_note.clone(), + counter_condition: f.counter_condition.clone(), + } +} + +pub fn translate_all(fs: &[PonsFinding]) -> Vec { + fs.iter().map(translate).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pf(sev: &str, ev: &str) -> PonsFinding { + PonsFinding { + rule_id: "r".into(), + severity: sev.into(), + tier: "T0".into(), + evidence: ev.into(), + file: "a.rs".into(), + line: 3, + message: "m".into(), + evidence_note: None, + counter_condition: None, + } + } + + #[test] + fn error_keeps_its_blocking_meaning() { + let f = translate(&pf("ERROR", "DATAFLOW")); + assert_eq!((f.severity.as_str(), f.blocking), ("error", true)); + } + + #[test] + fn warn_and_info_do_not_block() { + assert_eq!(translate(&pf("WARN", "HEURISTIC")).severity, "warning"); + assert_eq!(translate(&pf("INFO", "PROTOCOL")).severity, "info"); + assert!(!translate(&pf("WARN", "HEURISTIC")).blocking); + } + + #[test] + fn speculative_is_carried_never_blocking() { + let f = translate(&pf("ERROR", "SPECULATIVE")); + assert_eq!(f.severity, "info"); + assert_eq!(f.pons_severity, "ERROR"); + assert_eq!(f.evidence_class, "SPECULATIVE"); + assert!(!f.blocking); + assert!(f.message.contains("never graded as blocking")); + } + + #[test] + fn unknown_evidence_class_cannot_block() { + let f = translate(&pf("ERROR", "MYSTERY")); + assert_eq!((f.severity.as_str(), f.blocking), ("info", false)); + assert_eq!(f.evidence_class, "MYSTERY"); + } + + #[test] + fn heuristic_is_labelled_as_one() { + assert!(translate(&pf("WARN", "HEURISTIC")) + .message + .contains("a heuristic, not proof")); + } +} diff --git a/bots/ponsbot/tests/adapter_test.rs b/bots/ponsbot/tests/adapter_test.rs new file mode 100644 index 00000000..b18e4c77 --- /dev/null +++ b/bots/ponsbot/tests/adapter_test.rs @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MPL-2.0 +//! End-to-end tests of the `ponsbot` binary against a stub `pons` shell script. +//! +//! The stub prints canned reports in pons' documented shape (schema 0.4.0, +//! taken from a live run of pons at the pinned commit). Nothing here proves +//! pons' own analysis; `real_pons_*` runs the genuine binary when +//! `PONSBOT_REAL_PONS` names one, and says so when it skips. + +use serde_json::Value; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::TempDir; + +fn pin_commit() -> String { + ponsbot::pin::pinned().commit +} + +fn stub(dir: &Path, body: &str) -> PathBuf { + let p = dir.join("pons"); + fs::write(&p, format!("#!/usr/bin/env bash\n{body}\n")).unwrap(); + fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); + p +} + +fn report(findings: &str) -> String { + format!( + r#"{{"schema_version":"0.4.0","tool":{{"name":"pons","version":"0.1.0","source_commit":"{}","source_dirty":"false"}},"findings":{findings}}}"#, + pin_commit() + ) +} + +fn emit(json: &str, code: i32) -> String { + format!("cat <<'J'\n{json}\nJ\nexit {code}") +} + +fn ponsbot(pons: &Path, scan: &Path, extra: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ponsbot")) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("PONS_BIN", pons) + .args(["--format", "json", "scan"]) + .arg(scan) + .args(extra) + .output() + .unwrap() +} + +fn tree() -> TempDir { + let t = TempDir::new().unwrap(); + fs::write(t.path().join("a.js"), "if (true) {}\n").unwrap(); + t +} + +fn out_json(o: &Output) -> Value { + serde_json::from_slice(&o.stdout).unwrap_or_else(|e| { + panic!( + "stdout not JSON: {e}: {}", + String::from_utf8_lossy(&o.stdout) + ) + }) +} + +const WARN: &str = r#"{"rule_id":"constant-condition","tier":"T0","evidence":"HEURISTIC","severity":"WARN","location":{"file":"./a.js","line_start":1},"message":"constant condition","evidence_note":"`if (true)`"}"#; +const ERR: &str = r#"{"rule_id":"div-by-literal-zero","tier":"T1","evidence":"DATAFLOW","severity":"ERROR","location":{"file":"./a.js","line_start":2},"message":"division by zero"}"#; +const SPEC: &str = r#"{"rule_id":"spec-rule","tier":"T3","evidence":"SPECULATIVE","severity":"ERROR","location":{"file":"./a.js","line_start":3},"message":"maybe"}"#; + +#[test] +fn findings_are_printed_with_tier_and_evidence_class() { + let (bin, scan) = (TempDir::new().unwrap(), tree()); + let pons = stub(bin.path(), &emit(&report(&format!("[{WARN}]")), 0)); + let o = ponsbot(&pons, scan.path(), &[]); + assert_eq!(o.status.code(), Some(0)); + let v = out_json(&o); + let f = &v[0]; + assert_eq!(f["rule_id"], "constant-condition"); + assert_eq!(f["tier"], "T0"); + assert_eq!(f["evidence_class"], "HEURISTIC"); + assert_eq!(f["severity"], "warning"); + assert_eq!(f["file"], "a.js"); + assert_eq!(f["line"], 1); +} + +#[test] +fn a_blocking_finding_exits_1_but_is_still_a_findings_array() { + let (bin, scan) = (TempDir::new().unwrap(), tree()); + let pons = stub(bin.path(), &emit(&report(&format!("[{ERR}]")), 1)); + let o = ponsbot(&pons, scan.path(), &[]); + assert_eq!(o.status.code(), Some(1)); + assert_eq!(out_json(&o)[0]["severity"], "error"); + assert_eq!(out_json(&o)[0]["blocking"], true); +} + +#[test] +fn speculative_findings_are_carried_and_never_blocking() { + let (bin, scan) = (TempDir::new().unwrap(), tree()); + let pons = stub(bin.path(), &emit(&report(&format!("[{SPEC}]")), 0)); + let o = ponsbot(&pons, scan.path(), &[]); + assert_eq!( + o.status.code(), + Some(0), + "a speculative finding must not make ponsbot exit 1" + ); + let f = &out_json(&o)[0]; + assert_eq!(f["evidence_class"], "SPECULATIVE"); + assert_eq!(f["pons_severity"], "ERROR"); + assert_eq!(f["severity"], "info"); + assert_eq!(f["blocking"], false); +} + +#[test] +fn a_clean_scan_is_an_empty_array_and_exit_0() { + let (bin, scan) = (TempDir::new().unwrap(), tree()); + let pons = stub(bin.path(), &emit(&report("[]"), 0)); + let o = ponsbot(&pons, scan.path(), &[]); + assert_eq!( + (o.status.code(), out_json(&o)), + (Some(0), serde_json::json!([])) + ); +} + +#[test] +fn pons_exit_2_is_error_with_pons_message_never_a_pass() { + let (bin, scan) = (TempDir::new().unwrap(), tree()); + let pons = stub(bin.path(), "echo 'pons: no applicable input' >&2; exit 2"); + let o = ponsbot(&pons, scan.path(), &[]); + assert_eq!(o.status.code(), Some(2)); + let v = out_json(&o); + assert_eq!(v["status"], "error"); + assert!( + v["reason"] + .as_str() + .unwrap() + .contains("no applicable input"), + "{v}" + ); +} + +#[test] +fn missing_binary_is_not_run() { + let scan = tree(); + let home = TempDir::new().unwrap(); + let o = Command::new(env!("CARGO_BIN_EXE_ponsbot")) + .env_clear() + .env("PATH", "") + .env("HOME", home.path()) + .args(["--format", "json", "scan"]) + .arg(scan.path()) + .output() + .unwrap(); + assert_eq!(o.status.code(), Some(3)); + let v = out_json(&o); + assert_eq!(v["status"], "not_run"); + assert!(v["reason"].as_str().unwrap().contains("not installed")); +} + +#[test] +fn explicit_binary_that_does_not_exist_is_not_run() { + let scan = tree(); + let o = ponsbot(Path::new("/nonexistent/pons"), scan.path(), &[]); + assert_eq!(o.status.code(), Some(3)); + assert_eq!(out_json(&o)["status"], "not_run"); +} + +#[test] +fn timeout_is_error_not_a_hang_and_not_empty_ran() { + let (bin, scan) = (TempDir::new().unwrap(), tree()); + let pons = stub(bin.path(), "exec sleep 30"); + let t = std::time::Instant::now(); + let o = ponsbot(&pons, scan.path(), &["--timeout", "1"]); + assert!(t.elapsed().as_secs() < 10, "the timeout must be enforced"); + assert_eq!(o.status.code(), Some(2)); + assert!(out_json(&o)["reason"] + .as_str() + .unwrap() + .contains("did not finish within 1s")); +} + +#[test] +fn exit_0_without_a_valid_report_is_error() { + let (bin, scan) = (TempDir::new().unwrap(), tree()); + for body in [ + "exit 0", + "echo '{}'; exit 0", + "echo 'garbage'; exit 0", + "echo '[]'; exit 0", + ] { + let pons = stub(bin.path(), body); + let o = ponsbot(&pons, scan.path(), &[]); + assert_eq!(o.status.code(), Some(2), "{body}"); + assert_eq!(out_json(&o)["status"], "error", "{body}"); + } +} + +#[test] +fn a_pons_at_another_commit_is_refused() { + let (bin, scan) = (TempDir::new().unwrap(), tree()); + let other = report("[]").replace(&pin_commit(), "1111111111111111111111111111111111111111"); + let pons = stub(bin.path(), &emit(&other, 0)); + let o = ponsbot(&pons, scan.path(), &[]); + assert_eq!(o.status.code(), Some(2)); + assert!(out_json(&o)["reason"] + .as_str() + .unwrap() + .contains("refusing to report")); +} + +#[test] +fn a_directory_that_does_not_exist_is_error() { + let bin = TempDir::new().unwrap(); + let pons = stub(bin.path(), &emit(&report("[]"), 0)); + let o = ponsbot(&pons, Path::new("/nonexistent/dir"), &[]); + assert_eq!(o.status.code(), Some(2)); +} + +fn snapshot(dir: &Path) -> Vec<(String, Vec)> { + let mut v: Vec<_> = walk(dir) + .into_iter() + .map(|p| { + ( + p.strip_prefix(dir).unwrap().display().to_string(), + fs::read(&p).unwrap_or_default(), + ) + }) + .collect(); + v.sort(); + v +} +fn walk(d: &Path) -> Vec { + let mut out = vec![]; + for e in fs::read_dir(d).unwrap() { + let p = e.unwrap().path(); + out.push(p.clone()); + if p.is_dir() { + out.extend(walk(&p)); + } + } + out +} + +#[test] +fn the_scanned_tree_is_left_untouched_and_pons_runs_with_the_documented_args() { + let (bin, scan) = (TempDir::new().unwrap(), tree()); + let argsfile = bin.path().join("args"); + let body = format!( + "echo \"$*\" > '{}'; pwd -P >> '{}'\n{}", + argsfile.display(), + argsfile.display(), + emit(&report(&format!("[{WARN}]")), 0) + ); + let pons = stub(bin.path(), &body); + let before = snapshot(scan.path()); + let o = ponsbot(&pons, scan.path(), &[]); + assert_eq!(o.status.code(), Some(0)); + assert_eq!( + before, + snapshot(scan.path()), + "ponsbot wrote into the scanned tree" + ); + let args = fs::read_to_string(&argsfile).unwrap(); + let mut lines = args.lines(); + assert_eq!( + lines.next(), + Some("scan --format json ."), + "no exception flags and no --no-speculative" + ); + assert_eq!( + Path::new(lines.next().unwrap()), + scan.path().canonicalize().unwrap() + ); +} + +/// Runs only when `PONSBOT_REAL_PONS` names a pons built at the pinned commit. +#[test] +fn real_pons_end_to_end_when_available() { + let Some(real) = std::env::var_os("PONSBOT_REAL_PONS") else { + eprintln!("SKIP: PONSBOT_REAL_PONS unset; the real-binary path is unverified by this run"); + return; + }; + let scan = TempDir::new().unwrap(); + fs::write(scan.path().join("a.js"), "if (true) { f(); }\n").unwrap(); + let before = snapshot(scan.path()); + let o = ponsbot(Path::new(&real), scan.path(), &[]); + assert_eq!( + o.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&o.stderr) + ); + let v = out_json(&o); + assert_eq!(v[0]["rule_id"], "constant-condition"); + assert_eq!(v[0]["evidence_class"], "HEURISTIC"); + assert_eq!(v[0]["file"], "a.js"); + assert_eq!(before, snapshot(scan.path())); + // a tree with no analysable source: pons exits 2, which must be an error + let empty = TempDir::new().unwrap(); + let o = ponsbot(Path::new(&real), empty.path(), &[]); + assert_eq!(o.status.code(), Some(2)); + assert_eq!(out_json(&o)["status"], "error"); +} diff --git a/docs/REPORT-BACK.adoc b/docs/REPORT-BACK.adoc index 450fde15..e4d38205 100644 --- a/docs/REPORT-BACK.adoc +++ b/docs/REPORT-BACK.adoc @@ -37,12 +37,12 @@ adopts it. | `scripts/fleet-report-back-build.sh` | Builds the enrolled adapter crates (`cargo build --release --locked`) into one - directory and installs panic-attack at the pinned commit for panicbot. One + directory and installs panic-attack at the pinned commit for panicbot and, with `--pons`, pons at the commit in `bots/ponsbot/canon/pin.toml` for ponsbot. One recipe for the reusable workflow and for inline callers; a crate that fails to build is a warning here and an `error` row later, never a dead run. | `tests/fleet-report-back-test.sh` -| 48 checks over stubbed adapters whose JSON shapes were taken from live runs: +| Checks over stubbed adapters whose JSON shapes were taken from live runs (ponsbot's shape from a real pons at its pinned commit): enrolment rules, every adapter's translation, path relativisation, the failure modes, envelope shape, `--plan`, the summary, and (with `STANDARDS_DIR` set) the real renderer turning the rows non-orange. @@ -110,6 +110,15 @@ grading can match them against the diff. Hypatia lane, so a repository that enrols panicbot sees the same tool twice, once through the bot's categorisation. `fleet` mode is *not* used: it writes a `.panicbot/*.a2ml` debt register into the scanned tree, and A2ML is retired. + Advisory (owner ruling 2026-11-12): enrolments leave `gating` unset for it. A + scan that did not really run (panic-attack not installed, timed out, exited + non-zero, or exited 0 without a report that has `weak_points` and a positive + `statistics.total_lines`) exits non-zero with no findings array, which the + driver writes as `error` with the reason, never as an empty `ran`. + +| ponsbot +| `ponsbot --format json scan ` +| Wraps pons asinorum, run as a subprocess at the commit pinned in `bots/ponsbot/canon/pin.toml` (design: `docs/migrations/scanner-cutover-ponsbot-panicbot.adoc`). Prints a findings array only when pons really ran; otherwise `{"status": "error" or "not_run", "reason": ...}`, which the driver writes as that status. pons exit 2 is `error` with pons' message, a missing pons is `not_run`, a timeout, a bad report or a pons at another commit is `error`; nothing maps to a pass. Each finding keeps its `tier`, `evidence_class` and `pons_severity`; pons `ERROR` is `error`, `WARN` is `warning`, and a SPECULATIVE finding is carried at `info` so the grid never grades it as blocking. **Gating is the enrolment's, not the envelope's**: the grid reads a row's `gating` from `reporters..gating` in `.gitbot-fleet/enrolment.json`, so an enrolment must set `"gating": true` for ponsbot (pons gates, owner ruling 2026-11-12) and leave panicbot without it (advisory). Not yet enrolled anywhere, and the reusable workflow does not yet install pons, so until phase 1 an enrolled ponsbot reads `not_run: pons is not installed`. | seambot | `seambot --path --format json check` diff --git a/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc b/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc index 9422dc89..c94b3b07 100644 --- a/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc +++ b/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc @@ -4,18 +4,22 @@ :toclevels: 2 :revdate: 2026-11-12 -Status: **DESIGN ONLY.** Nothing in this document has been cut over. No -workflow, enrolment manifest, grid row or pin changes until the owner rules on -§8 and each phase lands as its own pull request. +Status: **DESIGN, with the owner's rulings of 2026-11-12 recorded (§8). Phase 0 +(the adapters) is the only phase started.** Nothing has been cut over: no +workflow, enrolment manifest, grid row or direct lane has changed. Each later +phase lands as its own pull request. == 1. Goal Retire the estate's *direct* invocations of the two deep scanners, `hyperpolymath/pons-asinorum` and `hyperpolymath/panic-attack`, so that each one reaches a pull request, a sweep or a gate only through its fleet adapter: -`panicbot` (exists) and `ponsbot` (does not exist yet). +`panicbot` (exists) and `ponsbot` (phase 0 adds it). -=== 1.1 What "retire" means here (reading to confirm, §8 D0) +The bots are estate tooling. External users do their own scanning for now; this +cutover is not a service offered to them. + +=== 1.1 What "retire" means here (decided, §8 D0, 2026-11-12) The scanners stay as **external engines**. The adapters call them as pinned subprocesses, which is how `bots/panicbot/src/scanner.rs` already uses @@ -24,9 +28,10 @@ link:../../bots/README.adoc[`bots/README.adoc`]: a bot slot must not absorb a standalone product. What retires is every *lane* that installs and runs a scanner binary itself and renders its own row, envelope or gate. -The other reading is that the scanner repositories themselves retire and their -analysis moves into the bots. That conflicts with the guardrail, so this design -does not take it without an explicit ruling. +The other reading, that the scanner repositories themselves retire and their +analysis moves into the bots, conflicts with the guardrail and was **not +taken**: the owner ruled on 2026-11-12 (D0) that pons-asinorum and panic-attack +stay as external engines and repositories, and only the direct CI lanes retire. === 1.2 Why @@ -74,7 +79,7 @@ does not take it without an explicit ruling. | standards `hypatia-scan-reusable.yml` ("Install panic-attack (pinned source build)", "Run panic-attack assail") | `assail` on the caller's tree, `panic-attack-findings.json` status envelope, **permanent** grid row -| ADVISORY by default; with `PA_GATES=true` (block-on-critical) critical findings gate. Scope excludes are counted separately (`counts_excluded`). A report must carry `weak_points` and a positive `statistics.total_lines`. +| ADVISORY by default; with `PA_GATES=true` (block-on-critical) critical findings gate. Scope excludes are counted separately (`counts_excluded`). (Current behaviour, retired in phase 2: §8 G2.) A report must carry `weak_points` and a positive `statistics.total_lines`. | standards `deep-recon-reusable.yml` | `assail`, REPORT-ONLY for findings @@ -119,9 +124,13 @@ does not take it without an explicit ruling. ponsbot apply, with a reason when not. "Permanent" stops being hard-wired. * Each scanner has **one** pin, held by its adapter (`bots//canon/pin.toml`) and read by every workflow that builds it. -* Gates that exist today (pons `error` in deep-recon; panic-attack - block-on-critical where a caller opts in) keep the same thresholds. They - read the adapter's output instead of the scanner's. +* **pons gates; panic-attack does not** (§8 G1, G2). ponsbot's row is a gating + row: pons `error` keeps its blocking meaning, now read from ponsbot's row + instead of the scanner's output. panicbot's row is advisory, so + panic-attack's findings never block a merge through the fleet. The grid reads + a row's gating from the enrolment's per-reporter `gating` boolean (standards + `scripts/lib/verdict-grid.jq`), not from the envelope: enrolments set + `"gating": true` for ponsbot and leave panicbot without it. * Hypatia's estate rescan and bridge gate are decided separately (§8 D4); they are not PR lanes. @@ -136,14 +145,20 @@ A thin crate in `bots/ponsbot/`, the same shape as panicbot: scanned tree. That is the report-back driver's contract for a new member. * `bots/ponsbot/canon/pin.toml`: pons commit and sha256 of pons' published rule table, if it has one. Same re-pin procedure as panicbot. -* **Evidence class is carried, not dropped.** Each finding keeps pons' tier - and evidence class. SPECULATIVE findings are omitted, matching the hard - gate's `--no-speculative`, or carried with an explicit class that the - grid never grades as blocking. Whichever is chosen, a heuristic must never - read as a proof (pons ADR-0004). +* **Evidence class is carried, not dropped** (decided, §8 D1, 2026-11-12). Each + finding keeps pons' tier and evidence class. SPECULATIVE findings are + carried, never dropped, with an explicit class the grid never grades as + blocking: ponsbot does not pass `--no-speculative`, and caps a SPECULATIVE + (or unrecognised) class at severity `info` while keeping pons' own severity in + `pons_severity`. A heuristic never reads as a proof (pons ADR-0004): its + message says so. * **Exit codes map to report-back status, never to a pass.** pons exit 1 with findings → `ran`. Exit 2 (invalid input, no applicable input, config error) → `error` with pons' message as `reason`. A missing binary → `not_run`. + The adapter prints a findings array only when pons really ran; otherwise it + prints `{"status": "error"|"not_run", "reason": "..."}` and exits 2 or 3. It + also refuses a pons binary whose own `tool.source_commit` is not the pin, or is + absent, as `error`. * `.ponsignore` and `pons.toml` stay pons' business. The adapter passes the repository root and adds no exception mechanism of its own. * Driver integration: one `case` arm in `scripts/fleet-report-back.sh`, one stub @@ -158,27 +173,34 @@ A thin crate in `bots/ponsbot/`, the same shape as panicbot: * Make `bots/panicbot/canon/pin.toml` the single panic-attack pin. Today it pins only the rule-ID table; it gains the build commit, and the workflows read it - instead of their own `PANIC_ATTACK_SHA` or `PA_SHA`. + instead of their own `PANIC_ATTACK_SHA` or `PA_SHA`. *Phase 0:* the `[build]` + table is added (value and provenance in the file; workflows do not read it + yet, see §8 D7). * Parity with the direct lane's envelope. The direct lane distinguishes "not installed", "timed out", "exited non-zero" and "exited 0 without a valid report", and checks `weak_points` and a positive `statistics.total_lines`. panicbot `scan` must surface each of these as `error` or `not_run` with a - reason, never as an empty `ran`. + reason, never as an empty `ran`. *Phase 0:* tests added in + `bots/panicbot/tests/envelope_parity_test.rs` and the driver tests. They found + three cases that read as `ran` or hung, now fixed at the source in + `bots/panicbot/src/scanner.rs`: the timeout was never enforced (a hung scan + hung), a non-zero exit with parseable stdout was accepted, and a report with + no `statistics.total_lines` (nothing analysed) was an empty `ran`. * Scope excludes: the direct lane counts excluded findings separately (`counts_excluded`). Decide whether the adapter reports them or the grid's scope filter alone handles them. * Fix or retire `panicbot-sweep.yml`'s install line (see §2.2). -== 6. Phases (each is its own PR; none started) +== 6. Phases (each is its own PR; only phase 0 is started) [cols="1,3,2,2"] |=== | Phase | Change | Repo | Exit criterion -| 0 +| 0 (landed in the PR that records the §8 rulings) | Add the ponsbot adapter and tests; add the build commit to panicbot's pin; add the envelope-parity cases to panicbot | gitbot-fleet -| Adapter tests green; `tests/fleet-report-back-test.sh` covers both members +| Adapter tests green; `tests/fleet-report-back-test.sh` covers both members. Not done in phase 0: no enrolment, no grid row removed, no workflow change; `BotId::Ponsbot` exists but is not in `all()` (fleet dispatch is phase 4) | 1 (shadow) | Enrol panicbot and ponsbot in standards **alongside** the permanent rows, for a fixed number of PRs; compare counts row by row @@ -186,12 +208,12 @@ A thin crate in `bots/ponsbot/`, the same shape as panicbot: | Same findings, same statuses, for the agreed sample; differences explained | 2 (grid flip) -| Remove the panic-attack and pons permanent rows from `verdict-grid.sh` and its jq library and tests; delete the direct install/run steps and `panic-ref`/`pons-ref` inputs from `hypatia-scan-reusable.yml`; keep the `PA_GATES` block-on-critical behaviour, now reading panicbot's row +| Remove the panic-attack and pons permanent rows from `verdict-grid.sh` and its jq library and tests; delete the direct install/run steps and `panic-ref`/`pons-ref` inputs from `hypatia-scan-reusable.yml`; retire the `PA_GATES` block-on-critical behaviour (panic-attack does not gate, §8 G2; panicbot's row is advisory); ponsbot's row is gating (§8 G1) | standards | Grid shows panicbot and ponsbot rows only; callers that passed `panic-ref`/`pons-ref` are updated in the same wave (§7) | 3 (deep gate) -| Point `deep-recon-reusable.yml` at the adapters; keep pons' `error` hard gate and SARIF upload, and panic-attack's must-run check +| Point `deep-recon-reusable.yml` at the adapters; keep pons' `error` hard gate and SARIF upload; panic-attack's must-run check is open (D6) | standards | A deliberately broken fixture still fails the gate; an empty scan still fails @@ -215,19 +237,38 @@ A thin crate in `bots/ponsbot/`, the same shape as panicbot: * **Silent green.** The main hazard is an adapter that runs nothing and reports an empty `ran`. The parity cases in phases 0 and 3 exist to catch this. * **Gate drift.** Fleet rows grade by normalised severity - (`error` → high, `warning` → medium). Check that pons' `error` and - panic-attack's `critical` keep their blocking meaning through that - normalisation before phase 2. + (`error` → high, `warning` → medium). pons' `error` must keep its blocking + meaning through that normalisation and through the enrolment's + `"gating": true` (checked by `tests/fleet-report-back-test.sh` against the + real renderer when `STANDARDS_DIR` is set; re-check before phase 2). +* **Callers that opted into block-on-critical.** Under §8 G2 panic-attack no + longer blocks, so a caller that set `block-on-critical` loses that block when + phase 2 lands. Deprecate the input (accept, warn, ignore) a cycle before + removing it, and say so in that phase's PR. * **Rollback.** Phases 2 and 3 are reverts of their own PRs. Phase 1 is additive. Keep the deleted steps' last commit SHA in each PR body. -== 8. Decisions for the owner (open) +== 8. Decisions + +=== 8.1 Decided (owner rulings, 2026-11-12) + +D0. **Decided 2026-11-12.** Scanners remain external engines behind adapters; + only the direct CI lanes retire. The adapters call pons-asinorum and + panic-attack as pinned subprocesses. -D0. Confirm the reading in §1.1: scanners remain external engines behind - adapters; only the direct lanes retire. +D1. **Decided 2026-11-12.** SPECULATIVE pons findings are carried, with an + explicit evidence class that the grid never grades as blocking. They are + never dropped and never read as proof. -D1. SPECULATIVE pons findings in ponsbot: omit them, as deep-recon does, or - carry them with an evidence class the grid never treats as blocking? +G1. **Decided 2026-11-12.** pons gates: ponsbot's row is gating, and pons + `error` keeps its blocking meaning. (Label G1 is this document's own.) + +G2. **Decided 2026-11-12.** panic-attack does not gate: panicbot is advisory. + This overrules the earlier phase-2 note that kept the `PA_GATES` + block-on-critical behaviour on panicbot's row. (Label G2 is this + document's own.) + +=== 8.2 Open D2. Shadow-period length for phase 1 (number of PRs or days) and the acceptable difference between the direct and adapter rows. @@ -242,3 +283,18 @@ D4. Hypatia's estate rescan (verisim-data feed) and bridge gate D5. `panicbot-sweep.yml` installs from `hyperpolymath/panic-attacker.git`. Is that a stale name for `panic-attack`, or a separate repository? + +D6. The deep-recon lane fails the job when panic-attack crashes or writes an + empty report (a tool-failure check, not a findings gate). With panicbot + advisory (§8 G2), does that must-run check survive phase 3, or does a + panic-attack failure become an advisory `error` row only? + +D7. Pins disagree today. panic-attack: standards' Hypatia lane and + `fork-governance.yml` run `410a8f82`, gitbot-fleet's + `fleet-report-back-reusable.yml` builds `b689a74d` (its comment says it + equals the Hypatia pin; it does not) and deep-recon defaults `b5530834`. + pons: the Hypatia lane runs `bca1ea28`, deep-recon defaults the older + `51b8920f`. Phase 0 records `410a8f82` and `bca1ea28` (the Hypatia lane's + values) in `bots/panicbot/canon/pin.toml` `[build]` and + `bots/ponsbot/canon/pin.toml`. Confirm those are the pins to converge on + before phase 1 makes the workflows read the files. diff --git a/scripts/fleet-report-back-build.sh b/scripts/fleet-report-back-build.sh index 790c6ad1..3de67a1d 100755 --- a/scripts/fleet-report-back-build.sh +++ b/scripts/fleet-report-back-build.sh @@ -20,7 +20,7 @@ # # Usage: # fleet-report-back-build.sh --fleet DIR --bin-dir DIR [--crates "a b c"] -# [--panic-attack SHA] [--build-timeout SECONDS] +# [--panic-attack SHA] [--pons SHA] [--build-timeout SECONDS] # [--build-budget SECONDS] # # --fleet a gitbot-fleet checkout (bots//Cargo.toml) @@ -28,6 +28,8 @@ # --crates crate directory names under bots/, space-separated; # default: read `fleet-report-back.sh --plan` output on stdin # --panic-attack also install panic-attack at this commit into --bin-dir +# --pons also install pons (pons-cli) at this commit into --bin-dir, +# for ponsbot; the commit is bots/ponsbot/canon/pin.toml # --build-timeout seconds per cargo invocation (default 900) # --build-budget seconds for every build together (default 2400). A crate # whose turn comes after the budget is spent is not built @@ -38,20 +40,21 @@ set -euo pipefail -fleet="" bindir="" crates="" pa_sha="" build_timeout=900 build_budget=2400 +fleet="" bindir="" crates="" pa_sha="" pons_sha="" build_timeout=900 build_budget=2400 while [ $# -gt 0 ]; do case "$1" in --fleet) fleet="$2"; shift 2 ;; --bin-dir) bindir="$2"; shift 2 ;; --crates) crates="$2"; shift 2 ;; --panic-attack) pa_sha="$2"; shift 2 ;; + --pons) pons_sha="$2"; shift 2 ;; --build-timeout) build_timeout="$2"; shift 2 ;; --build-budget) build_budget="$2"; shift 2 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac done if [ -z "$fleet" ] || [ -z "$bindir" ]; then - echo "usage: $0 --fleet DIR --bin-dir DIR [--crates \"a b\"] [--panic-attack SHA]" >&2; exit 2 + echo "usage: $0 --fleet DIR --bin-dir DIR [--crates \"a b\"] [--panic-attack SHA] [--pons SHA]" >&2; exit 2 fi [ -d "$fleet/bots" ] || { echo "not a gitbot-fleet checkout: $fleet" >&2; exit 2; } mkdir -p "$bindir"; bindir="$(cd "$bindir" && pwd)" @@ -114,6 +117,26 @@ if [ -n "$pa_sha" ]; then endgroup fi +if [ -n "$pons_sha" ]; then + if ! [[ "$pons_sha" =~ ^[0-9a-f]{40}$ ]]; then + warn "pons not installed" "$(printf '%q' "$pons_sha") is not a 40-hex commit; ponsbot will read not_run" + else + root="$HOME/.pons"; bin="$root/bin/pons" + group "pons at $pons_sha" + if [ -x "$bin" ] && grep -qF "#$pons_sha" "$root/.crates.toml" 2>/dev/null; then + echo "pons restored from cache at $pons_sha" + elif [ "$(remaining)" -eq 0 ]; then + warn "pons not installed" "the ${build_budget}s build budget was spent before its turn; ponsbot will read not_run" + elif ! timeout "$(slice 600)" cargo install --git https://github.com/hyperpolymath/pons-asinorum \ + --rev "$pons_sha" --locked --force --root "$root" pons-cli; then + rm -rf "$root" + warn "pons not installed" "source build at $pons_sha failed or exceeded 10 minutes; ponsbot will read not_run" + fi + if [ -x "$bin" ]; then cp "$bin" "$bindir/"; fi + endgroup + fi +fi + echo "fleet-report-back-build: $built crate(s) built, $failed failed; binaries in $bindir:" find "$bindir" -maxdepth 1 -type f -printf ' %f\n' | sort exit 0 diff --git a/scripts/fleet-report-back.sh b/scripts/fleet-report-back.sh index 60290a26..24350c57 100755 --- a/scripts/fleet-report-back.sh +++ b/scripts/fleet-report-back.sh @@ -91,6 +91,7 @@ crate_dir_of() { case "$1" in rhodibot) echo rhodibot ;; panicbot) echo panicbot ;; + ponsbot) echo ponsbot ;; seambot) echo seambot ;; finishingbot|finishbot|finishing-bot) echo finishingbot ;; glambot) echo glambot ;; @@ -176,7 +177,7 @@ EOF binary_of() { case "$1" in finishingbot|finishbot|finishing-bot) echo finishing-bot ;; - rhodibot|panicbot|seambot|glambot|accessibilitybot|cipherbot) echo "$1" ;; + rhodibot|panicbot|ponsbot|seambot|glambot|accessibilitybot|cipherbot) echo "$1" ;; *) echo "" ;; esac } @@ -185,6 +186,7 @@ default_title_of() { case "$1" in rhodibot) echo "rhodibot (RSR compliance)" ;; panicbot) echo "panicbot (panic recovery, error handling)" ;; + ponsbot) echo "ponsbot (pons asinorum static analysis)" ;; echidnabot) echo "echidnabot (formal verification)" ;; seambot) echo "seambot (integration health)" ;; finishingbot) echo "finishingbot (release readiness)" ;; @@ -384,6 +386,11 @@ tool_version_of() { fi echo "panic-attack:unknown" ;; + ponsbot) + # ponsbot wraps pons asinorum; the commit is the adapter's own pin. + version=$(grep -m1 '^commit = ' "$FLEET_DIR/bots/ponsbot/canon/pin.toml" 2>/dev/null | sed -E 's/.*"([0-9a-f]+)".*/\1/' | cut -c1-12) + echo "pons:${version:-unknown}" + ;; echidnabot) # echidnabot uses ECHIDNA engine if [ -n "${ECHIDNA_URL:-}" ]; then @@ -441,6 +448,7 @@ write_envelope() { # member status reason findings_json elif .rule_id | startswith("PA8") then "panic-attack:unchecked-error" elif .rule_id | startswith("PA9") then "panic-attack:path-traversal" else "panic-attack:unknown" end) + elif $member == "ponsbot" then "pons:unclassified" elif $member == "rhodibot" then "rsr:file-presence" elif $member == "cipherbot" then "cipherbot:crypto-hygiene" elif $member == "glambot" or $member == "accessibilitybot" then "wcag:accessibility" @@ -536,6 +544,32 @@ FINDING='{rule_id: (.rule_id // .id // "?" | tostring), severity: (.severity // file: ((.file // .location // "") | rel), line: ((.line // 0) | if type == "number" then . else 0 end), message: (.message // "" | tostring)}' +# ponsbot prints either a findings array (the scan ran) or, when it did not, +# {"status": "error"|"not_run", "reason": "..."} (pons failed, timed out, was +# at another commit, or is not installed). The object form is the ONLY way a +# non-ran status reaches the grid from this adapter; anything else that is not +# an array falls through to translate_or_error and becomes an error. Nothing +# maps to a pass: a status the adapter did not state is never invented here. +# Each finding keeps pons' tier and evidence class (D1: SPECULATIVE findings are +# carried, capped at severity `info` by ponsbot, so the grid never grades them +# as blocking); the grid reads only severity, so the extra fields are provenance. +translate_ponsbot() { # member + local m="$1" st reason + if [ "$ADAPTER_RC" -ne 124 ] \ + && jq -e 'type == "object" and (.status | IN("error", "not_run")) and (.reason | type == "string" and length > 0)' "$ADAPTER_OUT" >/dev/null 2>&1; then + st=$(jq -r .status "$ADAPTER_OUT"); reason=$(jq -r '.reason[0:240]' "$ADAPTER_OUT") + write_envelope "$m" "$st" "$reason" '[]' + cleanup_adapter + return + fi + translate_or_error "$m" "$SHAPE_ARRAY"' + [ .[] | {rule_id: (.rule_id // "?" | tostring), severity: (.severity // "unknown" | tostring), + file: ((.file // "") | rel), line: ((.line // 0) | if type == "number" then . else 0 end), + message: (.message // "" | tostring), + tier: (.tier // "unknown" | tostring), evidence_class: (.evidence_class // "unknown" | tostring), + pons_severity: (.pons_severity // "unknown" | tostring)} ]' +} + # Run one member against repo and write its translated report-back envelope. # echidnabot and sustainabot yield not_run, even with ECHIDNA_URL configured; # unknown members, unavailable binaries, timeouts, and unusable output yield @@ -589,6 +623,14 @@ run_member() { # into the scanned tree; A2ML is retired, so it is not used.) run_adapter "$bin" --format json scan "$repo" translate_or_error "$m" "$SHAPE_ARRAY [ .[] | $FINDING ]" ;; + ponsbot) + # `scan` runs pons at the pinned commit (bots/ponsbot/canon/pin.toml) and + # writes nothing into the tree. Gating is NOT decided here: the grid reads + # it from the enrolment's per-reporter `gating` boolean, which must be + # true for ponsbot (owner ruling 2026-11-12) and is left unset for + # panicbot, which is advisory. + run_adapter "$bin" --format json scan "$repo" + translate_ponsbot "$m" ;; cipherbot) run_adapter "$bin" --format json scan "$repo" translate_or_error "$m" "$SHAPE_ARRAY [ .[] | $FINDING ]" ;; diff --git a/shared-context/src/bot.rs b/shared-context/src/bot.rs index 6ba007cc..2e8af72b 100644 --- a/shared-context/src/bot.rs +++ b/shared-context/src/bot.rs @@ -30,6 +30,12 @@ pub enum BotId { Cipherbot, /// Targeted audit bot wrapping panic-attack static analysis Panicbot, + /// Thin adapter running pons asinorum at a pinned commit (`bots/ponsbot`). + /// Deliberately NOT in `all()` yet: it has the report-back `scan` mode only + /// (no `fleet` dispatch), so registering it would make the coordinator wait + /// on a verifier it cannot run (cutover design phase 4). Like `Oikosbot`, + /// it is excluded from `all()` on purpose. + Ponsbot, /// External ecological/economic code-analysis App (OikosBot, /// `hyperpolymath/oikosbot`) that publishes findings via the optional /// `oikosbot-fleet` bridge. NOT the reserved `Sustainabot` fleet slot and @@ -54,6 +60,7 @@ impl fmt::Display for BotId { BotId::Accessibilitybot => write!(f, "accessibilitybot"), BotId::Cipherbot => write!(f, "cipherbot"), BotId::Panicbot => write!(f, "panicbot"), + BotId::Ponsbot => write!(f, "ponsbot"), BotId::Oikosbot => write!(f, "oikosbot"), BotId::Custom(id) => write!(f, "custom-{}", id), } @@ -68,7 +75,8 @@ impl BotId { | BotId::Echidnabot | BotId::Sustainabot | BotId::Oikosbot - | BotId::Panicbot => Tier::Verifier, + | BotId::Panicbot + | BotId::Ponsbot => Tier::Verifier, BotId::Glambot | BotId::Seambot | BotId::Finishbot | BotId::Accessibilitybot => { Tier::Finisher } @@ -112,6 +120,7 @@ impl BotId { "accessibilitybot" | "accessibility-bot" => Some(BotId::Accessibilitybot), "cipherbot" | "cipher-bot" => Some(BotId::Cipherbot), "panicbot" | "panic-bot" => Some(BotId::Panicbot), + "ponsbot" | "pons-bot" => Some(BotId::Ponsbot), "oikosbot" | "oikos-bot" => Some(BotId::Oikosbot), _ => None, } @@ -368,6 +377,15 @@ impl BotInfo { can_fix: false, depends_on: vec![BotId::Rhodibot], }, + BotId::Ponsbot => Self { + id, + name: "Ponsbot".to_string(), + description: "Thin adapter running pons asinorum at a pinned commit".to_string(), + version: "0.1.0".to_string(), + categories: vec!["static-analysis/pons".to_string()], + can_fix: false, + depends_on: vec![BotId::Rhodibot], + }, BotId::Oikosbot => Self { id, name: "OikosBot".to_string(), diff --git a/shared-context/tests/ponsbot_test.rs b/shared-context/tests/ponsbot_test.rs new file mode 100644 index 00000000..209900e2 --- /dev/null +++ b/shared-context/tests/ponsbot_test.rs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MPL-2.0 +//! `BotId::Ponsbot` is known to the fleet but not yet dispatched by it. + +use gitbot_shared_context::{BotId, Tier}; + +#[test] +fn ponsbot_round_trips_through_its_name() { + assert_eq!(BotId::Ponsbot.to_string(), "ponsbot"); + assert_eq!(BotId::from_str("ponsbot"), Some(BotId::Ponsbot)); + assert_eq!(BotId::from_str("Pons-Bot"), Some(BotId::Ponsbot)); + assert_eq!(BotId::Ponsbot.tier(), Tier::Verifier); +} + +#[test] +fn ponsbot_is_deliberately_not_in_all() { + // The adapter has the report-back `scan` mode only; registering it in all() + // would make verifier-tier completion wait on a bot the coordinator cannot + // run. Add it here and to all() together with its fleet dispatch. + assert!(!BotId::all().contains(&BotId::Ponsbot)); + assert!(!Tier::Verifier.bots().contains(&BotId::Ponsbot)); +} diff --git a/tests/fleet-report-back-build-test.sh b/tests/fleet-report-back-build-test.sh index fe895d84..65ab6c6d 100755 --- a/tests/fleet-report-back-build-test.sh +++ b/tests/fleet-report-back-build-test.sh @@ -221,6 +221,50 @@ check "valid crate names may contain uppercase letters, underscores, digits and check "invalid crate tokens count as failures while the valid build completes" yes \ "$(grep -q '1 crate(s) built, 4 failed' "$TMP/crate-names.log" && echo yes || echo no)" + +# --- pons for ponsbot: pinned install, cached by commit, never fatal ----------- +PSHA=bca1ea289a4f8a86bac6212731e6f8ec15c67ad4 +PH="$TMP/pons-home"; mkdir -p "$PH" "$TMP/pons-tools" +cat > "$TMP/pons-tools/cargo" <<'STUB' +#!/usr/bin/env bash +# Records the call; on `install --root R ... pons-cli` fakes the installed binary. +set -euo pipefail +printf '%s\n' "$*" >> "$FLEET_PONS_CALLS" +[[ "$1" == install ]] || exit 99 +[[ -z "${FLEET_PONS_FAIL:-}" ]] || exit 1 +root=""; rev="" +while [ $# -gt 0 ]; do case "$1" in --root) root="$2"; shift 2 ;; --rev) rev="$2"; shift 2 ;; *) shift ;; esac; done +mkdir -p "$root/bin"; printf '#!/usr/bin/env bash\necho pons\n' > "$root/bin/pons"; chmod +x "$root/bin/pons" +printf '%s\n' "git+https://github.com/hyperpolymath/pons-asinorum?rev=$rev#$rev" > "$root/.crates.toml" +STUB +cp "$TOOLS/timeout" "$TMP/pons-tools/timeout"; sed -i '/slow/d' "$TMP/pons-tools/timeout" +chmod +x "$TMP/pons-tools/cargo" "$TMP/pons-tools/timeout" +export FLEET_PONS_CALLS="$TMP/pons-calls" +run_pons_build() { # label args... ; own HOME so ~/.pons is a scratch cache + local label="$1"; shift; : > "$FLEET_PONS_CALLS"; rc=0 + HOME="$PH" PATH="$TMP/pons-tools:$PATH" GITHUB_ACTIONS='' bash "$BUILDER" \ + --fleet "$FLEET" --bin-dir "$TMP/$label" --crates "" "$@" < "$TMP/plan" > "$TMP/$label.log" 2>&1 || rc=$? +} +run_pons_build pons-ok --pons "$PSHA" +check "pons: installed from the pinned commit with --locked into a private root" \ + "install --git https://github.com/hyperpolymath/pons-asinorum --rev $PSHA --locked --force --root $PH/.pons pons-cli" \ + "$(cat "$FLEET_PONS_CALLS")" +check "pons: the binary is copied next to the adapters" pons "$(files "$TMP/pons-ok")" +run_pons_build pons-cached --pons "$PSHA" +check "pons: a cache hit at the same commit does not reinstall" 0 "$(grep -c . "$FLEET_PONS_CALLS" || true)" +check "pons: ...and still provides the binary" pons "$(files "$TMP/pons-cached")" +rm -rf "$PH/.pons" +FLEET_PONS_FAIL=1 run_pons_build pons-fail --pons "$PSHA" +check "pons: a failed install is a warning, not a failed run" 0 "$rc" +check "pons: ...it says ponsbot will read not_run" yes "$(grep -q 'ponsbot will read not_run' "$TMP/pons-fail.log" && echo yes || echo no)" +check "pons: ...and leaves no binary and no cache behind" "no no" \ + "$([ -n "$(files "$TMP/pons-fail")" ] && echo yes || echo no) $([ -e "$PH/.pons" ] && echo yes || echo no)" +run_pons_build pons-badsha --pons 'not-a-commit; rm -rf /' +check "pons: a non-commit value never reaches cargo" 0 "$(grep -c . "$FLEET_PONS_CALLS" || true)" +check "pons: ...and is warned about" yes "$(grep -q 'not a 40-hex commit' "$TMP/pons-badsha.log" && echo yes || echo no)" +run_pons_build pons-budget --pons "$PSHA" --build-budget 0 +check "pons: a spent build budget skips the install with a warning" "0 yes" \ + "$(grep -c . "$FLEET_PONS_CALLS" || true) $(grep -q 'spent before its turn' "$TMP/pons-budget.log" && echo yes || echo no)" echo echo "fleet-report-back-build-test: $pass passed, $fail failed" [ "$fail" -eq 0 ] diff --git a/tests/fleet-report-back-test.sh b/tests/fleet-report-back-test.sh index 6c035fb1..aba109e3 100755 --- a/tests/fleet-report-back-test.sh +++ b/tests/fleet-report-back-test.sh @@ -30,6 +30,8 @@ # 9. empty scans, malformed output, normalization boundaries, aliases, # failure isolation, invocation context, and an unchanged scanned tree. # 10. the build-helper unit suite (stubbed Cargo, no Rust builds or network). +# 11. ponsbot: translation, status mapping (never a pass), SPECULATIVE carried at +# info, gating read from the enrolment; panicbot envelope-parity failure modes. # # Run: bash tests/fleet-report-back-test.sh @@ -463,6 +465,139 @@ check "...and leaves no file beside the output directory" no "$([ -e "$TMP/escap check "usage errors create no output directory" no "$([ -e "$TMP/unused" ] && echo yes || echo no)" check "reporting never changes the scanned tree's files or directories" "$repo_before" "$(repo_state)" +# 10. ponsbot (pons asinorum) and the panicbot envelope-parity cases ─────────── +# Stubs print ponsbot's documented output: a findings array when pons ran, or +# {"status": "error"|"not_run", "reason": ...} when it did not. The adapter is +# tested on its own in bots/ponsbot/tests; this section tests the DRIVER's side. +PB="$TMP/pbin"; mkdir -p "$PB" +pstub() { printf '#!/usr/bin/env bash\nset -u\n%s\n' "$2" > "$PB/$1"; chmod +x "$PB/$1"; } +cat > "$TMP/pons-enrol.json" <<'JSON' +{ "schema": 1, + "reporters": { + "ponsbot": { "enrolled": true, "gating": true }, + "panicbot": { "enrolled": true } } } +JSON +runp() { # out-dir extra-args... + bash "$DRIVER" --repo "$REPO" --out "$1" --bin-dir "$PB" --fleet-ref deadbeef --enrolment "$TMP/pons-enrol.json" "${@:2}" > "$1.log" 2>&1 +} +ARGS_OK='[ "$1 $2 $3" = "--format json scan" ] || { echo "bad args: $*" >&2; exit 9; }' +pstub ponsbot "$ARGS_OK"' +cat <&2; exit 1'; do + pstub ponsbot "$ARGS_OK"$'\n'"$body" + runp "$TMP/p5" --member ponsbot + check "ponsbot: output [$body] is error, never ran" error "$(field "$TMP/p5/ponsbot.json" .status)" + check "ponsbot: ...with a reason" yes "$(field "$TMP/p5/ponsbot.json" '(.reason | type == "string") and (.reason | length) > 0' | sed 's/true/yes/;s/false/no/')" +done + +pstub ponsbot 'sleep 5; echo "[]"' +runp "$TMP/p6" --member ponsbot --timeout 1 +check "ponsbot: a timeout is error" error "$(field "$TMP/p6/ponsbot.json" .status)" +runp "$TMP/p7" --member ponsbot --bin-dir "$TMP/nowhere" +check "ponsbot: a missing adapter binary is error naming bots/ponsbot" yes "$(field "$TMP/p7/ponsbot.json" .reason | grep -q "ponsbot.*bots/ponsbot" && echo yes || echo no)" +check "--plan lists ponsbot with its crate and binary" $'ponsbot\tponsbot\tponsbot' "$(bash "$DRIVER" --plan --enrolment "" --member ponsbot)" +check "ponsbot has a built-in title and runs only when enrolled" "ponsbot" \ + "$(bash "$DRIVER" --plan --enrolment "$TMP/pons-enrol.json" | cut -f1 | sort | tr '\n' ' ' | sed 's/panicbot //;s/ $//')" + +# panicbot parity (design section 5): each way a panic-attack scan can fail to +# be one reads as error with a reason, never as an empty `ran`. +PAN='[ "$1 $2 $3" = "--format json scan" ] || { echo "bad args: $*" >&2; exit 9; }' +while IFS='|' read -r label body; do + pstub panicbot "$PAN"$'\n'"$body" + runp "$TMP/pp" --member panicbot + check "panicbot parity: $label is error" error "$(field "$TMP/pp/panicbot.json" .status)" + check "panicbot parity: $label carries a reason" yes "$(field "$TMP/pp/panicbot.json" '(.reason | type == "string") and (.reason | length) > 0' | sed 's/true/yes/;s/false/no/')" + check "panicbot parity: $label has no findings" 0 "$(field "$TMP/pp/panicbot.json" '.findings | length')" +done <<'CASES' +panic-attack not installed|echo "Error: Failed to run panic-attack assail: panic-attack binary not found" >&2; exit 1 +panic-attack timed out|echo "Error: panic-attack did not finish within 300s (timed out and killed)" >&2; exit 1 +panic-attack exited non-zero|echo "Error: panic-attack assail exited with exit status: 3" >&2; exit 1 +exit 0 with no output|exit 0 +exit 0 with an empty object|echo "{}" +exit 0 with a non-array|echo "{\"weak_points\":[]}" +CASES + +# 11. gating comes from the enrolment, never from the envelope. ─────────────── +GRID="${STANDARDS_DIR:-}/scripts/verdict-grid.sh" +if [ -n "${STANDARDS_DIR:-}" ] && [ -f "$GRID" ]; then + pstub ponsbot "$ARGS_OK"' +cat </dev/null ) + prow() { jq -r --arg id "$1" "[.reporters[] | select(.id == \$id) | $2][0]" "$TMP/pgrid/report.json"; } + check "renderer: ponsbot's row is gating when the enrolment says gating:true" true "$(prow ponsbot .gating)" + check "renderer: panicbot's row is advisory when the enrolment is silent" false "$(prow panicbot .gating)" + check "renderer: ponsbot's carried pons ERROR (DATAFLOW) is graded high" 1 "$(prow ponsbot .counts.high)" + check "renderer: ponsbot's error finding is a red row" red "$(prow ponsbot .state)" + # the same envelope with the enrolment's gating removed is advisory: the flag is the enrolment's alone + jq 'del(.reporters.ponsbot.gating)' "$TMP/pons-enrol.json" > "$TMP/pons-enrol-nogate.json" + ( cd "$REPO" && bash "$GRID" render --out "$TMP/pgrid2" --hypatia /dev/null --panic /dev/null --pons /dev/null \ + --report-backs "$TMP/pg" --enrolment "$TMP/pons-enrol-nogate.json" --sha 0000000 >/dev/null ) + check "renderer: without gating:true in the enrolment ponsbot would be advisory" false \ + "$(jq -r '[.reporters[] | select(.id == "ponsbot") | .gating][0]' "$TMP/pgrid2/report.json")" + # a SPECULATIVE-only run: carried, graded info (minor), not blocking + pstub ponsbot "$ARGS_OK"' +cat </dev/null ) + check "renderer: a SPECULATIVE-only ponsbot row is blue, not red" blue \ + "$(jq -r '[.reporters[] | select(.id == "ponsbot") | .state][0]' "$TMP/pgrid3/report.json")" + check "renderer: ...and counts as no high or critical finding" 0 \ + "$(jq -r '[.reporters[] | select(.id == "ponsbot") | .counts.high + .counts.critical][0]' "$TMP/pgrid3/report.json")" +else + echo "SKIP: STANDARDS_DIR not set; ponsbot gating/evidence renderer round-trip not run" +fi +check "ponsbot and panicbot runs never change the scanned tree" "$repo_before" "$(repo_state)" + # 8. the real renderer ───────────────────────────────────────────────────────── GRID="${STANDARDS_DIR:-}/scripts/verdict-grid.sh" if [ -n "${STANDARDS_DIR:-}" ] && [ -f "$GRID" ]; then diff --git a/tests/panic-attack-rule-ids-test.sh b/tests/panic-attack-rule-ids-test.sh index 53df080d..e91783ae 100755 --- a/tests/panic-attack-rule-ids-test.sh +++ b/tests/panic-attack-rule-ids-test.sh @@ -59,7 +59,7 @@ done < <(grep -nE '\bPA[0-9]{3}b?\b' "$SCRIPTS"/fix-*.sh || true) echo "checked $checked PA reference(s) in $(basename "$SCRIPTS")/fix-*.sh" if [[ "${1:-}" == "--upstream" ]]; then - repo=$(sed -n 's/^repo = "\(.*\)"$/\1/p' "$CANON/pin.toml") + repo=$(sed -n 's/^repo = "\(.*\)"$/\1/p' "$CANON/pin.toml" | head -n1) path=$(sed -n 's/^path = "\(.*\)"$/\1/p' "$CANON/pin.toml") upstream=$(curl --proto '=https' --proto-redir '=https' -sSfL --max-time 30 "https://raw.githubusercontent.com/$repo/main/$path" | sha256sum | cut -d' ' -f1) [[ "$upstream" == "$pinned" ]] || err "panic-attack main's table hashes $upstream; re-pin (see pin.toml)" From d43423c7232e139d9abe50b62cb09b144ff195a4 Mon Sep 17 00:00:00 2001 From: CLAUDE <355856132+HoTT-stuff@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:56:13 +0000 Subject: [PATCH 2/8] fix(ponsbot,panicbot): propagate errors instead of expect() on hot paths; shell hygiene Hypatia code_safety/expect_in_hot_path (bots/ponsbot/src/scanner.rs, pin.rs, bots/panicbot/src/scanner.rs) failed governance / Validate Hypatia Baseline. Pipe capture and pin parsing now return an error outcome instead of panicking. Also addresses SonarCloud shell findings (S7688, S7679) in touched scripts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkERqsQWMogHRpM2z8 --- bots/panicbot/src/scanner.rs | 8 ++++++-- bots/ponsbot/src/main.rs | 12 +++++++++++- bots/ponsbot/src/pin.rs | 16 ++++++++-------- bots/ponsbot/src/scanner.rs | 8 ++++++-- bots/ponsbot/tests/adapter_test.rs | 2 +- scripts/fleet-report-back-build.sh | 8 ++++---- scripts/fleet-report-back.sh | 7 ++++--- tests/fleet-report-back-build-test.sh | 2 +- tests/fleet-report-back-test.sh | 7 ++++--- 9 files changed, 45 insertions(+), 25 deletions(-) diff --git a/bots/panicbot/src/scanner.rs b/bots/panicbot/src/scanner.rs index 39d4a2a8..9bc0883a 100644 --- a/bots/panicbot/src/scanner.rs +++ b/bots/panicbot/src/scanner.rs @@ -479,8 +479,12 @@ fn run_panic_attack_raw(args: &[&str], timeout: Duration) -> Result { // Drain both pipes on threads so a large report cannot fill a pipe and // block the child while this thread waits on the deadline. - let mut out_pipe = child.stdout.take().expect("stdout is piped"); - let mut err_pipe = child.stderr.take().expect("stderr is piped"); + let (Some(mut out_pipe), Some(mut err_pipe)) = (child.stdout.take(), child.stderr.take()) + else { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("panic-attack stdout/stderr pipes were not captured"); + }; let out_t = std::thread::spawn(move || { let mut b = Vec::new(); let _ = out_pipe.read_to_end(&mut b); diff --git a/bots/ponsbot/src/main.rs b/bots/ponsbot/src/main.rs index 79a81b45..588485cb 100644 --- a/bots/ponsbot/src/main.rs +++ b/bots/ponsbot/src/main.rs @@ -19,7 +19,17 @@ use std::time::Duration; fn main() -> ExitCode { let cli = Cli::parse(); let Command::Scan { dir, timeout } = cli.command; - let pin = pin::pinned(); + let pin = match pin::pinned() { + Ok(p) => p, + Err(e) => { + return report_not_ran( + "error", + &format!("canon/pin.toml is malformed: {e}"), + cli.format, + 2, + ) + } + }; match run_pons(&dir, Duration::from_secs(timeout.max(1)), &pin.commit) { Outcome::Ran(found) => { let findings = translator::translate_all(&found); diff --git a/bots/ponsbot/src/pin.rs b/bots/ponsbot/src/pin.rs index ddb7765a..af8204de 100644 --- a/bots/ponsbot/src/pin.rs +++ b/bots/ponsbot/src/pin.rs @@ -20,12 +20,11 @@ pub struct Source { /// The pin, parsed from the compiled-in `canon/pin.toml`. /// -/// Panics only if the file shipped in this crate is malformed, which the -/// `pin_is_a_full_commit` test rejects before release. -pub fn pinned() -> Source { - toml::from_str::(PIN_TOML) - .expect("canon/pin.toml is well-formed") - .source +/// Fails only if the file shipped in this crate is malformed, which the +/// `pin_is_a_full_commit` test rejects before release; the caller reports +/// that as an error rather than panicking. +pub fn pinned() -> Result { + toml::from_str::(PIN_TOML).map(|f| f.source) } /// True for a full 40-hex lowercase commit. @@ -38,13 +37,14 @@ mod tests { use super::*; #[test] - fn pin_is_a_full_commit() { - let p = pinned(); + fn pin_is_a_full_commit() -> Result<(), toml::de::Error> { + let p = pinned()?; assert!( is_full_commit(&p.commit), "pin must be 40 hex: {}", p.commit ); assert_eq!(p.repo, "hyperpolymath/pons-asinorum"); + Ok(()) } } diff --git a/bots/ponsbot/src/scanner.rs b/bots/ponsbot/src/scanner.rs index 223c2d2f..bd84b006 100644 --- a/bots/ponsbot/src/scanner.rs +++ b/bots/ponsbot/src/scanner.rs @@ -108,8 +108,12 @@ pub fn run_pons(dir: &Path, timeout: Duration, expected_commit: &str) -> Outcome }; // Drain both pipes on threads so a large report cannot fill a pipe and // wedge the child before the deadline logic sees it. - let mut out_pipe = child.stdout.take().expect("piped"); - let mut err_pipe = child.stderr.take().expect("piped"); + let (Some(mut out_pipe), Some(mut err_pipe)) = (child.stdout.take(), child.stderr.take()) + else { + let _ = child.kill(); + let _ = child.wait(); + return Outcome::Error("pons pipes were not captured".to_string()); + }; let out_t = thread::spawn(move || { let mut b = Vec::new(); let _ = out_pipe.read_to_end(&mut b); diff --git a/bots/ponsbot/tests/adapter_test.rs b/bots/ponsbot/tests/adapter_test.rs index b18e4c77..5657db3a 100644 --- a/bots/ponsbot/tests/adapter_test.rs +++ b/bots/ponsbot/tests/adapter_test.rs @@ -14,7 +14,7 @@ use std::process::{Command, Output}; use tempfile::TempDir; fn pin_commit() -> String { - ponsbot::pin::pinned().commit + ponsbot::pin::pinned().map(|p| p.commit).unwrap_or_default() } fn stub(dir: &Path, body: &str) -> PathBuf { diff --git a/scripts/fleet-report-back-build.sh b/scripts/fleet-report-back-build.sh index 3de67a1d..f5610dfc 100755 --- a/scripts/fleet-report-back-build.sh +++ b/scripts/fleet-report-back-build.sh @@ -117,22 +117,22 @@ if [ -n "$pa_sha" ]; then endgroup fi -if [ -n "$pons_sha" ]; then +if [[ -n "$pons_sha" ]]; then if ! [[ "$pons_sha" =~ ^[0-9a-f]{40}$ ]]; then warn "pons not installed" "$(printf '%q' "$pons_sha") is not a 40-hex commit; ponsbot will read not_run" else root="$HOME/.pons"; bin="$root/bin/pons" group "pons at $pons_sha" - if [ -x "$bin" ] && grep -qF "#$pons_sha" "$root/.crates.toml" 2>/dev/null; then + if [[ -x "$bin" ]] && grep -qF "#$pons_sha" "$root/.crates.toml" 2>/dev/null; then echo "pons restored from cache at $pons_sha" - elif [ "$(remaining)" -eq 0 ]; then + elif [[ "$(remaining)" -eq 0 ]]; then warn "pons not installed" "the ${build_budget}s build budget was spent before its turn; ponsbot will read not_run" elif ! timeout "$(slice 600)" cargo install --git https://github.com/hyperpolymath/pons-asinorum \ --rev "$pons_sha" --locked --force --root "$root" pons-cli; then rm -rf "$root" warn "pons not installed" "source build at $pons_sha failed or exceeded 10 minutes; ponsbot will read not_run" fi - if [ -x "$bin" ]; then cp "$bin" "$bindir/"; fi + if [[ -x "$bin" ]]; then cp "$bin" "$bindir/"; fi endgroup fi fi diff --git a/scripts/fleet-report-back.sh b/scripts/fleet-report-back.sh index 24350c57..de5a61fe 100755 --- a/scripts/fleet-report-back.sh +++ b/scripts/fleet-report-back.sh @@ -175,9 +175,10 @@ EOF # Print the adapter executable name for a member, or an empty line if unknown # or unsupported locally. The finishingbot aliases all use finishing-bot. binary_of() { - case "$1" in + local member="$1" + case "$member" in finishingbot|finishbot|finishing-bot) echo finishing-bot ;; - rhodibot|panicbot|ponsbot|seambot|glambot|accessibilitybot|cipherbot) echo "$1" ;; + rhodibot|panicbot|ponsbot|seambot|glambot|accessibilitybot|cipherbot) echo "$member" ;; *) echo "" ;; esac } @@ -555,7 +556,7 @@ FINDING='{rule_id: (.rule_id // .id // "?" | tostring), severity: (.severity // # as blocking); the grid reads only severity, so the extra fields are provenance. translate_ponsbot() { # member local m="$1" st reason - if [ "$ADAPTER_RC" -ne 124 ] \ + if [[ "$ADAPTER_RC" -ne 124 ]] \ && jq -e 'type == "object" and (.status | IN("error", "not_run")) and (.reason | type == "string" and length > 0)' "$ADAPTER_OUT" >/dev/null 2>&1; then st=$(jq -r .status "$ADAPTER_OUT"); reason=$(jq -r '.reason[0:240]' "$ADAPTER_OUT") write_envelope "$m" "$st" "$reason" '[]' diff --git a/tests/fleet-report-back-build-test.sh b/tests/fleet-report-back-build-test.sh index 65ab6c6d..c8c1d45d 100755 --- a/tests/fleet-report-back-build-test.sh +++ b/tests/fleet-report-back-build-test.sh @@ -258,7 +258,7 @@ FLEET_PONS_FAIL=1 run_pons_build pons-fail --pons "$PSHA" check "pons: a failed install is a warning, not a failed run" 0 "$rc" check "pons: ...it says ponsbot will read not_run" yes "$(grep -q 'ponsbot will read not_run' "$TMP/pons-fail.log" && echo yes || echo no)" check "pons: ...and leaves no binary and no cache behind" "no no" \ - "$([ -n "$(files "$TMP/pons-fail")" ] && echo yes || echo no) $([ -e "$PH/.pons" ] && echo yes || echo no)" + "$([[ -n "$(files "$TMP/pons-fail")" ]] && echo yes || echo no) $([[ -e "$PH/.pons" ]] && echo yes || echo no)" run_pons_build pons-badsha --pons 'not-a-commit; rm -rf /' check "pons: a non-commit value never reaches cargo" 0 "$(grep -c . "$FLEET_PONS_CALLS" || true)" check "pons: ...and is warned about" yes "$(grep -q 'not a 40-hex commit' "$TMP/pons-badsha.log" && echo yes || echo no)" diff --git a/tests/fleet-report-back-test.sh b/tests/fleet-report-back-test.sh index aba109e3..5203273f 100755 --- a/tests/fleet-report-back-test.sh +++ b/tests/fleet-report-back-test.sh @@ -470,7 +470,7 @@ check "reporting never changes the scanned tree's files or directories" "$repo_b # {"status": "error"|"not_run", "reason": ...} when it did not. The adapter is # tested on its own in bots/ponsbot/tests; this section tests the DRIVER's side. PB="$TMP/pbin"; mkdir -p "$PB" -pstub() { printf '#!/usr/bin/env bash\nset -u\n%s\n' "$2" > "$PB/$1"; chmod +x "$PB/$1"; } +pstub() { local name="$1" body="$2"; printf '#!/usr/bin/env bash\nset -u\n%s\n' "$body" > "$PB/$name"; chmod +x "$PB/$name"; } cat > "$TMP/pons-enrol.json" <<'JSON' { "schema": 1, "reporters": { @@ -478,7 +478,8 @@ cat > "$TMP/pons-enrol.json" <<'JSON' "panicbot": { "enrolled": true } } } JSON runp() { # out-dir extra-args... - bash "$DRIVER" --repo "$REPO" --out "$1" --bin-dir "$PB" --fleet-ref deadbeef --enrolment "$TMP/pons-enrol.json" "${@:2}" > "$1.log" 2>&1 + local out="$1"; shift + bash "$DRIVER" --repo "$REPO" --out "$out" --bin-dir "$PB" --fleet-ref deadbeef --enrolment "$TMP/pons-enrol.json" "$@" > "$out.log" 2>&1 } ARGS_OK='[ "$1 $2 $3" = "--format json scan" ] || { echo "bad args: $*" >&2; exit 9; }' pstub ponsbot "$ARGS_OK"' @@ -570,7 +571,7 @@ echo "[]"' runp "$TMP/pg" --enrolment "$TMP/pons-enrol.json" ( cd "$REPO" && bash "$GRID" render --out "$TMP/pgrid" --hypatia /dev/null --panic /dev/null --pons /dev/null \ --report-backs "$TMP/pg" --enrolment "$TMP/pons-enrol.json" --sha 0000000 >/dev/null ) - prow() { jq -r --arg id "$1" "[.reporters[] | select(.id == \$id) | $2][0]" "$TMP/pgrid/report.json"; } + prow() { local id="$1" expr="$2"; jq -r --arg id "$id" "[.reporters[] | select(.id == \$id) | $expr][0]" "$TMP/pgrid/report.json"; } check "renderer: ponsbot's row is gating when the enrolment says gating:true" true "$(prow ponsbot .gating)" check "renderer: panicbot's row is advisory when the enrolment is silent" false "$(prow panicbot .gating)" check "renderer: ponsbot's carried pons ERROR (DATAFLOW) is graded high" 1 "$(prow ponsbot .counts.high)" From 2f8b2a44eaa971a2bf9ed6715ff5706886276d52 Mon Sep 17 00:00:00 2001 From: CLAUDE <355856132+HoTT-stuff@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:00:54 +0000 Subject: [PATCH 3/8] test(report-back): use [[ ]] in the renderer-gating guard Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkERqsQWMogHRpM2z8 --- tests/fleet-report-back-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fleet-report-back-test.sh b/tests/fleet-report-back-test.sh index 5203273f..e36ac549 100755 --- a/tests/fleet-report-back-test.sh +++ b/tests/fleet-report-back-test.sh @@ -560,7 +560,7 @@ CASES # 11. gating comes from the enrolment, never from the envelope. ─────────────── GRID="${STANDARDS_DIR:-}/scripts/verdict-grid.sh" -if [ -n "${STANDARDS_DIR:-}" ] && [ -f "$GRID" ]; then +if [[ -n "${STANDARDS_DIR:-}" ]] && [[ -f "$GRID" ]]; then pstub ponsbot "$ARGS_OK"' cat < Date: Fri, 13 Nov 2026 10:41:22 +0000 Subject: [PATCH 4/8] docs(cutover): record owner rulings D4-D7 (2026-11-13) D4: estate rescan and bridge gate are sanctioned direct uses, reading panicbot's pin. D5: panic-attacker.git is a stale name; move to panic-attack. D6: the deep-recon must-run check stays. D7: converge on the Hypatia lane's pins (410a8f82, bca1ea28). D2 and D3 remain open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkERqsQWMogHRpM2z8 --- .../scanner-cutover-ponsbot-panicbot.adoc | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc b/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc index c94b3b07..3ff508c0 100644 --- a/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc +++ b/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc @@ -2,10 +2,11 @@ = Scanner cutover: pons-asinorum and panic-attack → ponsbot and panicbot :toc: left :toclevels: 2 -:revdate: 2026-11-12 +:revdate: 2026-11-13 -Status: **DESIGN, with the owner's rulings of 2026-11-12 recorded (§8). Phase 0 -(the adapters) is the only phase started.** Nothing has been cut over: no +Status: **DESIGN, with the owner's rulings of 2026-11-12 and 2026-11-13 +recorded (§8). Phase 0 (the adapters) is the only phase started.** Nothing +has been cut over: no workflow, enrolment manifest, grid row or direct lane has changed. Each later phase lands as its own pull request. @@ -213,7 +214,7 @@ A thin crate in `bots/ponsbot/`, the same shape as panicbot: | Grid shows panicbot and ponsbot rows only; callers that passed `panic-ref`/`pons-ref` are updated in the same wave (§7) | 3 (deep gate) -| Point `deep-recon-reusable.yml` at the adapters; keep pons' `error` hard gate and SARIF upload; panic-attack's must-run check is open (D6) +| Point `deep-recon-reusable.yml` at the adapters; keep pons' `error` hard gate and SARIF upload; keep panic-attack's must-run check (D6, decided 2026-11-13) | standards | A deliberately broken fixture still fails the gate; an empty scan still fails @@ -223,7 +224,7 @@ A thin crate in `bots/ponsbot/`, the same shape as panicbot: | No `panic-attack` invocation remains outside `bots/panicbot/` | 5 (Hypatia) -| Per the D4 ruling: either move estate-rescan to `panicbot` with a store-shaped output, or record it as a sanctioned direct use +| Per the D4 ruling (2026-11-13): record estate-rescan and the bridge gate as sanctioned direct uses, reading panicbot's pin | hypatia | Ruling recorded; the verisim-data shape is unchanged either way |=== @@ -268,7 +269,31 @@ G2. **Decided 2026-11-12.** panic-attack does not gate: panicbot is advisory. block-on-critical behaviour on panicbot's row. (Label G2 is this document's own.) -=== 8.2 Open +=== 8.2 Decided (owner rulings, 2026-11-13) + +D4. **Decided 2026-11-13.** Hypatia's estate rescan (verisim-data feed) and + the bridge gate (`panic-attack bridge triage`) are **sanctioned direct + uses**, consistent with D0: bridge triage is the CVE-mitigation + lifecycle, not lane scanning. Both stay outside panicbot, and both read + panic-attack from panicbot's single pin (D7). + +D5. **Decided 2026-11-13.** `hyperpolymath/panic-attacker.git` in + `panicbot-sweep.yml` is a stale name from before the 2026-02-08 rename. + The sweep moves to `hyperpolymath/panic-attack`, read through panicbot's + single pin, never unpinned. + +D6. **Decided 2026-11-13.** The deep-recon must-run check **stays** through + phase 3. Advisory status (G2) governs findings, not a scanner that did not + run: a panic-attack crash or empty report still fails the job (fail + loudly, no silent green). + +D7. **Decided 2026-11-13.** Converge on the Hypatia lane's pins: panic-attack + `410a8f82`, pons `bca1ea28`, the values phase 0 already records in + `bots/panicbot/canon/pin.toml` `[build]` and `bots/ponsbot/canon/pin.toml`. + `fleet-report-back-reusable.yml` (`b689a74d`) and deep-recon (`b5530834`, + `51b8920f`) move to them in phase 1. Later bumps change those files only. + +=== 8.3 Open D2. Shadow-period length for phase 1 (number of PRs or days) and the acceptable difference between the direct and adapter rows. @@ -276,25 +301,3 @@ D2. Shadow-period length for phase 1 (number of PRs or days) and the D3. Does `hooks/pre-push-gate.sh`'s default (`panic-attack hypatia`) become `panicbot ponsbot hypatia`, or does pons stay out of the local pre-push gate because of its build cost? - -D4. Hypatia's estate rescan (verisim-data feed) and bridge gate - (`panic-attack bridge triage`): in scope for this cutover, or sanctioned - direct uses? panicbot has no `bridge` subcommand today. - -D5. `panicbot-sweep.yml` installs from `hyperpolymath/panic-attacker.git`. - Is that a stale name for `panic-attack`, or a separate repository? - -D6. The deep-recon lane fails the job when panic-attack crashes or writes an - empty report (a tool-failure check, not a findings gate). With panicbot - advisory (§8 G2), does that must-run check survive phase 3, or does a - panic-attack failure become an advisory `error` row only? - -D7. Pins disagree today. panic-attack: standards' Hypatia lane and - `fork-governance.yml` run `410a8f82`, gitbot-fleet's - `fleet-report-back-reusable.yml` builds `b689a74d` (its comment says it - equals the Hypatia pin; it does not) and deep-recon defaults `b5530834`. - pons: the Hypatia lane runs `bca1ea28`, deep-recon defaults the older - `51b8920f`. Phase 0 records `410a8f82` and `bca1ea28` (the Hypatia lane's - values) in `bots/panicbot/canon/pin.toml` `[build]` and - `bots/ponsbot/canon/pin.toml`. Confirm those are the pins to converge on - before phase 1 makes the workflows read the files. From 54a27eebecdc46751d774d692097fb2072f08cc9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:13:55 +0000 Subject: [PATCH 5/8] feat(panicbot): run the full panic-attack suite unless the directives say otherwise (#671) Stacks on #670. Owner ruling 2026-11-13: panicbot ran `assail` only; it now runs every class (a) panic-attack subcommand unless the repo's bot directives say otherwise. ## What changed - `bots/panicbot/src/suite.rs`: inventory table of every panic-attack subcommand (class a / b / c, with reasons) and the suite runner. Each part ends `ran`, `not_applicable` (with reason), `denied` or `error`; nothing is silently skipped. - Ran by default: `assail`, `assay`, `diagnostics` (preflight only). Dynamic `assault`/`ambush` run only with an explicit `--target` executable inside the scanned dir, else `not_applicable`. `adjudicate` runs only over dynamic reports. `attack`, `amuck`, `abduct`, `axial` report `not_applicable` with reasons (no file/exec source). - Directives: no file means the full suite. A file keeps exactly what it allows and denies (no `allow` key keeps the old static trio; `deny` narrows, deny wins). A directive file that fails to parse now stops the run instead of falling back to the (now full) default. - Security gate: dynamic modes refused under `pull_request_target` / `workflow_run`. The checked workflows (`fleet-report-back-reusable.yml`, standards `fork-governance.yml` fleet job) run on `pull_request` with `contents: read` and no secrets, and none passes a target. - Findings name files: `PB-ASSAY-*` (hit file), `PB-DYN-*` and `PB-ADJUDICATE-VERDICT` (the target). Gating stays advisory (G2). - Failure reporting: a failed part prints an error object and exits 3; a run that analysed nothing prints `not_run` and exits 4. The driver maps both to envelope statuses; evidence classes added for `PB-` ids. - Budget: 240 s whole-suite budget under the driver's 300 s limit; panic-attack runs in a scratch cwd so scans do not write into the repo. - Fixes found on the way: `adjudicate` prints `pass (processed 0, failed 1)` and exits 0 for a missing or unrecognised report, which panicbot now treats as an error; the `diagnostics` `--version` call had no timeout. ## Held back / for the owner - `bridge` is class (a) in kind but held out per D4 (bridge gate stays outside panicbot). - No deed clause invented for per-bot directives; the legacy `panicbot.a2ml` is read-only as before. ## Checks `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` in `bots/panicbot`; `tests/fleet-report-back-test.sh` (213 passed) and `tests/fleet-report-back-build-test.sh` (65 passed) with `STANDARDS_DIR` set. --- _Generated by [Claude Code](https://claude.ai/code/session_0136P7ZkERqsQWMogHRpM2z8)_ Co-authored-by: CLAUDE <355856132+HoTT-stuff@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- bots/panicbot/src/cli.rs | 21 +- bots/panicbot/src/config.rs | 284 +++- bots/panicbot/src/directives.rs | 227 +++- bots/panicbot/src/fleet.rs | 89 +- bots/panicbot/src/lib.rs | 1 + bots/panicbot/src/main.rs | 109 +- bots/panicbot/src/scanner.rs | 453 ++++++- bots/panicbot/src/suite.rs | 1279 +++++++++++++++++++ bots/panicbot/tests/envelope_parity_test.rs | 161 ++- docs/REPORT-BACK.adoc | 18 +- scripts/fleet-report-back.sh | 35 +- tests/fleet-report-back-test.sh | 37 + 12 files changed, 2604 insertions(+), 110 deletions(-) create mode 100644 bots/panicbot/src/suite.rs diff --git a/bots/panicbot/src/cli.rs b/bots/panicbot/src/cli.rs index 5594da96..822b8f3c 100644 --- a/bots/panicbot/src/cli.rs +++ b/bots/panicbot/src/cli.rs @@ -2,7 +2,7 @@ //! CLI Interface — clap-based command-line argument parsing for panicbot. //! //! Subcommands: -//! - `scan ` — Run panic-attack assail and translate findings +//! - `scan ` — Run the allowed panic-attack suite and translate findings //! - `fleet ` — Run as a gitbot-fleet member //! - `report ` — Generate A2ML debt register @@ -49,11 +49,24 @@ pub enum OutputFormat { /// Available subcommands. #[derive(Subcommand, Debug)] pub enum Command { - /// Run panic-attack assail on a directory and translate findings. + /// Run the allowed panic-attack suite on a directory and translate findings. + /// + /// Full suite by default; the repository's bot directives can narrow it. + /// Exits 3 if any part failed (findings of the parts that ran are still + /// printed) and 4 if no part analysed anything. Scan { /// Directory or binary to scan. #[arg(default_value = ".")] dir: PathBuf, + + /// A built executable inside DIR for the dynamic modes (assault, ambush). + /// They EXECUTE it. Without it they report not_applicable. + #[arg(long)] + target: Option, + + /// Wall-clock budget for the whole suite, in seconds (default 240). + #[arg(long)] + budget_seconds: Option, }, /// Run as a gitbot-fleet member. @@ -65,6 +78,10 @@ pub enum Command { /// Bot operating mode (advisor, auditor, guardian). #[arg(short, long, default_value = "advisor")] mode: String, + + /// A built executable inside DIR for the dynamic modes. They EXECUTE it. + #[arg(long)] + target: Option, }, /// Generate A2ML debt register for a directory. diff --git a/bots/panicbot/src/config.rs b/bots/panicbot/src/config.rs index 98f684b4..4c10dc7f 100644 --- a/bots/panicbot/src/config.rs +++ b/bots/panicbot/src/config.rs @@ -12,7 +12,7 @@ use std::time::Duration; /// Top-level panicbot configuration. /// /// Controls which `panic-attack` subcommands run, severity thresholds, -/// and subprocess timeout behaviour. +/// and subprocess timeout and budget behaviour. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PanicbotConfig { /// Which panic-attack subcommands are allowed to execute. @@ -25,25 +25,207 @@ pub struct PanicbotConfig { /// Maximum wall-clock time for a single `panic-attack` invocation. pub timeout: Duration, + /// Wall-clock budget for the WHOLE suite. Each invocation runs for at most + /// the smaller of `timeout` and what is left of the budget; a part that + /// cannot start inside the budget reports `error`, never a silent skip. + /// Kept under the report-back driver's 300 s per-adapter limit. + pub budget: Duration, + + /// Things the directive loader wants the operator to see (unknown command + /// names, a deny that overrides an allow). Printed by the CLI. + #[serde(default)] + pub notes: Vec, + /// Per-category confidence overrides. /// If absent, `translator::default_confidence()` is used. pub confidence_overrides: Vec, } +/// A panic-attack subcommand panicbot can run (class (a) of the inventory in +/// [`crate::suite`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Subcommand { + /// Static analysis of a directory (weak points). + Assail, + /// Proven-library substitution survey of a directory. + Assay, + /// Tool preflight: binary present, version, health. + Diagnostics, + /// Verdict over dynamic-mode reports. + Adjudicate, + /// Single-axis attack on a built program (covered by `assault`). + Attack, + /// Assail plus multi-axis attack on a built program. + Assault, + /// Program run under ambient stressors. + Ambush, + /// File mutation with an execution check. + Amuck, + /// File isolation with an execution check. + Abduct, + /// Reaction observation from an execution or reports. + Axial, +} + +impl Subcommand { + /// Every class (a) subcommand, in the order the suite runs them. + pub const ALL: [Subcommand; 10] = [ + Subcommand::Assail, + Subcommand::Assay, + Subcommand::Assault, + Subcommand::Ambush, + Subcommand::Attack, + Subcommand::Amuck, + Subcommand::Abduct, + Subcommand::Axial, + Subcommand::Adjudicate, + Subcommand::Diagnostics, + ]; + + /// The subcommand as panic-attack and the directive files spell it. + pub fn name(self) -> &'static str { + match self { + Subcommand::Assail => "assail", + Subcommand::Assay => "assay", + Subcommand::Diagnostics => "diagnostics", + Subcommand::Adjudicate => "adjudicate", + Subcommand::Attack => "attack", + Subcommand::Assault => "assault", + Subcommand::Ambush => "ambush", + Subcommand::Amuck => "amuck", + Subcommand::Abduct => "abduct", + Subcommand::Axial => "axial", + } + } + + /// Parse a directive spelling. Unknown names are `None`, never a guess. + pub fn parse(s: &str) -> Option { + Subcommand::ALL.into_iter().find(|c| c.name() == s) + } + + /// Whether the subcommand executes code from the scanned repository + /// (or a program built from it). Dynamic modes need an explicit target + /// and are refused under privileged CI triggers (see [`crate::suite`]). + pub fn is_dynamic(self) -> bool { + matches!( + self, + Subcommand::Attack + | Subcommand::Assault + | Subcommand::Ambush + | Subcommand::Amuck + | Subcommand::Abduct + | Subcommand::Axial + ) + } +} + /// Which panic-attack subcommands panicbot is allowed to invoke. /// -/// Dynamic attack modes (`attack`, `assault`, `ambush`, `amuck`, `abduct`, -/// `axial`) are **denied by default** — they execute the target binary and -/// can cause side effects. Static analysis (`assail`, `adjudicate`, -/// `diagnostics`) is safe and enabled by default. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// The default is the FULL SUITE: every class (a) subcommand (owner ruling +/// 2026-11-13). A repository narrows it with its directive file, which can +/// deny any of them or replace the set with an explicit allow list (see +/// [`crate::directives`]). Allowed is not the same as run: a dynamic mode +/// that is allowed but has no target reports `not_applicable` with its reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AllowedCommands { - /// `panic-attack assail` — core static analysis (safe, default: true). + /// `panic-attack assail` — core static analysis. pub assail: bool, - /// `panic-attack adjudicate` — multi-report verdict (safe, default: true). + /// `panic-attack assay` — proven-library substitution survey. + pub assay: bool, + /// `panic-attack adjudicate` — verdict over dynamic-mode reports. pub adjudicate: bool, - /// `panic-attack diagnostics` — preflight health check (safe, default: true). + /// `panic-attack diagnostics` — preflight health check. pub diagnostics: bool, + /// `panic-attack attack` — single-axis attack (covered by `assault`). + pub attack: bool, + /// `panic-attack assault` — assail plus multi-axis attack on a program. + pub assault: bool, + /// `panic-attack ambush` — program under ambient stressors. + pub ambush: bool, + /// `panic-attack amuck` — file mutation with an execution check. + pub amuck: bool, + /// `panic-attack abduct` — file isolation with an execution check. + pub abduct: bool, + /// `panic-attack axial` — reaction observation. + pub axial: bool, +} + +impl AllowedCommands { + /// Every class (a) subcommand allowed: the default. + pub fn all() -> Self { + Self { + assail: true, + assay: true, + adjudicate: true, + diagnostics: true, + attack: true, + assault: true, + ambush: true, + amuck: true, + abduct: true, + axial: true, + } + } + + /// Nothing allowed. + pub fn none() -> Self { + Self { + assail: false, + assay: false, + adjudicate: false, + diagnostics: false, + attack: false, + assault: false, + ambush: false, + amuck: false, + abduct: false, + axial: false, + } + } + + /// What a directive file granted before the full-suite ruling: the static + /// trio. A directive file that says nothing about `allow` keeps exactly + /// this, so a repository's scope is never widened behind its back. + pub fn legacy_static() -> Self { + let mut a = Self::none(); + a.assail = true; + a.adjudicate = true; + a.diagnostics = true; + a + } + + /// Whether `cmd` is allowed. + pub fn allows(&self, cmd: Subcommand) -> bool { + match cmd { + Subcommand::Assail => self.assail, + Subcommand::Assay => self.assay, + Subcommand::Diagnostics => self.diagnostics, + Subcommand::Adjudicate => self.adjudicate, + Subcommand::Attack => self.attack, + Subcommand::Assault => self.assault, + Subcommand::Ambush => self.ambush, + Subcommand::Amuck => self.amuck, + Subcommand::Abduct => self.abduct, + Subcommand::Axial => self.axial, + } + } + + /// Allow or deny one subcommand. + pub fn set(&mut self, cmd: Subcommand, allowed: bool) { + match cmd { + Subcommand::Assail => self.assail = allowed, + Subcommand::Assay => self.assay = allowed, + Subcommand::Diagnostics => self.diagnostics = allowed, + Subcommand::Adjudicate => self.adjudicate = allowed, + Subcommand::Attack => self.attack = allowed, + Subcommand::Assault => self.assault = allowed, + Subcommand::Ambush => self.ambush = allowed, + Subcommand::Amuck => self.amuck = allowed, + Subcommand::Abduct => self.abduct = allowed, + Subcommand::Axial => self.axial = allowed, + } + } } /// Minimum severity filter for reported findings. @@ -73,27 +255,28 @@ pub struct ConfidenceOverride { pub confidence: f64, } +impl Default for AllowedCommands { + fn default() -> Self { + Self::all() + } +} + +/// Default wall-clock budget for the whole suite (see [`PanicbotConfig::budget`]). +pub const DEFAULT_BUDGET: Duration = Duration::from_secs(240); + impl Default for PanicbotConfig { fn default() -> Self { Self { allowed_commands: AllowedCommands::default(), min_severity: MinSeverity::Low, timeout: Duration::from_secs(300), + budget: DEFAULT_BUDGET, + notes: Vec::new(), confidence_overrides: Vec::new(), } } } -impl Default for AllowedCommands { - fn default() -> Self { - Self { - assail: true, - adjudicate: true, - diagnostics: true, - } - } -} - impl MinSeverity { /// Returns the numeric threshold for this severity level. /// Findings with a severity value >= this threshold are reported. @@ -145,6 +328,11 @@ mod tests { assert!(config.allowed_commands.diagnostics); assert_eq!(config.min_severity, MinSeverity::Low); assert_eq!(config.timeout, Duration::from_secs(300)); + assert_eq!(config.budget, DEFAULT_BUDGET); + assert!( + config.budget < Duration::from_secs(300), + "must stay under the driver's 300 s adapter limit" + ); assert!(config.confidence_overrides.is_empty()); } @@ -176,3 +364,61 @@ mod tests { assert_eq!(config.confidence_for("PanicPath"), None); } } + +#[cfg(test)] +mod suite_tests { + use super::*; + + #[test] + fn default_is_the_full_suite() { + let allowed = PanicbotConfig::default().allowed_commands; + for cmd in Subcommand::ALL { + assert!( + allowed.allows(cmd), + "{} must be allowed by default", + cmd.name() + ); + } + assert_eq!(allowed, AllowedCommands::all()); + } + + #[test] + fn all_lists_each_subcommand_once_and_names_round_trip() { + let mut seen = std::collections::HashSet::new(); + for cmd in Subcommand::ALL { + assert!(seen.insert(cmd), "{} listed twice", cmd.name()); + assert_eq!(Subcommand::parse(cmd.name()), Some(cmd)); + } + assert_eq!(Subcommand::parse("tui"), None); + assert_eq!(Subcommand::parse("Assail"), None); + } + + #[test] + fn set_and_allows_agree_for_every_subcommand() { + for cmd in Subcommand::ALL { + let mut a = AllowedCommands::none(); + assert!(!a.allows(cmd)); + a.set(cmd, true); + assert!(a.allows(cmd)); + for other in Subcommand::ALL.into_iter().filter(|o| *o != cmd) { + assert!( + !a.allows(other), + "{} leaked into {}", + cmd.name(), + other.name() + ); + } + } + } + + #[test] + fn legacy_static_is_exactly_the_old_default() { + let a = AllowedCommands::legacy_static(); + let on: Vec<&str> = Subcommand::ALL + .into_iter() + .filter(|c| a.allows(*c)) + .map(|c| c.name()) + .collect(); + assert_eq!(on, ["assail", "adjudicate", "diagnostics"]); + } +} diff --git a/bots/panicbot/src/directives.rs b/bots/panicbot/src/directives.rs index 53547f87..2bb4c703 100644 --- a/bots/panicbot/src/directives.rs +++ b/bots/panicbot/src/directives.rs @@ -1,12 +1,25 @@ // SPDX-License-Identifier: MPL-2.0 //! Directives — per-repo configuration parser for `.machine_readable/bot_directives/panicbot.a2ml`. //! -//! Each repository can customise panicbot's behaviour via a TOML-shaped A2ML -//! file. If no directive file exists, safe defaults are used: -//! - Allow: `assail`, `adjudicate`, `diagnostics` (static analysis only) -//! - Deny: all dynamic attack modes (`attack`, `assault`, `ambush`, etc.) -//! - Min severity: `low` (report everything) -//! - Timeout: 300 seconds +//! Each repository can customise panicbot's behaviour via a TOML-shaped file. +//! A2ML is retired and the deed has no per-bot directive clause yet (the +//! standards mapping only covers the `instructions-dir` pointer), so this +//! module only READS the legacy file; it never writes or extends one. +//! +//! ## What a repository gets +//! +//! - **No directive file: the full suite** — every class (a) panic-attack +//! subcommand (see [`crate::suite`]), all severities, 300 s per command +//! inside the suite budget (owner ruling 2026-11-13). +//! - **A directive file keeps exactly what it allows and denies.** Before the +//! full-suite ruling a file with no `allow` key granted the static trio +//! (`assail`, `adjudicate`, `diagnostics`); it still grants exactly that, so +//! no repository is widened behind its back. `allow = [...]` replaces that +//! set; `deny = [...]` removes from it, and a deny wins over an allow of the +//! same name. Widening needs an explicit `allow`. +//! - **A file that exists but cannot be parsed is an error**, not a fallback to +//! the (now full) default: failing open would widen a repository whose +//! directive is broken. Callers must surface the error. //! //! ## Directive Format (A2ML / TOML-shaped) //! @@ -22,9 +35,14 @@ //! timeout-seconds = 300 //! ``` //! +//! Names are panic-attack subcommands: `assail`, `assay`, `adjudicate`, +//! `diagnostics`, `attack`, `assault`, `ambush`, `amuck`, `abduct`, `axial`. +//! An unknown name is ignored with a note (it can only ever narrow, never +//! widen), and the note is printed. +//! //! The SCM form was retired 2026-04-17; no fallback is supported. -use crate::config::{AllowedCommands, MinSeverity, PanicbotConfig}; +use crate::config::{AllowedCommands, MinSeverity, PanicbotConfig, Subcommand}; use anyhow::{Context, Result}; use serde::Deserialize; use std::path::Path; @@ -35,15 +53,15 @@ const DIRECTIVE_PATH: &str = ".machine_readable/bot_directives/panicbot.a2ml"; /// Load panicbot configuration from a repository's directive file. /// -/// If the file doesn't exist, returns the default config (static analysis -/// only, all severities, 300s timeout). If the file exists but can't be -/// parsed, returns an error. +/// If the file doesn't exist, returns the default config: the full suite, all +/// severities, 300 s per command. If the file exists but can't be read or +/// parsed, returns an error (never the default: see the module docs). pub fn load_directives(repo_path: &Path) -> Result { let directive_file = repo_path.join(DIRECTIVE_PATH); if !directive_file.exists() { tracing::debug!( - "No directive file at {}, using defaults", + "No directive file at {}, using the full suite", directive_file.display() ); return Ok(PanicbotConfig::default()); @@ -62,6 +80,8 @@ struct DirectiveFile { #[serde(default)] allow: Option>, #[serde(default)] + deny: Option>, + #[serde(default)] config: Option, #[serde(default, rename = "min-severity")] min_severity_top: Option, @@ -78,17 +98,50 @@ struct ConfigBlock { } /// Parse a panicbot directive from its A2ML/TOML content. +/// +/// The content is a directive FILE, so the baseline is what a file granted +/// before the full-suite ruling (the static trio), not the no-file default. fn parse_directive(content: &str) -> Result { - let mut config = PanicbotConfig::default(); + let mut config = PanicbotConfig { + allowed_commands: AllowedCommands::legacy_static(), + ..PanicbotConfig::default() + }; let file: DirectiveFile = toml::from_str(content).context("Failed to parse TOML")?; - // Allow list - if let Some(allowed) = file.allow { - config.allowed_commands = AllowedCommands { - assail: allowed.iter().any(|s| s == "assail"), - adjudicate: allowed.iter().any(|s| s == "adjudicate"), - diagnostics: allowed.iter().any(|s| s == "diagnostics"), - }; + // Allow list: an explicit allow REPLACES the baseline. + if let Some(allowed) = &file.allow { + config.allowed_commands = AllowedCommands::none(); + for name in allowed { + match Subcommand::parse(name) { + Some(cmd) => config.allowed_commands.set(cmd, true), + None => config + .notes + .push(format!("directive allow: unknown command '{name}' ignored")), + } + } + } + + // Deny list: removes from whatever is allowed; a deny wins. + if let Some(denied) = &file.deny { + for name in denied { + match Subcommand::parse(name) { + Some(cmd) => { + if file + .allow + .as_ref() + .is_some_and(|a| a.iter().any(|n| n == name)) + { + config.notes.push(format!( + "directive lists '{name}' in both allow and deny: deny wins" + )); + } + config.allowed_commands.set(cmd, false); + } + None => config + .notes + .push(format!("directive deny: unknown command '{name}' ignored")), + } + } } // min-severity: prefer [config] block, fall back to top-level kebab key. @@ -196,3 +249,139 @@ timeout-seconds = 120 assert_eq!(config.min_severity, MinSeverity::Low); } } + +#[cfg(test)] +mod suite_tests { + use super::*; + + fn allowed_names(config: &PanicbotConfig) -> Vec<&'static str> { + Subcommand::ALL + .into_iter() + .filter(|c| config.allowed_commands.allows(*c)) + .map(|c| c.name()) + .collect() + } + + fn repo_with_directive(content: &str) -> tempfile::TempDir { + let d = tempfile::tempdir().unwrap(); + let dir = d.path().join(".machine_readable/bot_directives"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("panicbot.a2ml"), content).unwrap(); + d + } + + #[test] + fn no_directive_file_means_the_full_suite() { + let d = tempfile::tempdir().unwrap(); + let config = load_directives(d.path()).unwrap(); + assert_eq!(config.allowed_commands, AllowedCommands::all()); + assert_eq!(allowed_names(&config).len(), Subcommand::ALL.len()); + } + + #[test] + fn a_directive_file_that_matches_todays_denies_keeps_exactly_todays_scope() { + // The directive shape the estate ships: static trio allowed, the six + // dynamic modes denied. `assay` is new and is NOT in the file, so it + // must stay off: the file's scope is not silently widened. + let d = repo_with_directive( + r#" +schema_version = "1.0" +bot = "panicbot" +scope = "static-analysis" +allow = ["assail", "adjudicate", "diagnostics"] +deny = ["attack", "assault", "ambush", "amuck", "abduct", "axial"] + +[config] +min-severity = "low" +timeout-seconds = 300 +"#, + ); + let config = load_directives(d.path()).unwrap(); + assert_eq!( + allowed_names(&config), + ["assail", "adjudicate", "diagnostics"] + ); + assert!(!config.allowed_commands.assay); + assert!(config.notes.is_empty()); + } + + #[test] + fn a_file_with_no_allow_key_keeps_the_static_trio_and_is_not_widened() { + let d = repo_with_directive("[config]\nmin-severity = \"high\"\n"); + let config = load_directives(d.path()).unwrap(); + assert_eq!( + allowed_names(&config), + ["assail", "adjudicate", "diagnostics"] + ); + assert_eq!(config.min_severity, MinSeverity::High); + } + + #[test] + fn deny_alone_narrows_the_baseline() { + let config = parse_directive("deny = [\"adjudicate\"]\n").unwrap(); + assert_eq!(allowed_names(&config), ["assail", "diagnostics"]); + } + + #[test] + fn an_explicit_allow_can_widen_to_any_subcommand() { + let config = parse_directive("allow = [\"assail\", \"assay\", \"assault\"]\n").unwrap(); + assert_eq!(allowed_names(&config), ["assail", "assay", "assault"]); + } + + #[test] + fn deny_can_remove_any_subcommand_from_an_allow() { + for cmd in Subcommand::ALL { + let content = format!( + "allow = [{}]\ndeny = [\"{}\"]\n", + Subcommand::ALL + .iter() + .map(|c| format!("\"{}\"", c.name())) + .collect::>() + .join(", "), + cmd.name() + ); + let config = parse_directive(&content).unwrap(); + assert!( + !config.allowed_commands.allows(cmd), + "{} not denied", + cmd.name() + ); + for other in Subcommand::ALL.into_iter().filter(|o| *o != cmd) { + assert!( + config.allowed_commands.allows(other), + "{} lost", + other.name() + ); + } + } + } + + #[test] + fn a_name_in_both_lists_is_denied_and_noted() { + let config = parse_directive("allow = [\"assail\"]\ndeny = [\"assail\"]\n").unwrap(); + assert!(!config.allowed_commands.assail); + assert!( + config.notes.iter().any(|n| n.contains("deny wins")), + "{:?}", + config.notes + ); + } + + #[test] + fn unknown_names_are_ignored_loudly_and_never_widen() { + let config = + parse_directive("allow = [\"assail\", \"tui\"]\ndeny = [\"asail\"]\n").unwrap(); + assert_eq!(allowed_names(&config), ["assail"]); + assert_eq!(config.notes.len(), 2, "{:?}", config.notes); + assert!(config.notes.iter().any(|n| n.contains("'tui'"))); + assert!(config.notes.iter().any(|n| n.contains("'asail'"))); + } + + #[test] + fn a_broken_directive_file_is_an_error_not_the_default() { + // Falling back to the default would hand a repository whose directive + // is broken the FULL suite. + let d = repo_with_directive("allow = [ this is not toml\n"); + assert!(load_directives(d.path()).is_err()); + } +} diff --git a/bots/panicbot/src/fleet.rs b/bots/panicbot/src/fleet.rs index 0846861a..f17c4e33 100644 --- a/bots/panicbot/src/fleet.rs +++ b/bots/panicbot/src/fleet.rs @@ -4,8 +4,9 @@ //! Registers as `BotId::Panicbot` in gitbot-shared-context. Provides the //! standard fleet entry point (`run_fleet_scan`) that: //! 1. Reads per-repo directives (`.machine_readable/bot_directives/panicbot.scm`) -//! 2. Invokes `panic-attack assail` via the scanner module -//! 3. Translates WeakPoints into Finding structs +//! 2. Runs the allowed panic-attack suite (full suite by default, see `suite`) +//! 3. Translates each part's results into Finding structs (assail WeakPoints, +//! assay candidates, dynamic-mode results when a target is given) //! 4. Classifies fixable vs unfixable //! 5. Writes unfixable to `.panicbot/PANICBOT-FINDINGS.a2ml` //! 6. Returns FindingSet for the fleet pipeline @@ -13,6 +14,7 @@ use crate::a2ml_writer; use crate::directives; use crate::scanner; +use crate::suite::{self, SuiteOptions, SuiteRun}; use crate::translator; use gitbot_shared_context::bot::{BotExecution, BotId, BotInfo}; use gitbot_shared_context::finding::FindingSet; @@ -28,11 +30,12 @@ pub fn bot_info() -> BotInfo { /// Controls scan depth and whether findings can block PRs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BotMode { - /// Report findings, don't block. Runs `assail` only. + /// Report findings, don't block. Runs the allowed suite. Advisor, - /// Full suite: `assail` + `adjudicate` + `diagnostics`. + /// Report findings, don't block. Same scope as Advisor since the full-suite + /// ruling (2026-11-13); the name is kept for callers. Auditor, - /// Block PRs on critical findings. Runs `assail` + strict thresholds. + /// Block PRs on critical findings. Runs the allowed suite + strict thresholds. Guardian, } @@ -57,50 +60,54 @@ impl BotMode { /// /// Performs the complete scan pipeline: /// 1. Load per-repo directives -/// 2. Run panic-attack assail -/// 3. Translate findings -/// 4. Classify fixable/unfixable -/// 5. Write A2ML debt register -/// 6. Return FindingSet + BotExecution +/// 2. Run the allowed panic-attack suite (see [`crate::suite`]) +/// 3. Classify fixable/unfixable +/// 4. Write A2ML debt register +/// 5. Return FindingSet + BotExecution pub fn run_fleet_scan(dir: &Path, mode: BotMode) -> (FindingSet, BotExecution) { + let (set, execution, _) = run_fleet_suite(dir, mode, &SuiteOptions::from_env(None)); + (set, execution) +} + +/// [`run_fleet_scan`] that also returns the per-part results and takes the +/// suite options (target for the dynamic modes). +pub fn run_fleet_suite( + dir: &Path, + mode: BotMode, + opts: &SuiteOptions, +) -> (FindingSet, BotExecution, Option) { let mut execution = BotExecution::new(BotId::Panicbot); execution.start(); - // Step 1: Load per-repo directives + // Step 1: Load per-repo directives. A directive file that exists but does + // not parse is a failure, never the (full-suite) default: failing open + // would widen the scope of a repository whose directive is broken. let config = match directives::load_directives(dir) { Ok(c) => c, Err(e) => { - tracing::warn!("Failed to load directives: {}, using defaults", e); - crate::config::PanicbotConfig::default() - } - }; - - // Step 2: Run panic-attack assail - if !config.allowed_commands.assail { - tracing::info!("assail command disabled by directives, skipping scan"); - execution.skip("assail disabled by bot directives"); - return (FindingSet::new(), execution); - } - - let assail_result = scanner::run_assail(dir, config.timeout); - let report = match assail_result { - Ok(r) => r, - Err(e) => { - let msg = format!("panic-attack assail failed: {}", e); + let msg = format!("bot directives unreadable, nothing run: {e:#}"); tracing::error!("{}", msg); execution.fail(&msg); - return (FindingSet::new(), execution); + return (FindingSet::new(), execution, None); } }; - // Step 3: Translate WeakPoints into Findings - let findings = translator::translate_all(&report.weak_points, &config); - let findings_count = findings.len(); + // Step 2: Run the allowed suite. + let run = suite::run_suite(dir, &config, opts); + for line in run.table() { + tracing::info!("{}", line); + } + if !run.analysed() && run.errored().is_empty() { + execution.skip("no panic-attack part ran: all denied by bot directives or not applicable"); + return (FindingSet::new(), execution, Some(run)); + } - // Step 4: Classify fixable vs unfixable + // Step 3: Classify fixable vs unfixable + let findings = run.findings.clone(); + let findings_count = findings.len(); let (fixable, unfixable) = translator::classify_fixability(&findings); - // Step 5: Write A2ML debt register (if there are unfixable findings) + // Step 4: Write A2ML debt register (if there are findings) let repo_name = dir .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -124,14 +131,15 @@ pub fn run_fleet_scan(dir: &Path, mode: BotMode) -> (FindingSet, BotExecution) { } } - // Step 6: Build FindingSet for fleet pipeline + // Step 5: Build FindingSet for fleet pipeline let mut finding_set = FindingSet::new(); finding_set.extend(findings); // Count files from weak point locations - let files_analyzed: std::collections::HashSet<_> = report - .weak_points + let files_analyzed: std::collections::HashSet<_> = run + .assail .iter() + .flat_map(|r| r.weak_points.iter()) .filter_map(|wp| wp.location.as_ref()) .filter_map(|loc| loc.split(':').next()) .collect(); @@ -139,6 +147,11 @@ pub fn run_fleet_scan(dir: &Path, mode: BotMode) -> (FindingSet, BotExecution) { let error_count = finding_set.errors().len(); execution.complete(findings_count, error_count, files_analyzed.len()); + // A failed part is a failed run, whatever the other parts found. + if let Some(summary) = run.error_summary() { + execution.fail(&summary); + } + // In guardian mode, mark as failed if there are critical findings if mode.blocks_on_error() && finding_set.has_errors() { execution.fail(&format!( @@ -147,7 +160,7 @@ pub fn run_fleet_scan(dir: &Path, mode: BotMode) -> (FindingSet, BotExecution) { )); } - (finding_set, execution) + (finding_set, execution, Some(run)) } #[cfg(test)] diff --git a/bots/panicbot/src/lib.rs b/bots/panicbot/src/lib.rs index 399cc887..d0f8522b 100644 --- a/bots/panicbot/src/lib.rs +++ b/bots/panicbot/src/lib.rs @@ -41,4 +41,5 @@ pub mod config; pub mod directives; pub mod fleet; pub mod scanner; +pub mod suite; pub mod translator; diff --git a/bots/panicbot/src/main.rs b/bots/panicbot/src/main.rs index 680ca977..30bee570 100644 --- a/bots/panicbot/src/main.rs +++ b/bots/panicbot/src/main.rs @@ -5,7 +5,7 @@ //! subcommand handler based on command-line arguments. //! //! Subcommands: -//! - `scan ` — Run panic-attack assail and show findings +//! - `scan ` — Run the allowed panic-attack suite and show findings //! - `fleet ` — Run as a fleet member (produces FindingSet + A2ML) //! - `report ` — Generate A2ML debt register only @@ -18,6 +18,7 @@ use panicbot::cli::{Cli, Command, OutputFormat}; use panicbot::directives; use panicbot::fleet; use panicbot::scanner; +use panicbot::suite::{self, SuiteOptions}; use panicbot::translator; fn main() -> Result<()> { @@ -37,21 +38,61 @@ fn main() -> Result<()> { .init(); match cli.command { - Command::Scan { dir } => cmd_scan(&dir, cli.format), - Command::Fleet { dir, mode } => cmd_fleet(&dir, &mode, cli.format), + Command::Scan { + dir, + target, + budget_seconds, + } => cmd_scan(&dir, target, budget_seconds, cli.format), + Command::Fleet { dir, mode, target } => cmd_fleet(&dir, &mode, target, cli.format), Command::Report { dir, output } => cmd_report(&dir, output.as_deref(), cli.format), } } -/// Scan a directory with panic-attack and display translated findings. -fn cmd_scan(dir: &std::path::Path, format: OutputFormat) -> Result<()> { +/// Exit status when at least one panic-attack part failed. The findings of the +/// parts that ran are still printed; the report-back driver reads this status +/// as an `error` row carrying the failure list. +const EXIT_PART_ERROR: i32 = 3; +/// Exit status when no part analysed anything (all denied by the directives or +/// not applicable). The driver reads it as `not_run`, never as a clean `ran`. +const EXIT_NOTHING_RAN: i32 = 4; + +const NOTHING_RAN: &str = + "no part analysed the repository (all denied by its bot directives or not applicable)"; + +/// Scan a directory with the allowed panic-attack suite and display translated +/// findings. +fn cmd_scan( + dir: &std::path::Path, + target: Option, + budget_seconds: Option, + format: OutputFormat, +) -> Result<()> { tracing::info!("Scanning: {}", dir.display()); - let config = directives::load_directives(dir).unwrap_or_default(); - let report = - scanner::run_assail(dir, config.timeout).context("Failed to run panic-attack assail")?; + // A directive file that exists but does not parse stops the run: falling + // back to the default (now the full suite) would widen the repository. + let mut config = + directives::load_directives(dir).context("bot directives unreadable, nothing run")?; + if let Some(secs) = budget_seconds { + config.budget = std::time::Duration::from_secs(secs); + } - let findings = translator::translate_all(&report.weak_points, &config); + let run = suite::run_suite(dir, &config, &SuiteOptions::from_env(target)); + let findings = run.findings.clone(); + + // Failure summary FIRST on stderr: the report-back driver quotes the first + // line as the row's reason. + let part_error = run.error_summary(); + if let Some(summary) = &part_error { + eprintln!("panicbot: {summary}"); + } + for note in &config.notes { + eprintln!("panicbot: directive note: {note}"); + } + for line in run.table() { + eprintln!("panicbot: {line}"); + } + eprintln!("panicbot: suite wall time {} ms", run.wall_ms); match format { OutputFormat::Text => { @@ -83,12 +124,40 @@ fn cmd_scan(dir: &std::path::Path, format: OutputFormat) -> Result<()> { } } OutputFormat::Json => { + // A findings ARRAY means every allowed part that applies ran. A run + // that did not is an object naming its status and reason (the same + // form ponsbot uses), never an array: the report-back driver turns + // it into an `error` or `not_run` row, so nothing reads as clean. + let value = if let Some(summary) = &part_error { + serde_json::json!({ + "status": "error", + "reason": summary, + "findings": findings, + "parts": run.parts, + }) + } else if !run.analysed() { + serde_json::json!({ + "status": "not_run", + "reason": NOTHING_RAN, + "findings": findings, + "parts": run.parts, + }) + } else { + serde_json::to_value(&findings).context("Failed to serialize findings")? + }; let json = - serde_json::to_string_pretty(&findings).context("Failed to serialize findings")?; + serde_json::to_string_pretty(&value).context("Failed to serialize findings")?; println!("{}", json); } } + if part_error.is_some() { + std::process::exit(EXIT_PART_ERROR); + } + if !run.analysed() { + eprintln!("panicbot: {NOTHING_RAN}"); + std::process::exit(EXIT_NOTHING_RAN); + } // Exit with error code if there are blocking issues if findings.iter().any(|f| f.severity.blocks_release()) { std::process::exit(1); @@ -98,7 +167,12 @@ fn cmd_scan(dir: &std::path::Path, format: OutputFormat) -> Result<()> { } /// Run as a fleet member. -fn cmd_fleet(dir: &std::path::Path, mode_str: &str, format: OutputFormat) -> Result<()> { +fn cmd_fleet( + dir: &std::path::Path, + mode_str: &str, + target: Option, + format: OutputFormat, +) -> Result<()> { let mode = fleet::BotMode::parse(mode_str).unwrap_or_else(|| { tracing::warn!("Unknown mode '{}', defaulting to advisor", mode_str); fleet::BotMode::Advisor @@ -106,7 +180,8 @@ fn cmd_fleet(dir: &std::path::Path, mode_str: &str, format: OutputFormat) -> Res tracing::info!("Running as fleet member in {:?} mode", mode); - let (finding_set, execution) = fleet::run_fleet_scan(dir, mode); + let (finding_set, execution, run) = + fleet::run_fleet_suite(dir, mode, &SuiteOptions::from_env(target)); match format { OutputFormat::Text => { @@ -117,6 +192,12 @@ fn cmd_fleet(dir: &std::path::Path, mode_str: &str, format: OutputFormat) -> Res println!("Errors: {}", finding_set.errors().len()); println!("Warnings: {}", finding_set.warnings().len()); println!(); + if let Some(run) = &run { + for line in run.table() { + println!(" {line}"); + } + println!(); + } for finding in finding_set.into_iter() { let location = finding.location_string().unwrap_or_default(); @@ -134,6 +215,7 @@ fn cmd_fleet(dir: &std::path::Path, mode_str: &str, format: OutputFormat) -> Res "bot_info": fleet::bot_info(), "execution": execution, "findings": finding_set, + "parts": run.as_ref().map(|r| &r.parts), }); let json = serde_json::to_string_pretty(&output) .context("Failed to serialize fleet output")?; @@ -152,7 +234,8 @@ fn cmd_report( ) -> Result<()> { tracing::info!("Generating A2ML report for: {}", dir.display()); - let config = directives::load_directives(dir).unwrap_or_default(); + let config = + directives::load_directives(dir).context("bot directives unreadable, nothing run")?; let report = scanner::run_assail(dir, config.timeout).context("Failed to run panic-attack assail")?; diff --git a/bots/panicbot/src/scanner.rs b/bots/panicbot/src/scanner.rs index 9bc0883a..9868332d 100644 --- a/bots/panicbot/src/scanner.rs +++ b/bots/panicbot/src/scanner.rs @@ -85,6 +85,10 @@ pub struct WeakPoint { } /// Report from `panic-attack adjudicate` — cross-report verdict. +/// +/// NOTE: this models a report shape panic-attack 2.5.5 does not write (it +/// writes [`AdjudicateVerdict`]). Kept because it is public; the suite uses +/// [`run_adjudicate_verdict`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdjudicateReport { /// Number of reports adjudicated. @@ -311,18 +315,23 @@ pub fn run_adjudicate(reports: &[PathBuf], timeout: Duration) -> Result Result { - // First check if the binary exists at all - let binary = find_panic_attack_binary()?; + // Fail early, and clearly, when the binary is not there at all. + find_panic_attack_binary()?; - // Try `--version` for a lightweight health check - let version_output = Command::new(&binary) - .arg("--version") - .output() + // `--version` is the lightweight health check. Timed like every other invocation: a hung binary must not hang the lane. + let version_run = run_panic_attack_plain(&["--version"], timeout) .context("Failed to execute panic-attack --version")?; - let version = String::from_utf8_lossy(&version_output.stdout) + // First line only, bounded: this ends up in a part's detail. + let version: String = version_run + .stdout + .lines() + .next() + .unwrap_or("") .trim() - .to_string(); + .chars() + .take(80) + .collect(); // If diagnostics subcommand exists, use it match invoke_panic_attack(&["diagnostics"], timeout) { @@ -344,7 +353,7 @@ pub fn run_diagnostics(timeout: Duration) -> Result { } else { version }, - healthy: version_output.status.success(), + healthy: version_run.status.success(), supported_languages: Vec::new(), messages: vec!["Diagnostics constructed from --version check".to_string()], }) @@ -402,6 +411,281 @@ pub fn run_migration_diff( .context("Failed to parse panic-attack migration-diff output as JSON") } +// ── subcommands beyond `assail` (the full suite) ──────────────────────────── +// +// These all write their report with `-o` into a private scratch directory and +// run with that directory as the working directory. panic-attack otherwise +// drops `reports/`, `runtime/` and similar into the CWD, and the report-back +// driver's CWD is the scanned repository: a scan must not change the tree. + +/// A private scratch directory, removed on drop. +pub struct Scratch { + path: PathBuf, +} + +impl Scratch { + /// Create a fresh directory under the system temp dir. + pub fn new() -> Result { + let path = std::env::temp_dir().join(format!("panicbot-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path) + .with_context(|| format!("Failed to create scratch dir {}", path.display()))?; + Ok(Self { path }) + } + + /// The directory. + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// One proven-library substitution candidate from `panic-attack assay`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssayCandidate { + /// Stable candidate id (e.g. `safe-path`). + pub id: String, + /// Name of the proven replacement. + pub proven_name: String, + /// The proof artifact backing the replacement. + pub proof_backing: String, + /// `offered`, `already-applied` or `no-replacement-source`. + pub status: String, + /// Catalogue confidence (0.0 - 1.0). + pub confidence: f64, + /// Where the unproven pattern occurs. + #[serde(default)] + pub target_hits: Vec, +} + +/// One occurrence of an unproven pattern. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssayHit { + /// File (absolute or relative to the target). + pub file: PathBuf, + /// 1-based line. + pub line: u32, +} + +/// Report from `panic-attack assay `. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssayReport { + /// Candidates found. + pub candidates: Vec, + /// How many catalogue entries were checked. + #[serde(default)] + pub catalogue_entries: usize, + /// Free-text notes from the survey. + #[serde(default)] + pub notes: Vec, +} + +/// One per-axis result of a dynamic run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AxisResult { + /// Attack axis (`cpu`, `memory`, ...). + pub axis: String, + /// Whether the program survived the axis. + pub success: bool, + /// The axis did not run. + #[serde(default)] + pub skipped: bool, + /// Why it did not run. + #[serde(default)] + pub skip_reason: Option, + /// The program's exit code, if it exited. + #[serde(default)] + pub exit_code: Option, + /// Crashes observed. + #[serde(default)] + pub crashes: Vec, + /// Bug signatures detected. + #[serde(default)] + pub signatures_detected: Vec, +} + +/// Report from `panic-attack assault` or `panic-attack ambush`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DynamicReport { + /// Per-axis results. + pub attack_results: Vec, + /// Total crashes. + #[serde(default)] + pub total_crashes: usize, + /// Total bug signatures. + #[serde(default)] + pub total_signatures: usize, +} + +/// A verdict from `panic-attack adjudicate` (the real 2.5 report shape). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdjudicateVerdict { + /// `pass`, `warn` or `fail`. + pub verdict: String, + /// Reports it understood. + pub processed_reports: usize, + /// Reports it could not read or did not recognise. + pub failed_reports: usize, + /// Per-report problems. + #[serde(default)] + pub notes: Vec, + /// Prioritised conclusions. + #[serde(default)] + pub priorities: Vec, +} + +/// One prioritised conclusion of an adjudication. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdjudicatePriority { + /// `high`, `medium`, `info`. + pub level: String, + /// What it says. + pub message: String, +} + +/// Run a report-writing panic-attack subcommand in `scratch` and return the +/// parsed JSON report it wrote to `-o`. +/// +/// Any non-zero exit, a timeout, a missing report file or a report that is not +/// JSON is an error: a part never reads as clean because it printed nothing. +fn run_to_report(args: &[&str], scratch: &Scratch, timeout: Duration) -> Result { + let out = scratch.path().join("report.json"); + let out_s = out.display().to_string(); + let mut full: Vec<&str> = args.to_vec(); + full.push("-o"); + full.push(&out_s); + let run = run_panic_attack_raw_in(&full, timeout, Some(scratch.path()))?; + if !run.status.success() { + bail!( + "panic-attack {} exited with {}: {}", + args.first().copied().unwrap_or("?"), + run.status, + first_diagnostic(&run.stderr) + ); + } + let raw = std::fs::read_to_string(&out).with_context(|| { + format!( + "panic-attack {} exited 0 without writing its report", + args.first().copied().unwrap_or("?") + ) + })?; + serde_json::from_str(extract_json(&raw)).with_context(|| { + format!( + "panic-attack {} wrote a report that is not JSON", + args.first().copied().unwrap_or("?") + ) + }) +} + +/// Run `panic-attack assay `: the proven-library substitution survey. +pub fn run_assay(target: &Path, timeout: Duration) -> Result { + let scratch = Scratch::new()?; + let t = target.display().to_string(); + let value = run_to_report(&["assay", &t], &scratch, timeout)?; + parse_assay_report(value) +} + +/// Validate and parse an `assay` report. A report with no `candidates` array is +/// not an empty survey, it is a malformed one. +pub fn parse_assay_report(value: serde_json::Value) -> Result { + if !value.get("candidates").is_some_and(|c| c.is_array()) { + bail!("panic-attack assay wrote a report without a candidates array"); + } + serde_json::from_value(value).context("panic-attack assay wrote an unexpected report shape") +} + +/// Run `panic-attack assault` or `panic-attack ambush` against a built program. +/// +/// EXECUTES `program`. The caller is responsible for the security gate (see +/// `suite::dynamic_refusal`). `source` is the directory both modes run their +/// own assail phase over; only the attack results are used here, so the +/// static findings are not reported twice. +/// The raw report is copied to `keep` for `adjudicate`. +pub fn run_dynamic( + mode: &str, + program: &Path, + source: &Path, + axis_seconds: u64, + timeout: Duration, + keep: &Path, +) -> Result { + if mode != "assault" && mode != "ambush" { + bail!("run_dynamic: '{mode}' is not a dynamic mode panicbot runs"); + } + let scratch = Scratch::new()?; + let p = program.display().to_string(); + let s = source.display().to_string(); + let d = axis_seconds.to_string(); + let value = run_to_report( + &[mode, &p, "--source", &s, "-d", &d, "--quiet"], + &scratch, + timeout, + )?; + // Keep the raw report: `adjudicate` reads it from disk. + std::fs::write(keep, serde_json::to_vec(&value)?) + .with_context(|| format!("Failed to keep the {mode} report at {}", keep.display()))?; + parse_dynamic_report(value) +} + +/// Validate and parse an `assault`/`ambush` report. No per-axis results means +/// nothing was attacked, which is an error, never a clean run. +pub fn parse_dynamic_report(value: serde_json::Value) -> Result { + let report: DynamicReport = serde_json::from_value(value) + .context("panic-attack dynamic run wrote an unexpected report shape")?; + if report.attack_results.is_empty() { + bail!("panic-attack dynamic run produced no per-axis results (nothing was attacked)"); + } + Ok(report) +} + +/// Run `panic-attack adjudicate` over dynamic-mode reports and return its +/// verdict. +/// +/// panic-attack 2.5.5 prints `pass (processed 0, failed 1)` and exits 0 for a +/// missing or unrecognised report, so exit status is not evidence of a verdict: +/// [`parse_adjudicate_verdict`] requires every input to have been processed. +pub fn run_adjudicate_verdict(reports: &[PathBuf], timeout: Duration) -> Result { + let scratch = Scratch::new()?; + let mut args: Vec = vec!["adjudicate".to_string()]; + args.extend(reports.iter().map(|r| r.display().to_string())); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let value = run_to_report(&arg_refs, &scratch, timeout)?; + parse_adjudicate_verdict(value, reports.len()) +} + +/// Validate and parse an `adjudicate` report for `expected` inputs. +pub fn parse_adjudicate_verdict( + value: serde_json::Value, + expected: usize, +) -> Result { + let v: AdjudicateVerdict = serde_json::from_value(value) + .context("panic-attack adjudicate wrote an unexpected report shape")?; + if v.failed_reports > 0 || v.processed_reports < expected { + bail!( + "panic-attack adjudicate understood {} of {} report(s) ({} failed){}; its '{}' verdict is not evidence", + v.processed_reports, + expected, + v.failed_reports, + v.notes + .first() + .map(|n| format!(": {n}")) + .unwrap_or_default(), + v.verdict + ); + } + if !matches!(v.verdict.as_str(), "pass" | "warn" | "fail") { + bail!( + "panic-attack adjudicate wrote unknown verdict {:?}", + v.verdict + ); + } + Ok(v) +} + /// Locate the `panic-attack` binary on PATH. /// /// Returns the path to the binary, or an error if not found. @@ -455,6 +739,27 @@ struct RawRun { /// the call fails: a hung scanner must not hang the lane or read as a run). /// The exit status is returned for the caller to judge. fn run_panic_attack_raw(args: &[&str], timeout: Duration) -> Result { + run_panic_attack_raw_in(args, timeout, None) +} + +/// [`run_panic_attack_raw`] with an explicit working directory. +fn run_panic_attack_raw_in(args: &[&str], timeout: Duration, cwd: Option<&Path>) -> Result { + run_panic_attack_cmd(args, timeout, cwd, true) +} + +/// Run `panic-attack ` as given (no `--output-format`), with the same +/// enforced timeout. For `--version`. +fn run_panic_attack_plain(args: &[&str], timeout: Duration) -> Result { + run_panic_attack_cmd(args, timeout, None, false) +} + +/// The one place panic-attack is spawned and its timeout enforced. +fn run_panic_attack_cmd( + args: &[&str], + timeout: Duration, + cwd: Option<&Path>, + json: bool, +) -> Result { use std::io::Read; let binary = find_panic_attack_binary()?; @@ -467,8 +772,13 @@ fn run_panic_attack_raw(args: &[&str], timeout: Duration) -> Result { let mut cmd = Command::new(&binary); cmd.args(args); - cmd.arg("--output-format"); - cmd.arg("json"); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + if json { + cmd.arg("--output-format"); + cmd.arg("json"); + } cmd.stdin(std::process::Stdio::null()); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); @@ -784,3 +1094,124 @@ mod parity_tests { assert!(parse_assail_output(json).is_err()); } } + +#[cfg(test)] +mod suite_parse_tests { + use super::*; + use serde_json::json; + + #[test] + fn adjudicate_that_understood_nothing_is_an_error_not_a_pass() { + // Real panic-attack 2.5.5 output for a missing/unrecognised report: + // exit 0, verdict "pass", processed 0, failed 1. + let v = json!({ + "verdict": "pass", "processed_reports": 0, "failed_reports": 1, + "notes": ["assail.json: unsupported report format"], "priorities": [] + }); + let err = parse_adjudicate_verdict(v, 1).unwrap_err().to_string(); + assert!(err.contains("understood 0 of 1"), "{err}"); + assert!(err.contains("unsupported report format"), "{err}"); + assert!(err.contains("not evidence"), "{err}"); + } + + #[test] + fn adjudicate_that_understood_only_some_inputs_is_an_error() { + let v = + json!({"verdict": "fail", "processed_reports": 1, "failed_reports": 1, "notes": []}); + assert!(parse_adjudicate_verdict(v, 2).is_err()); + } + + #[test] + fn adjudicate_verdict_is_read_when_every_input_was_understood() { + let v = json!({ + "verdict": "fail", "processed_reports": 2, "failed_reports": 0, "notes": [], + "priorities": [{"level": "high", "message": "1 crashes detected across assault reports"}], + "totals": {"total_crashes": 1} + }); + let ok = parse_adjudicate_verdict(v, 2).unwrap(); + assert_eq!(ok.verdict, "fail"); + assert_eq!(ok.priorities[0].level, "high"); + } + + #[test] + fn adjudicate_with_an_unknown_verdict_is_an_error() { + let v = json!({"verdict": "shrug", "processed_reports": 1, "failed_reports": 0}); + assert!(parse_adjudicate_verdict(v, 1).is_err()); + } + + #[test] + fn assay_report_needs_a_candidates_array() { + assert!(parse_assay_report(json!({})).is_err()); + assert!(parse_assay_report(json!({"candidates": "x"})).is_err()); + // The real "nothing found" report is a valid, empty survey. + let ok = parse_assay_report(json!({ + "candidates": [], "catalogue_entries": 2, "notes": ["no substitution candidates found for this target"] + })) + .unwrap(); + assert!(ok.candidates.is_empty()); + } + + #[test] + fn assay_candidate_with_hits_parses_from_the_real_shape() { + let ok = parse_assay_report(json!({ + "candidates": [{ + "id": "safe-path", "proven_name": "SafePath", + "proof_backing": "proven: src/Proven/SafePath/Operations.idr", + "dest_rel": "src/safe_path.rs", "port_present": false, + "target_hits": [{"file": "src/a.rs", "line": 3, "snippet": "canonicalize(p).unwrap_or_else"}], + "equivalence_basis": "x", "perf_note": "y", + "substitution_kind": "port-to-rust", "confidence": 0.9, "status": "offered" + }] + })) + .unwrap(); + assert_eq!(ok.candidates[0].target_hits[0].line, 3); + assert_eq!(ok.candidates[0].status, "offered"); + } + + #[test] + fn a_dynamic_report_with_no_axis_results_is_an_error() { + assert!(parse_dynamic_report(json!({"attack_results": [], "total_crashes": 0})).is_err()); + assert!(parse_dynamic_report(json!({})).is_err()); + } + + #[test] + fn a_dynamic_report_parses_from_the_real_shape() { + let ok = parse_dynamic_report(json!({ + "schema_version": "2.5", "total_crashes": 1, "total_signatures": 0, + "attack_results": [{ + "axis": "cpu", "program": "./crash.sh", "success": false, "skipped": false, + "exit_code": null, "peak_memory": 0, "duration": {"secs": 0, "nanos": 1}, + "crashes": [{"signal": null, "stderr": "", "stdout": ""}], + "signatures_detected": [] + }] + })) + .unwrap(); + assert_eq!(ok.attack_results[0].crashes.len(), 1); + assert!(!ok.attack_results[0].success); + } + + #[test] + fn only_assault_and_ambush_are_dynamic_modes_panicbot_runs() { + let err = run_dynamic( + "attack", + Path::new("p"), + Path::new("."), + 1, + Duration::from_secs(1), + Path::new("k"), + ) + .unwrap_err() + .to_string(); + assert!(err.contains("not a dynamic mode"), "{err}"); + } + + #[test] + fn scratch_is_removed_on_drop() { + let path = { + let s = Scratch::new().unwrap(); + assert!(s.path().is_dir()); + s.path().to_path_buf() + }; + assert!(!path.exists()); + } +} diff --git a/bots/panicbot/src/suite.rs b/bots/panicbot/src/suite.rs new file mode 100644 index 00000000..c64f397c --- /dev/null +++ b/bots/panicbot/src/suite.rs @@ -0,0 +1,1279 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Suite — runs the allowed panic-attack subcommands and reports each part. +//! +//! Owner ruling 2026-11-13: panicbot runs the **full suite** unless the +//! repository's bot directives say otherwise. "Full suite" is every class (a) +//! subcommand below. Each subcommand is one *part*; every part ends as `ran`, +//! `not_applicable` (with its reason), `denied` (by the directives) or `error` +//! (loudly). Nothing is skipped silently and nothing is a fake pass. +//! +//! ## Inventory of panic-attack 2.5.5 subcommands +//! +//! Source: `src/main.rs` of panic-attack, each subcommand run against +//! `rsr-template-repo` on 2026-11-13 unless the reason says otherwise. +//! +//! **Class (a)**: a scan or analysis that runs non-interactively and yields +//! findings or a verdict. This is the suite. +//! +//! | Subcommand | Input | Yields | Panicbot runs it | +//! |---|---|---|---| +//! | `assail` | repo directory | weak points | always (if allowed) | +//! | `assay` | repo directory | proven-library substitution candidates (file and line) | always (if allowed) | +//! | `diagnostics` | none | tool health | always (if allowed); preflight only, no findings | +//! | `assault` | built program | per-axis crashes and bug signatures | only with `--target` (see below) | +//! | `ambush` | built program | same, under ambient stressors | only with `--target` | +//! | `adjudicate` | assault/ambush/amuck/abduct reports | pass/warn/fail verdict | only when a dynamic part produced a report | +//! | `attack` | built program, one axis | one axis of `assault` | `not_applicable`: `assault` runs every axis | +//! | `amuck` | one file and an exec program | mutation outcomes | `not_applicable`: panicbot has no source for the file/exec pairing | +//! | `abduct` | one file and an exec program | isolated-run outcome | `not_applicable`: same | +//! | `axial` | an exec program or existing reports | observed reactions | `not_applicable`: same (it rejects an assail report) | +//! +//! **Class (b)**: interactive or UI, never run: `tui`, `gui` (need a terminal +//! or display), `groove` (starts a long-lived server). +//! +//! **Class (c)**: infrastructure, side effects, or no repository scan; not part +//! of the suite: +//! +//! | Subcommand | Why not class (a) | +//! |---|---| +//! | `assimilate` | rewrites the tree (stages proven modules); an action, not a scan | +//! | `notify`, `sweep-tracker`, `query`, `campaign` (`register-pr`, `dismiss`, `status`, `poll`) | issue/PR side effects or a store of past findings, not a scan | +//! | `verisim-push`, `assemblyline`, `image` | push to a database, or batch-scan many repos and write a store | +//! | `attest`, `verify` | check an attestation sidecar; no repository analysis | +//! | `temporal` (`list`, `diff`) | reads the local scan-history store; errors without one | +//! | `diff`, `report`, `panll`, `a2ml-export`, `a2ml-import`, `manifest`, `analyze`, `aggregate` | consume or convert reports or prover output panicbot does not hold | +//! | `fingerprint` | a BLAKE3 hash of the tree; yields neither findings nor a verdict | +//! | `migration-snapshot`, `migration-diff` | ReScript-migration *metrics* (their embedded assail report duplicates `assail`); no findings; `--build-time` runs `rescript build` | +//! | `describe-contract`, `completions`, `help` | describe the CLI | +//! | `bridge` (`triage`, `status`) | class (a) in kind (CVE triage with reachability) but **held out**: owner ruling D4 of 2026-11-13 keeps the bridge gate outside panicbot (CVE-mitigation lifecycle, a sanctioned direct use), it needs the `http` feature and OSV network access, and it errors on a repo with no supported lockfile. Revisit with the owner | +//! +//! ## Dynamic modes: where the target comes from +//! +//! `assault` and `ambush` execute a program. Panicbot does not build the +//! scanned repository, so there is no program unless the operator gives one: +//! `--target PATH` (a built executable inside the scanned directory). Without +//! it the dynamic parts report `not_applicable` with that reason. None of the +//! CI callers passes a target, so in CI the dynamic parts are `not_applicable`. +//! +//! The security gate (checked 2026-11-13 over gitbot-fleet +//! `fleet-report-back-reusable.yml` and standards `fork-governance.yml`): both +//! run on plain `pull_request` with `contents: read` and no secrets, which is +//! unprivileged. A reusable workflow cannot know its caller's trigger, though, +//! so [`dynamic_refusal`] makes panicbot refuse to execute repository code when +//! `GITHUB_EVENT_NAME` is `pull_request_target` or `workflow_run`. +//! +//! ## Rule ids +//! +//! `assail` findings keep panic-attack's canonical `PAxxx` ids. The other +//! subcommands have no canonical id, so their findings use panicbot-local ids +//! with a `PB-` prefix (`PB-ASSAY-`, `PB-DYN-CRASH`, +//! `PB-DYN-SIGNATURE`, `PB-DYN-FAIL`, `PB-ADJUDICATE-VERDICT`) rather than a +//! second numbering inside `PA`. Every finding names a file (gitbot-fleet #668): +//! assay names the hit's file, dynamic findings and the verdict name the target. +//! +//! ## Budget +//! +//! Parts run one after another. Each invocation gets the smaller of the +//! per-command timeout and what is left of the suite budget +//! ([`PanicbotConfig::budget`], 240 s, under the driver's 300 s adapter limit). +//! A part that cannot start inside the budget is an `error`. + +use crate::config::{PanicbotConfig, Subcommand}; +use crate::scanner::{ + self, AdjudicateVerdict, AssailReport, AssayReport, DiagnosticsReport, DynamicReport, Scratch, +}; +use crate::translator; +use anyhow::Result; +use gitbot_shared_context::bot::BotId; +use gitbot_shared_context::finding::{Finding, Severity}; +use gitbot_shared_context::triangle::TriangleTier; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +/// Seconds each attack axis runs for in the dynamic modes. +pub const DYNAMIC_AXIS_SECONDS: u64 = 10; + +/// How one part of the suite ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PartStatus { + /// It ran to a usable result (with or without findings). + Ran, + /// It cannot apply here; the detail says why. + NotApplicable, + /// The repository's directives deny it. + Denied, + /// It failed. Always loud. + Error, +} + +/// The outcome of one subcommand. +#[derive(Debug, Clone, Serialize)] +pub struct Part { + /// Which subcommand. + pub command: Subcommand, + /// How it ended. + pub status: PartStatus, + /// The reason (never empty unless it ran with nothing to add). + pub detail: String, + /// Findings it contributed after the severity filter. + pub findings: usize, + /// Wall time spent in it. + pub wall_ms: u128, +} + +/// Everything a suite run produced. +#[derive(Debug, Clone, Serialize)] +pub struct SuiteRun { + /// One entry per subcommand, in run order. + pub parts: Vec, + /// The findings of every part that ran. + #[serde(skip)] + pub findings: Vec, + /// The assail report, when assail ran (statistics for callers). + #[serde(skip)] + pub assail: Option, + /// Total wall time. + pub wall_ms: u128, +} + +impl SuiteRun { + /// The parts that ended in `error`. + pub fn errored(&self) -> Vec<&Part> { + self.parts + .iter() + .filter(|p| p.status == PartStatus::Error) + .collect() + } + + /// Whether any part that analyses the repository ran. A preflight alone + /// (`diagnostics`) is not an analysis: a run where nothing else ran must + /// not read as a clean scan. + pub fn analysed(&self) -> bool { + self.parts + .iter() + .any(|p| p.status == PartStatus::Ran && p.command != Subcommand::Diagnostics) + } + + /// One line naming every failed part, or `None` when none failed. + pub fn error_summary(&self) -> Option { + let errs = self.errored(); + if errs.is_empty() { + return None; + } + let list: Vec = errs + .iter() + .map(|p| format!("{}: {}", p.command.name(), p.detail)) + .collect(); + Some(format!( + "{} of {} panic-attack part(s) failed: {}", + errs.len(), + self.parts.len(), + list.join("; ") + )) + } + + /// One line per part, for logs and the text output. + pub fn table(&self) -> Vec { + self.parts + .iter() + .map(|p| { + let status = match p.status { + PartStatus::Ran => "ran", + PartStatus::NotApplicable => "not_applicable", + PartStatus::Denied => "denied", + PartStatus::Error => "ERROR", + }; + format!( + "{:<12} {:<15} {:>4} finding(s) {:>6} ms {}", + p.command.name(), + status, + p.findings, + p.wall_ms, + p.detail + ) + }) + .collect() + } +} + +/// How a suite run is invoked. +#[derive(Debug, Clone, Default)] +pub struct SuiteOptions { + /// A built program for the dynamic modes. None: they are `not_applicable`. + pub target: Option, + /// The CI event name (`GITHUB_EVENT_NAME`), for the dynamic-mode gate. + pub ci_event: Option, +} + +impl SuiteOptions { + /// Options from the environment, with an optional explicit target. + pub fn from_env(target: Option) -> Self { + Self { + target, + ci_event: std::env::var("GITHUB_EVENT_NAME").ok(), + } + } +} + +/// The panic-attack side of the suite, as a trait so the orchestration can be +/// tested without the binary. +pub trait Engine { + /// `panic-attack assail`. + fn assail(&self, dir: &Path, timeout: Duration) -> Result; + /// `panic-attack assay`. + fn assay(&self, dir: &Path, timeout: Duration) -> Result; + /// `panic-attack assault|ambush`, keeping the raw report at `keep`. + fn dynamic( + &self, + mode: &str, + program: &Path, + source: &Path, + timeout: Duration, + keep: &Path, + ) -> Result; + /// `panic-attack adjudicate`. + fn adjudicate(&self, reports: &[PathBuf], timeout: Duration) -> Result; + /// The tool's health. + fn diagnostics(&self, timeout: Duration) -> Result; +} + +/// The real panic-attack binary. +pub struct PanicAttack; + +impl Engine for PanicAttack { + fn assail(&self, dir: &Path, timeout: Duration) -> Result { + scanner::run_assail(dir, timeout) + } + fn assay(&self, dir: &Path, timeout: Duration) -> Result { + scanner::run_assay(dir, timeout) + } + fn dynamic( + &self, + mode: &str, + program: &Path, + source: &Path, + timeout: Duration, + keep: &Path, + ) -> Result { + scanner::run_dynamic(mode, program, source, DYNAMIC_AXIS_SECONDS, timeout, keep) + } + fn adjudicate(&self, reports: &[PathBuf], timeout: Duration) -> Result { + scanner::run_adjudicate_verdict(reports, timeout) + } + fn diagnostics(&self, timeout: Duration) -> Result { + scanner::run_diagnostics(timeout) + } +} + +/// Why panicbot must not execute repository code right now, if it must not. +/// +/// A reusable workflow cannot see its caller's trigger policy, and the +/// privileged triggers run with secrets or a write token. Refuse to execute +/// the scanned repository's code under them. +pub fn dynamic_refusal(opts: &SuiteOptions) -> Option { + match opts.ci_event.as_deref() { + Some(ev @ ("pull_request_target" | "workflow_run")) => Some(format!( + "refused to execute repository code under the privileged '{ev}' trigger" + )), + _ => None, + } +} + +/// Resolve `--target` to a canonical executable inside `dir`. +fn resolve_target(dir: &Path, target: &Path) -> std::result::Result { + let root = dir + .canonicalize() + .map_err(|e| format!("cannot resolve the scanned directory: {e}"))?; + let joined = if target.is_absolute() { + target.to_path_buf() + } else { + root.join(target) + }; + let real = joined + .canonicalize() + .map_err(|e| format!("target {} is not usable: {e}", target.display()))?; + if !real.starts_with(&root) { + return Err(format!( + "target {} is outside the scanned directory; refusing to execute it", + target.display() + )); + } + if !real.is_file() { + return Err(format!("target {} is not a file", target.display())); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = real + .metadata() + .map_err(|e| format!("cannot stat target: {e}"))? + .permissions() + .mode(); + if mode & 0o111 == 0 { + return Err(format!("target {} is not executable", target.display())); + } + } + Ok(real) +} + +fn severity_value(s: &Severity) -> u8 { + match s { + Severity::Error => 3, + Severity::Warning => 2, + Severity::Info => 1, + Severity::Suggestion => 0, + } +} + +/// A repository-relative path for a finding: strips the scanned directory, +/// leaves anything else as given. +fn rel(dir: &Path, p: &Path) -> PathBuf { + let canon = dir.canonicalize().ok(); + for base in std::iter::once(dir.to_path_buf()).chain(canon) { + if let Ok(r) = p.strip_prefix(&base) { + return r.to_path_buf(); + } + } + p.to_path_buf() +} + +/// Findings for an `assay` report. Candidates already applied are not findings. +fn assay_findings(dir: &Path, report: &AssayReport) -> (Vec, usize) { + let mut out = Vec::new(); + let mut unlocated = 0; + for c in &report.candidates { + if c.status == "already-applied" { + continue; + } + if c.target_hits.is_empty() { + unlocated += 1; + continue; + } + for hit in &c.target_hits { + let f = Finding::new( + BotId::Panicbot, + &format!("PB-ASSAY-{}", c.id.to_uppercase()), + Severity::Info, + &format!( + "unproven pattern has a proven drop-in equivalent: {} ({}); status {}", + c.proven_name, c.proof_backing, c.status + ), + ) + .with_rule_name("Proven-library substitution candidate") + .with_category("static-analysis/proven-substitution") + .with_triangle_tier(TriangleTier::Substitute) + .with_confidence(c.confidence) + .with_file(rel(dir, &hit.file)) + .with_line(hit.line as usize) + .with_metadata(serde_json::json!({ + "panic_attack_subcommand": "assay", + "candidate": c.id, + "status": c.status, + })); + out.push(f); + } + } + (out, unlocated) +} + +/// Findings for an `assault`/`ambush` report. They name the target program. +fn dynamic_findings(mode: &str, target_rel: &Path, report: &DynamicReport) -> Vec { + let mut out = Vec::new(); + let base = |rule: &str, sev: Severity, msg: String| { + Finding::new(BotId::Panicbot, rule, sev, &msg) + .with_category("dynamic-analysis") + .with_triangle_tier(TriangleTier::Control) + .with_confidence(0.9) + .with_file(target_rel.to_path_buf()) + .with_metadata(serde_json::json!({ "panic_attack_subcommand": mode })) + }; + for r in &report.attack_results { + if r.skipped { + continue; + } + if !r.crashes.is_empty() { + out.push( + base( + "PB-DYN-CRASH", + Severity::Error, + format!( + "program crashed under the {} {mode} ({} crash(es))", + r.axis, + r.crashes.len() + ), + ) + .with_rule_name("Program crash under attack"), + ); + } + for s in &r.signatures_detected { + let kind = s + .get("signature_type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + out.push( + base( + "PB-DYN-SIGNATURE", + Severity::Warning, + format!("bug signature {kind} detected under the {} {mode}", r.axis), + ) + .with_rule_name("Bug signature under attack"), + ); + } + if !r.success && r.crashes.is_empty() { + out.push( + base( + "PB-DYN-FAIL", + Severity::Warning, + format!( + "program failed under the {} {mode} (exit code {})", + r.axis, + r.exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + ) + .with_rule_name("Program failure under attack"), + ); + } + } + out +} + +/// The finding for an adjudicated verdict, if it is not a pass. +fn verdict_finding(target_rel: &Path, v: &AdjudicateVerdict) -> Option { + let sev = match v.verdict.as_str() { + "fail" => Severity::Warning, + "warn" => Severity::Info, + _ => return None, + }; + let why: Vec<&str> = v + .priorities + .iter() + .filter(|p| p.level != "info") + .take(3) + .map(|p| p.message.as_str()) + .collect(); + Some( + Finding::new( + BotId::Panicbot, + "PB-ADJUDICATE-VERDICT", + sev, + &format!("adjudicated verdict {}: {}", v.verdict, why.join("; ")), + ) + .with_rule_name("Adjudicated campaign verdict") + .with_category("dynamic-analysis") + .with_triangle_tier(TriangleTier::Control) + .with_confidence(0.8) + .with_file(target_rel.to_path_buf()) + .with_metadata(serde_json::json!({ "panic_attack_subcommand": "adjudicate" })), + ) +} + +/// Run the suite against `dir` with the real panic-attack binary. +pub fn run_suite(dir: &Path, config: &PanicbotConfig, opts: &SuiteOptions) -> SuiteRun { + run_suite_with(&PanicAttack, dir, config, opts) +} + +/// Run the suite against `dir` with `engine`. +pub fn run_suite_with( + engine: &dyn Engine, + dir: &Path, + config: &PanicbotConfig, + opts: &SuiteOptions, +) -> SuiteRun { + let start = Instant::now(); + let mut parts: Vec = Vec::new(); + let mut findings: Vec = Vec::new(); + let mut assail_report: Option = None; + let mut dynamic_reports: Vec = Vec::new(); + let mut target_rel: Option = None; + let mut scratch: Option = None; + let min = config.min_severity.threshold(); + + // The part's timeout: the per-command timeout, capped by what is left. + let slot = |start: &Instant| -> Option { + let left = config.budget.checked_sub(start.elapsed())?; + if left.is_zero() { + None + } else { + Some(config.timeout.min(left)) + } + }; + + for cmd in Subcommand::ALL { + let t0 = Instant::now(); + let mut push = |status: PartStatus, detail: String, n: usize| { + parts.push(Part { + command: cmd, + status, + detail, + findings: n, + wall_ms: t0.elapsed().as_millis(), + }); + }; + + if !config.allowed_commands.allows(cmd) { + push( + PartStatus::Denied, + "denied by the repository's bot directives".into(), + 0, + ); + continue; + } + + match cmd { + Subcommand::Assail | Subcommand::Assay | Subcommand::Diagnostics => { + let Some(timeout) = slot(&start) else { + push(PartStatus::Error, budget_msg(config), 0); + continue; + }; + match cmd { + Subcommand::Assail => match engine.assail(dir, timeout) { + Ok(report) => { + let fs = translator::translate_all(&report.weak_points, config); + let n = fs.len(); + findings.extend(fs); + assail_report = Some(report); + push(PartStatus::Ran, String::new(), n); + } + Err(e) => push(PartStatus::Error, format!("{e:#}"), 0), + }, + Subcommand::Assay => match engine.assay(dir, timeout) { + Ok(report) => { + let (fs, unlocated) = assay_findings(dir, &report); + let fs: Vec = fs + .into_iter() + .filter(|f| severity_value(&f.severity) >= min) + .collect(); + let n = fs.len(); + findings.extend(fs); + let detail = if unlocated > 0 { + format!("{unlocated} candidate(s) without a located file not reported as findings") + } else { + String::new() + }; + push(PartStatus::Ran, detail, n); + } + Err(e) => push(PartStatus::Error, format!("{e:#}"), 0), + }, + _ => match engine.diagnostics(timeout) { + Ok(d) if d.healthy => push(PartStatus::Ran, d.version.clone(), 0), + Ok(d) => push( + PartStatus::Error, + format!( + "panic-attack reports itself unhealthy ({}): {}", + d.version, + d.messages.join("; ") + ), + 0, + ), + Err(e) => push(PartStatus::Error, format!("{e:#}"), 0), + }, + } + } + Subcommand::Assault | Subcommand::Ambush => { + let Some(target) = &opts.target else { + push(PartStatus::NotApplicable, no_target_msg(), 0); + continue; + }; + if let Some(why) = dynamic_refusal(opts) { + push(PartStatus::Error, why, 0); + continue; + } + let program = match resolve_target(dir, target) { + Ok(p) => p, + Err(why) => { + push(PartStatus::Error, why, 0); + continue; + } + }; + let Some(timeout) = slot(&start) else { + push(PartStatus::Error, budget_msg(config), 0); + continue; + }; + if scratch.is_none() { + match Scratch::new() { + Ok(s) => scratch = Some(s), + Err(e) => { + push(PartStatus::Error, format!("{e:#}"), 0); + continue; + } + } + } + let Some(sc) = scratch.as_ref() else { + push(PartStatus::Error, "scratch directory unavailable".into(), 0); + continue; + }; + let keep = sc.path().join(format!("{}.json", cmd.name())); + let trel = rel(dir, &program); + match engine.dynamic(cmd.name(), &program, dir, timeout, &keep) { + Ok(report) => { + let fs: Vec = dynamic_findings(cmd.name(), &trel, &report) + .into_iter() + .filter(|f| severity_value(&f.severity) >= min) + .collect(); + let n = fs.len(); + findings.extend(fs); + dynamic_reports.push(keep); + target_rel = Some(trel); + let skipped = report.attack_results.iter().filter(|r| r.skipped).count(); + let detail = if skipped > 0 { + format!("{skipped} axis result(s) skipped by panic-attack") + } else { + String::new() + }; + push(PartStatus::Ran, detail, n); + } + Err(e) => push(PartStatus::Error, format!("{e:#}"), 0), + } + } + Subcommand::Attack => push( + PartStatus::NotApplicable, + "covered by assault: assault runs the attack engine on every axis, \ + so a single-axis attack would only repeat it" + .into(), + 0, + ), + Subcommand::Amuck | Subcommand::Abduct | Subcommand::Axial => push( + PartStatus::NotApplicable, + format!( + "{} needs one target file and an exec program (or reports it accepts) \ + and panicbot has no source for either; not run", + cmd.name() + ), + 0, + ), + Subcommand::Adjudicate => { + if dynamic_reports.is_empty() { + push( + PartStatus::NotApplicable, + "no dynamic report to adjudicate: no dynamic part ran".into(), + 0, + ); + continue; + } + let Some(timeout) = slot(&start) else { + push(PartStatus::Error, budget_msg(config), 0); + continue; + }; + match engine.adjudicate(&dynamic_reports, timeout) { + Ok(v) => { + let fs: Vec = target_rel + .as_deref() + .and_then(|t| verdict_finding(t, &v)) + .into_iter() + .filter(|f| severity_value(&f.severity) >= min) + .collect(); + let n = fs.len(); + findings.extend(fs); + push(PartStatus::Ran, format!("verdict {}", v.verdict), n); + } + Err(e) => push(PartStatus::Error, format!("{e:#}"), 0), + } + } + } + } + + SuiteRun { + parts, + findings, + assail: assail_report, + wall_ms: start.elapsed().as_millis(), + } +} + +fn budget_msg(config: &PanicbotConfig) -> String { + format!( + "the {}s suite budget was spent before this part could start", + config.budget.as_secs() + ) +} + +fn no_target_msg() -> String { + "no target program: panicbot does not build the scanned repository and none was \ + given (--target PATH)" + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AllowedCommands; + use crate::scanner::{AdjudicatePriority, AssayCandidate, AssayHit, AxisResult}; + use std::cell::RefCell; + + /// A scripted panic-attack: records calls, fails on request. + struct Fake { + calls: RefCell>, + timeouts: RefCell>, + keeps: RefCell>, + adjudicated: RefCell>, + fail: Vec<&'static str>, + healthy: bool, + crash: bool, + verdict: &'static str, + } + + impl Fake { + fn new() -> Self { + Fake { + calls: RefCell::new(Vec::new()), + timeouts: RefCell::new(Vec::new()), + keeps: RefCell::new(Vec::new()), + adjudicated: RefCell::new(Vec::new()), + fail: Vec::new(), + healthy: true, + crash: true, + verdict: "fail", + } + } + fn failing(names: &[&'static str]) -> Self { + Fake { + fail: names.to_vec(), + ..Fake::new() + } + } + fn called(&self) -> Vec { + self.calls.borrow().clone() + } + fn note(&self, what: &str, timeout: Duration) -> Result<()> { + self.calls.borrow_mut().push(what.to_string()); + self.timeouts.borrow_mut().push(timeout); + if self.fail.contains(&what) { + anyhow::bail!("{what} blew up"); + } + Ok(()) + } + } + + impl Engine for Fake { + fn assail(&self, _dir: &Path, timeout: Duration) -> Result { + self.note("assail", timeout)?; + Ok(AssailReport { + program_path: PathBuf::from("x"), + language: "rust".into(), + frameworks: vec![], + weak_points: vec![scanner::WeakPoint { + category: "UnsafeCode".into(), + location: Some("src/ffi.rs:42".into()), + severity: "High".into(), + description: "unsafe".into(), + recommended_attack: vec![], + suppressed: false, + test_context: None, + }], + statistics: serde_json::Value::Null, + file_statistics: vec![], + recommended_attacks: vec![], + dependency_graph: serde_json::Value::Null, + taint_matrix: serde_json::Value::Null, + }) + } + fn assay(&self, dir: &Path, timeout: Duration) -> Result { + self.note("assay", timeout)?; + let cand = |id: &str, status: &str, hits: Vec| AssayCandidate { + id: id.into(), + proven_name: "SafePath".into(), + proof_backing: "proven: Operations.idr".into(), + status: status.into(), + confidence: 0.9, + target_hits: hits, + }; + Ok(AssayReport { + candidates: vec![ + cand( + "safe-path", + "offered", + vec![AssayHit { + file: dir.join("src/p.rs"), + line: 7, + }], + ), + cand("done", "already-applied", vec![]), + cand("nowhere", "offered", vec![]), + ], + catalogue_entries: 3, + notes: vec![], + }) + } + fn dynamic( + &self, + mode: &str, + _program: &Path, + _source: &Path, + timeout: Duration, + keep: &Path, + ) -> Result { + self.note(mode, timeout)?; + self.keeps.borrow_mut().push(keep.to_path_buf()); + Ok(DynamicReport { + attack_results: vec![ + AxisResult { + axis: "cpu".into(), + success: !self.crash, + skipped: false, + skip_reason: None, + exit_code: None, + crashes: if self.crash { + vec![serde_json::json!({})] + } else { + vec![] + }, + signatures_detected: vec![], + }, + AxisResult { + axis: "time".into(), + success: false, + skipped: true, + skip_reason: Some("n/a".into()), + exit_code: None, + crashes: vec![], + signatures_detected: vec![], + }, + ], + total_crashes: 1, + total_signatures: 0, + }) + } + fn adjudicate(&self, reports: &[PathBuf], timeout: Duration) -> Result { + self.note("adjudicate", timeout)?; + self.adjudicated + .borrow_mut() + .extend(reports.iter().cloned()); + Ok(AdjudicateVerdict { + verdict: self.verdict.into(), + processed_reports: reports.len(), + failed_reports: 0, + notes: vec![], + priorities: vec![AdjudicatePriority { + level: "high".into(), + message: "1 crashes detected".into(), + }], + }) + } + fn diagnostics(&self, timeout: Duration) -> Result { + self.note("diagnostics", timeout)?; + Ok(DiagnosticsReport { + version: "panic-attack 2.5.5".into(), + healthy: self.healthy, + supported_languages: vec![], + messages: vec!["bad".into()], + }) + } + } + + fn status_of(run: &SuiteRun, cmd: Subcommand) -> PartStatus { + run.parts + .iter() + .find(|p| p.command == cmd) + .map(|p| p.status) + .expect("every subcommand has a part") + } + + fn detail_of(run: &SuiteRun, cmd: Subcommand) -> String { + run.parts + .iter() + .find(|p| p.command == cmd) + .map(|p| p.detail.clone()) + .unwrap_or_default() + } + + /// A directory holding an executable `prog` and a non-executable `data`. + fn repo_with_program() -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt; + let d = tempfile::tempdir().unwrap(); + let prog = d.path().join("prog"); + std::fs::write(&prog, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&prog, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(d.path().join("data"), "x").unwrap(); + d + } + + fn opts(target: Option<&str>, event: Option<&str>) -> SuiteOptions { + SuiteOptions { + target: target.map(PathBuf::from), + ci_event: event.map(String::from), + } + } + + #[test] + fn default_config_runs_the_full_suite_and_reports_every_part() { + let fake = Fake::new(); + let run = run_suite_with( + &fake, + Path::new("."), + &PanicbotConfig::default(), + &opts(None, None), + ); + // The static class (a) parts run. + assert_eq!(fake.called(), ["assail", "assay", "diagnostics"]); + // Every class (a) subcommand has exactly one part, in suite order. + let names: Vec = run.parts.iter().map(|p| p.command).collect(); + assert_eq!(names, Subcommand::ALL); + assert!(run.parts.iter().all(|p| p.status != PartStatus::Denied)); + assert_eq!(status_of(&run, Subcommand::Assail), PartStatus::Ran); + assert_eq!(status_of(&run, Subcommand::Assay), PartStatus::Ran); + // With no target the dynamic parts say so: never a silent skip, never a pass. + for cmd in [Subcommand::Assault, Subcommand::Ambush] { + assert_eq!(status_of(&run, cmd), PartStatus::NotApplicable); + assert!(detail_of(&run, cmd).contains("no target program")); + } + for cmd in [ + Subcommand::Attack, + Subcommand::Amuck, + Subcommand::Abduct, + Subcommand::Axial, + ] { + assert_eq!(status_of(&run, cmd), PartStatus::NotApplicable); + assert!(!detail_of(&run, cmd).is_empty()); + } + assert_eq!( + status_of(&run, Subcommand::Adjudicate), + PartStatus::NotApplicable + ); + assert!(run.errored().is_empty()); + assert!(run.analysed()); + assert!(run.error_summary().is_none()); + } + + #[test] + fn deny_removes_one_subcommand_and_never_calls_it() { + let fake = Fake::new(); + let mut config = PanicbotConfig::default(); + config.allowed_commands.set(Subcommand::Assay, false); + let run = run_suite_with(&fake, Path::new("."), &config, &opts(None, None)); + assert_eq!(fake.called(), ["assail", "diagnostics"]); + assert_eq!(status_of(&run, Subcommand::Assay), PartStatus::Denied); + assert!(detail_of(&run, Subcommand::Assay).contains("directives")); + assert_eq!(status_of(&run, Subcommand::Assail), PartStatus::Ran); + } + + #[test] + fn explicit_allow_runs_only_what_it_names() { + let fake = Fake::new(); + let mut config = PanicbotConfig { + allowed_commands: AllowedCommands::none(), + ..PanicbotConfig::default() + }; + config.allowed_commands.set(Subcommand::Assay, true); + let run = run_suite_with(&fake, Path::new("."), &config, &opts(None, None)); + assert_eq!(fake.called(), ["assay"]); + assert_eq!(status_of(&run, Subcommand::Assail), PartStatus::Denied); + assert_eq!(status_of(&run, Subcommand::Assay), PartStatus::Ran); + } + + #[test] + fn a_failing_part_is_an_error_and_does_not_drop_the_others() { + let fake = Fake::failing(&["assay"]); + let run = run_suite_with( + &fake, + Path::new("."), + &PanicbotConfig::default(), + &opts(None, None), + ); + assert_eq!(status_of(&run, Subcommand::Assay), PartStatus::Error); + assert!(detail_of(&run, Subcommand::Assay).contains("assay blew up")); + // assail's finding is still there, and the failure is named in the summary. + assert_eq!( + run.findings.iter().filter(|f| f.rule_id == "PA004").count(), + 1 + ); + let summary = run.error_summary().expect("a failed part has a summary"); + assert!(summary.contains("assay: assay blew up"), "{summary}"); + assert!(summary.starts_with("1 of 10"), "{summary}"); + } + + #[test] + fn a_failing_assail_is_an_error_not_an_empty_run() { + let fake = Fake::failing(&["assail"]); + let run = run_suite_with( + &fake, + Path::new("."), + &PanicbotConfig::default(), + &opts(None, None), + ); + assert_eq!(status_of(&run, Subcommand::Assail), PartStatus::Error); + assert!(run.findings.iter().all(|f| f.rule_id != "PA004")); + assert!(run.error_summary().is_some()); + } + + #[test] + fn an_unhealthy_tool_is_an_error() { + let fake = Fake { + healthy: false, + ..Fake::new() + }; + let run = run_suite_with( + &fake, + Path::new("."), + &PanicbotConfig::default(), + &opts(None, None), + ); + assert_eq!(status_of(&run, Subcommand::Diagnostics), PartStatus::Error); + assert!(detail_of(&run, Subcommand::Diagnostics).contains("unhealthy")); + } + + #[test] + fn a_preflight_alone_is_not_an_analysis() { + let fake = Fake::new(); + let mut config = PanicbotConfig { + allowed_commands: AllowedCommands::none(), + ..PanicbotConfig::default() + }; + config.allowed_commands.set(Subcommand::Diagnostics, true); + let run = run_suite_with(&fake, Path::new("."), &config, &opts(None, None)); + assert_eq!(status_of(&run, Subcommand::Diagnostics), PartStatus::Ran); + assert!( + !run.analysed(), + "diagnostics alone must not read as a clean scan" + ); + } + + #[test] + fn nothing_allowed_means_nothing_analysed() { + let fake = Fake::new(); + let config = PanicbotConfig { + allowed_commands: AllowedCommands::none(), + ..PanicbotConfig::default() + }; + let run = run_suite_with(&fake, Path::new("."), &config, &opts(None, None)); + assert!(fake.called().is_empty()); + assert!(!run.analysed()); + assert!(run.parts.iter().all(|p| p.status == PartStatus::Denied)); + } + + #[test] + fn assay_findings_name_their_file_and_line() { + let fake = Fake::new(); + let dir = PathBuf::from("/repo"); + let run = run_suite_with(&fake, &dir, &PanicbotConfig::default(), &opts(None, None)); + let f: Vec<&Finding> = run + .findings + .iter() + .filter(|f| f.rule_id.starts_with("PB-ASSAY")) + .collect(); + assert_eq!( + f.len(), + 1, + "already-applied and location-less candidates are not findings" + ); + assert_eq!(f[0].rule_id, "PB-ASSAY-SAFE-PATH"); + assert_eq!(f[0].file.as_deref(), Some(Path::new("src/p.rs"))); + assert_eq!(f[0].line, Some(7)); + assert!( + detail_of(&run, Subcommand::Assay).contains("1 candidate(s) without a located file") + ); + } + + #[test] + fn the_severity_filter_applies_to_every_part() { + let fake = Fake::new(); + let config = PanicbotConfig { + min_severity: crate::config::MinSeverity::High, + ..PanicbotConfig::default() + }; + let run = run_suite_with(&fake, Path::new("/repo"), &config, &opts(None, None)); + // assay findings are info: below High, dropped by the same filter assail uses. + assert!(run + .findings + .iter() + .all(|f| !f.rule_id.starts_with("PB-ASSAY"))); + assert_eq!(status_of(&run, Subcommand::Assay), PartStatus::Ran); + } + + #[test] + fn dynamic_modes_with_a_target_run_and_feed_adjudicate() { + let repo = repo_with_program(); + let fake = Fake::new(); + let run = run_suite_with( + &fake, + repo.path(), + &PanicbotConfig::default(), + &opts(Some("prog"), Some("pull_request")), + ); + assert_eq!( + fake.called(), + [ + "assail", + "assay", + "assault", + "ambush", + "adjudicate", + "diagnostics" + ] + ); + assert_eq!(status_of(&run, Subcommand::Assault), PartStatus::Ran); + assert_eq!(status_of(&run, Subcommand::Ambush), PartStatus::Ran); + assert_eq!(status_of(&run, Subcommand::Adjudicate), PartStatus::Ran); + // Both dynamic reports were handed to adjudicate. + assert_eq!(fake.adjudicated.borrow().len(), 2); + assert_eq!(*fake.adjudicated.borrow(), *fake.keeps.borrow()); + // The crash and the verdict name the target, relative to the repo. + let crash: Vec<&Finding> = run + .findings + .iter() + .filter(|f| f.rule_id == "PB-DYN-CRASH") + .collect(); + assert_eq!(crash.len(), 2, "one per dynamic mode"); + for f in crash { + assert_eq!(f.file.as_deref(), Some(Path::new("prog"))); + assert_eq!(f.severity, Severity::Error); + } + let verdict: Vec<&Finding> = run + .findings + .iter() + .filter(|f| f.rule_id == "PB-ADJUDICATE-VERDICT") + .collect(); + assert_eq!(verdict.len(), 1); + assert_eq!(verdict[0].file.as_deref(), Some(Path::new("prog"))); + // A skipped axis is reported in the detail, not turned into a finding. + assert!(detail_of(&run, Subcommand::Assault).contains("1 axis result(s) skipped")); + } + + #[test] + fn a_passing_verdict_is_not_a_finding() { + let repo = repo_with_program(); + let fake = Fake { + verdict: "pass", + crash: false, + ..Fake::new() + }; + let run = run_suite_with( + &fake, + repo.path(), + &PanicbotConfig::default(), + &opts(Some("prog"), None), + ); + assert!(run + .findings + .iter() + .all(|f| f.rule_id != "PB-ADJUDICATE-VERDICT")); + assert!(detail_of(&run, Subcommand::Adjudicate).contains("verdict pass")); + } + + #[test] + fn privileged_triggers_refuse_to_execute_repository_code() { + let repo = repo_with_program(); + for ev in ["pull_request_target", "workflow_run"] { + let fake = Fake::new(); + let run = run_suite_with( + &fake, + repo.path(), + &PanicbotConfig::default(), + &opts(Some("prog"), Some(ev)), + ); + assert!(!fake.called().contains(&"assault".to_string()), "{ev}"); + assert!(!fake.called().contains(&"ambush".to_string()), "{ev}"); + assert_eq!(status_of(&run, Subcommand::Assault), PartStatus::Error); + assert!(detail_of(&run, Subcommand::Assault).contains(ev)); + // The static parts are unaffected. + assert_eq!(status_of(&run, Subcommand::Assail), PartStatus::Ran); + } + assert!(dynamic_refusal(&opts(None, Some("pull_request"))).is_none()); + assert!(dynamic_refusal(&opts(None, None)).is_none()); + } + + #[test] + fn a_target_that_is_missing_outside_or_not_executable_is_an_error() { + let repo = repo_with_program(); + let outside = repo_with_program(); + let outside_prog = outside.path().join("prog"); + for (label, target) in [ + ("missing", "nope".to_string()), + ("not executable", "data".to_string()), + ("outside the repo", outside_prog.display().to_string()), + ( + "traversal", + format!( + "../{}/prog", + outside.path().file_name().unwrap().to_string_lossy() + ), + ), + ] { + let fake = Fake::new(); + let run = run_suite_with( + &fake, + repo.path(), + &PanicbotConfig::default(), + &opts(Some(&target), None), + ); + assert_eq!( + status_of(&run, Subcommand::Assault), + PartStatus::Error, + "{label}" + ); + assert!( + !fake.called().contains(&"assault".to_string()), + "{label}: must not execute it" + ); + } + } + + #[test] + fn each_part_gets_the_smaller_of_the_timeout_and_the_remaining_budget() { + let fake = Fake::new(); + let config = PanicbotConfig { + timeout: Duration::from_secs(300), + budget: Duration::from_secs(100), + ..PanicbotConfig::default() + }; + let _ = run_suite_with(&fake, Path::new("."), &config, &opts(None, None)); + for t in fake.timeouts.borrow().iter() { + assert!(*t <= Duration::from_secs(100), "{t:?} exceeds the budget"); + } + let short = PanicbotConfig { + timeout: Duration::from_secs(5), + ..PanicbotConfig::default() + }; + let fake2 = Fake::new(); + let _ = run_suite_with(&fake2, Path::new("."), &short, &opts(None, None)); + for t in fake2.timeouts.borrow().iter() { + assert!( + *t <= Duration::from_secs(5), + "{t:?} exceeds the per-command timeout" + ); + } + } + + #[test] + fn a_spent_budget_is_an_error_per_part_not_a_silent_skip() { + let fake = Fake::new(); + let config = PanicbotConfig { + budget: Duration::ZERO, + ..PanicbotConfig::default() + }; + let run = run_suite_with(&fake, Path::new("."), &config, &opts(None, None)); + assert!(fake.called().is_empty()); + for cmd in [ + Subcommand::Assail, + Subcommand::Assay, + Subcommand::Diagnostics, + ] { + assert_eq!(status_of(&run, cmd), PartStatus::Error); + assert!(detail_of(&run, cmd).contains("budget")); + } + assert!(!run.analysed()); + } + + #[test] + fn every_finding_names_a_file() { + let repo = repo_with_program(); + let fake = Fake::new(); + let run = run_suite_with( + &fake, + repo.path(), + &PanicbotConfig::default(), + &opts(Some("prog"), None), + ); + assert!(!run.findings.is_empty()); + for f in &run.findings { + assert!( + f.file.is_some(), + "{} has no file (gitbot-fleet #668)", + f.rule_id + ); + } + } +} diff --git a/bots/panicbot/tests/envelope_parity_test.rs b/bots/panicbot/tests/envelope_parity_test.rs index db960233..aade6809 100644 --- a/bots/panicbot/tests/envelope_parity_test.rs +++ b/bots/panicbot/tests/envelope_parity_test.rs @@ -127,11 +127,170 @@ fn exit_0_without_a_valid_report_is_not_a_run() { } } +/// A panic-attack that answers every subcommand the full suite runs: +/// `assail` prints REAL, `assay` writes `ASSAY_JSON` to its `-o` path, +/// `--version` and `diagnostics` answer. +fn full_stub_body(assay_json: &str) -> String { + format!( + r#"case "$1" in + --version) echo "panic-attack 2.5.5" ;; + diagnostics) exit 0 ;; + assay) + out="" + while [ $# -gt 0 ]; do [ "$1" = "-o" ] && out="$2"; shift; done + cat > "$out" <<'J' +{assay_json} +J + ;; + *) cat <<'J' +{REAL} +J + ;; +esac +exit 0"# + ) +} + +const ASSAY_NONE: &str = r#"{"candidates":[],"catalogue_entries":2,"notes":[]}"#; + #[test] fn a_real_report_is_a_run_with_its_findings() { let bin = TempDir::new().unwrap(); - stub(bin.path(), &format!("cat <<'J'\n{REAL}\nJ\nexit 0")); + stub(bin.path(), &full_stub_body(ASSAY_NONE)); let o = scan(bin.path(), tree_with_timeout(None).path()); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); let v: serde_json::Value = serde_json::from_slice(&o.stdout).expect("a findings array"); assert!(v.is_array() && v.as_array().unwrap().len() == 1, "{v}"); } + +#[test] +fn the_full_suite_runs_by_default_and_assay_findings_name_their_file() { + let bin = TempDir::new().unwrap(); + let assay = r#"{"candidates":[{"id":"safe-path","proven_name":"SafePath","proof_backing":"proven: Operations.idr","status":"offered","confidence":0.9,"target_hits":[{"file":"a.rs","line":1,"snippet":"x"}]}],"catalogue_entries":2,"notes":[]}"#; + stub(bin.path(), &full_stub_body(assay)); + let o = scan(bin.path(), tree_with_timeout(None).path()); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + let v: serde_json::Value = serde_json::from_slice(&o.stdout).expect("a findings array"); + let rules: Vec<&str> = v + .as_array() + .unwrap() + .iter() + .map(|f| f["rule_id"].as_str().unwrap()) + .collect(); + assert!( + rules.contains(&"PA005") && rules.contains(&"PB-ASSAY-SAFE-PATH"), + "{rules:?}" + ); + for f in v.as_array().unwrap() { + assert_eq!(f["file"], "a.rs", "{f}"); + } + // The per-part table is on stderr: assail, assay and diagnostics ran; the rest say why not. + let err = String::from_utf8_lossy(&o.stderr); + assert!( + err.contains("assay") && err.contains("not_applicable"), + "{err}" + ); +} + +#[test] +fn a_part_that_fails_makes_the_run_an_error_object_never_a_findings_array() { + // assail works, assay writes no report: the failure is loud and named, and + // the findings of the part that ran are carried, not dropped. + let bin = TempDir::new().unwrap(); + stub( + bin.path(), + &format!("case \"$1\" in --version) echo x ;; diagnostics) exit 0 ;; assay) exit 0 ;; *) cat <<'J'\n{REAL}\nJ\n;; esac"), + ); + let o = scan(bin.path(), tree_with_timeout(None).path()); + assert_eq!( + o.status.code(), + Some(3), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + let stderr = String::from_utf8_lossy(&o.stderr); + let first = stderr.lines().find(|l| !l.trim().is_empty()).unwrap(); + assert!( + first.contains("1 of 10") && first.contains("assay"), + "{first}" + ); + let v: serde_json::Value = serde_json::from_slice(&o.stdout).expect("an object"); + assert_eq!(v["status"], "error"); + assert!(v["reason"].as_str().unwrap().contains("assay"), "{v}"); + assert_eq!(v["findings"].as_array().unwrap().len(), 1); + assert!(v["parts"] + .as_array() + .unwrap() + .iter() + .any(|p| p["command"] == "assay" && p["status"] == "error")); +} + +fn tree_with_directive(content: &str) -> TempDir { + let t = TempDir::new().unwrap(); + fs::write(t.path().join("a.rs"), "fn main() {}\n").unwrap(); + let d = t.path().join(".machine_readable/bot_directives"); + fs::create_dir_all(&d).unwrap(); + fs::write(d.join("panicbot.a2ml"), content).unwrap(); + t +} + +#[test] +fn a_directive_that_denies_everything_is_not_run_never_a_clean_run() { + let bin = TempDir::new().unwrap(); + stub(bin.path(), &full_stub_body(ASSAY_NONE)); + let o = scan(bin.path(), tree_with_directive("allow = []\n").path()); + assert_eq!( + o.status.code(), + Some(4), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + let v: serde_json::Value = serde_json::from_slice(&o.stdout).expect("an object"); + assert_eq!(v["status"], "not_run"); + assert!(v["reason"].as_str().unwrap().contains("denied"), "{v}"); +} + +#[test] +fn a_directive_file_keeps_its_scope_and_is_not_widened_to_assay() { + // The shipped directive shape: static trio allowed. The stub has no assay + // answer at all, so any assay call would turn the run into an error. + let bin = TempDir::new().unwrap(); + stub(bin.path(), &format!("case \"$1\" in --version) echo x ;; diagnostics) exit 0 ;; assay) echo assay was called >&2; exit 9 ;; *) cat <<'J'\n{REAL}\nJ\n;; esac")); + let tree = tree_with_directive( + "allow = [\"assail\", \"adjudicate\", \"diagnostics\"]\ndeny = [\"attack\", \"assault\", \"ambush\", \"amuck\", \"abduct\", \"axial\"]\n", + ); + let o = scan(bin.path(), tree.path()); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + let err = String::from_utf8_lossy(&o.stderr); + assert!(!err.contains("assay was called"), "{err}"); + assert!(err.contains("denied"), "{err}"); +} + +#[test] +fn a_broken_directive_stops_the_run_and_never_falls_back_to_the_full_suite() { + let bin = TempDir::new().unwrap(); + stub(bin.path(), &full_stub_body(ASSAY_NONE)); + let o = scan( + bin.path(), + tree_with_directive("allow = [ not toml\n").path(), + ); + assert!(!o.status.success()); + assert!( + o.stdout.is_empty(), + "stdout: {}", + String::from_utf8_lossy(&o.stdout) + ); + assert!(String::from_utf8_lossy(&o.stderr).contains("bot directives unreadable")); +} diff --git a/docs/REPORT-BACK.adoc b/docs/REPORT-BACK.adoc index e4d38205..02cdc9d1 100644 --- a/docs/REPORT-BACK.adoc +++ b/docs/REPORT-BACK.adoc @@ -105,8 +105,12 @@ grading can match them against the diff. | panicbot | `panicbot --format json scan ` -| Wraps `panic-attack assail`; panicbot's own translator maps critical → error, - high → warning. The grid already carries a direct panic-attack row from the +| Runs the **full panic-attack suite** unless the repository's bot directives + say otherwise (owner ruling 2026-11-13): `assail`, `assay`, and the dynamic + modes when a built `--target` is given (none of the CI callers gives one, so + there they report `not_applicable` with the reason). The inventory of every + panic-attack subcommand and why each is in or out is in `bots/panicbot/src/suite.rs`. + panicbot's own translator maps critical → error, high → warning. The grid already carries a direct panic-attack row from the Hypatia lane, so a repository that enrols panicbot sees the same tool twice, once through the bot's categorisation. `fleet` mode is *not* used: it writes a `.panicbot/*.a2ml` debt register into the scanned tree, and A2ML is retired. @@ -114,7 +118,15 @@ grading can match them against the diff. scan that did not really run (panic-attack not installed, timed out, exited non-zero, or exited 0 without a report that has `weak_points` and a positive `statistics.total_lines`) exits non-zero with no findings array, which the - driver writes as `error` with the reason, never as an empty `ran`. + driver writes as `error` with the reason, never as an empty `ran`. A suite + where one part failed prints `{"status": "error", "reason": ..., "findings": + [...]}` and exits 3 (the failed part is named in the reason; the findings of + the parts that ran are carried in the envelope file, but the grid reads only the + status), and a run where no part analysed anything (everything denied or not + applicable) prints `status: not_run` and exits 4. A directive file that exists + but cannot be parsed stops the run (error), never a fallback to the full suite. + A repository with a directive file keeps exactly what it allows and denies; one + without gets the full suite. | ponsbot | `ponsbot --format json scan ` diff --git a/scripts/fleet-report-back.sh b/scripts/fleet-report-back.sh index de5a61fe..0b4cfa08 100755 --- a/scripts/fleet-report-back.sh +++ b/scripts/fleet-report-back.sh @@ -448,6 +448,9 @@ write_envelope() { # member status reason findings_json elif .rule_id | startswith("PA7") then "panic-attack:resource-leak" elif .rule_id | startswith("PA8") then "panic-attack:unchecked-error" elif .rule_id | startswith("PA9") then "panic-attack:path-traversal" + elif .rule_id | startswith("PB-ASSAY") then "panic-attack:proven-substitution" + elif .rule_id | startswith("PB-DYN") then "panic-attack:dynamic-attack" + elif .rule_id | startswith("PB-ADJUDICATE") then "panic-attack:dynamic-verdict" else "panic-attack:unknown" end) elif $member == "ponsbot" then "pons:unclassified" elif $member == "rhodibot" then "rsr:file-presence" @@ -486,6 +489,27 @@ def rel: if type != "string" then "" else JQ } +# panicbot prints either a findings array (every allowed part that applies ran) +# or, when the suite did not complete, {"status": "error"|"not_run", "reason": +# "...", "findings": [...]} and exits 3 or 4: a part failed (error; the findings +# of the parts that ran are carried in the envelope file, though the grid reads +# only the status and reason) or no part analysed the repository (not_run). Only +# the object form can reach the grid as a non-ran status from this adapter; an +# array is a complete run, and anything else falls through to translate_or_error. +translate_panicbot() { # member + local m="$1" st reason findings + if [[ "$ADAPTER_RC" -ne 124 ]] \ + && jq -e 'type == "object" and (.status | IN("error", "not_run")) and (.reason | type == "string" and length > 0)' "$ADAPTER_OUT" >/dev/null 2>&1; then + st=$(jq -r .status "$ADAPTER_OUT"); reason=$(jq -r '.reason[0:240]' "$ADAPTER_OUT") + findings=$(jq -c --arg repo "$repo" "$(relativize_filter) [ (.findings // [])[] | $FINDING ]" "$ADAPTER_OUT" 2> /dev/null) || findings='[]' + [ -n "$findings" ] || findings='[]' + write_envelope "$m" "$st" "$reason" "$findings" + cleanup_adapter + return + fi + translate_or_error "$m" "$SHAPE_ARRAY [ .[] | $FINDING ]" +} + # Run an adapter, capturing stdout/stderr separately. Non-zero exit with valid # JSON on stdout is still a run: several adapters exit 1 when their findings # would block a release, and that is a finding, not a failure. @@ -619,11 +643,14 @@ run_member() { message: ("\(.name): \(.severity)" + (if (.details // []) | length > 0 then " — " + ((.details // []) | join("; ")) else "" end)) } | .file |= rel ]' ;; panicbot) - # `scan` translates panic-attack output into fleet Findings and writes - # nothing. (`fleet` mode would write a .panicbot/*.a2ml debt register - # into the scanned tree; A2ML is retired, so it is not used.) + # `scan` runs the allowed panic-attack suite (full by default; the repo's + # bot directives narrow it) and translates it into fleet Findings, writing + # nothing. A part that failed or a run that analysed nothing arrives as + # an error/not_run object, see translate_panicbot. (`fleet` mode would write + # a .panicbot/*.a2ml debt register into the scanned tree; A2ML is retired, + # so it is not used.) run_adapter "$bin" --format json scan "$repo" - translate_or_error "$m" "$SHAPE_ARRAY [ .[] | $FINDING ]" ;; + translate_panicbot "$m" ;; ponsbot) # `scan` runs pons at the pinned commit (bots/ponsbot/canon/pin.toml) and # writes nothing into the tree. Gating is NOT decided here: the grid reads diff --git a/tests/fleet-report-back-test.sh b/tests/fleet-report-back-test.sh index e36ac549..dfb10ff1 100755 --- a/tests/fleet-report-back-test.sh +++ b/tests/fleet-report-back-test.sh @@ -558,6 +558,43 @@ exit 0 with an empty object|echo "{}" exit 0 with a non-array|echo "{\"weak_points\":[]}" CASES +# panicbot suite results (full-suite ruling 2026-11-13): the object form. +# A part that failed is an error row with its reason; the findings of the +# parts that ran are carried in the envelope file. A run that analysed nothing +# is not_run. A findings array is a complete run, and PB- rule ids get their +# own evidence classes. +pstub panicbot "$PAN"' +cat < 0' | sed 's/true/yes/;s/false/no/')" +pstub panicbot "$PAN"' +cat < Date: Fri, 13 Nov 2026 11:31:19 +0000 Subject: [PATCH 6/8] fix(panicbot): absolute child paths, loud diagnostics failure, all-skipped dynamic is not a run - assay/assault/ambush/adjudicate run with a scratch cwd; every path argument is now canonicalised first, so 'panicbot scan .' no longer surveys an empty tree and reports clean. - A diagnostics timeout, spawn failure or signal death is an error part, not a healthy report synthesised from --version. - A dynamic report whose axis results are all skipped is not_applicable with the skip reasons, not ran; adjudicate is then not fed it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkERqsQWMogHRpM2z8 --- bots/panicbot/src/scanner.rs | 55 +++++--- bots/panicbot/src/suite.rs | 102 ++++++++++++--- bots/panicbot/tests/envelope_parity_test.rs | 135 ++++++++++++++++++++ 3 files changed, 259 insertions(+), 33 deletions(-) diff --git a/bots/panicbot/src/scanner.rs b/bots/panicbot/src/scanner.rs index 9868332d..42f4f727 100644 --- a/bots/panicbot/src/scanner.rs +++ b/bots/panicbot/src/scanner.rs @@ -333,18 +333,28 @@ pub fn run_diagnostics(timeout: Duration) -> Result { .take(80) .collect(); - // If diagnostics subcommand exists, use it - match invoke_panic_attack(&["diagnostics"], timeout) { - Ok(output) => { - if let Ok(report) = serde_json::from_str::(&output) { - return Ok(report); - } - } - Err(_) => { - // diagnostics subcommand may not exist in older versions - tracing::debug!("panic-attack diagnostics subcommand not available, using fallback"); - } + // `diagnostics` is run under the same enforced timeout. A hang (timeout) or + // a spawn failure is an ERROR: a tool that cannot answer in time is not + // healthy, and must never be papered over with a `--version` report. A + // process killed by a signal (a crash) is an error for the same reason. + // + // A normal exit is different. panic-attack 2.5.x `diagnostics` is a + // human-readable self-check of its own source tree (plain text, exit 1 in + // any other directory), not a machine report, so a clean exit whose output + // is not a diagnostics JSON document falls back to the `--version` check. + let diag = run_panic_attack_raw(&["diagnostics"], timeout) + .context("Failed to execute panic-attack diagnostics")?; + if diag.status.code().is_none() { + bail!( + "panic-attack diagnostics was terminated by a signal ({}): {}", + diag.status, + first_diagnostic(&diag.stderr) + ); } + if let Ok(report) = serde_json::from_str::(extract_json(&diag.stdout)) { + return Ok(report); + } + tracing::debug!("panic-attack diagnostics output is not JSON, using the --version fallback"); // Fallback: construct a minimal diagnostics report Ok(DiagnosticsReport { @@ -581,10 +591,23 @@ fn run_to_report(args: &[&str], scratch: &Scratch, timeout: Duration) -> Result< }) } +/// Resolve a path argument to an absolute, symlink-free string. +/// +/// The child runs with the scratch directory as its working directory, so a +/// relative path (`panicbot scan .`) would be read against the scratch dir: +/// an empty tree, reported clean. Every path handed to a child goes through +/// here, before the working directory changes. A path that cannot be resolved +/// is an error, never passed on as given. +fn absolute(p: &Path) -> Result { + let c = std::fs::canonicalize(p) + .with_context(|| format!("Cannot resolve path {} to an absolute path", p.display()))?; + Ok(c.display().to_string()) +} + /// Run `panic-attack assay `: the proven-library substitution survey. pub fn run_assay(target: &Path, timeout: Duration) -> Result { let scratch = Scratch::new()?; - let t = target.display().to_string(); + let t = absolute(target)?; let value = run_to_report(&["assay", &t], &scratch, timeout)?; parse_assay_report(value) } @@ -617,8 +640,8 @@ pub fn run_dynamic( bail!("run_dynamic: '{mode}' is not a dynamic mode panicbot runs"); } let scratch = Scratch::new()?; - let p = program.display().to_string(); - let s = source.display().to_string(); + let p = absolute(program)?; + let s = absolute(source)?; let d = axis_seconds.to_string(); let value = run_to_report( &[mode, &p, "--source", &s, "-d", &d, "--quiet"], @@ -651,7 +674,9 @@ pub fn parse_dynamic_report(value: serde_json::Value) -> Result { pub fn run_adjudicate_verdict(reports: &[PathBuf], timeout: Duration) -> Result { let scratch = Scratch::new()?; let mut args: Vec = vec!["adjudicate".to_string()]; - args.extend(reports.iter().map(|r| r.display().to_string())); + for r in reports { + args.push(absolute(r)?); + } let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); let value = run_to_report(&arg_refs, &scratch, timeout)?; parse_adjudicate_verdict(value, reports.len()) diff --git a/bots/panicbot/src/suite.rs b/bots/panicbot/src/suite.rs index c64f397c..f5cb113d 100644 --- a/bots/panicbot/src/suite.rs +++ b/bots/panicbot/src/suite.rs @@ -609,6 +609,28 @@ pub fn run_suite_with( let keep = sc.path().join(format!("{}.json", cmd.name())); let trel = rel(dir, &program); match engine.dynamic(cmd.name(), &program, dir, timeout, &keep) { + Ok(report) if report.attack_results.iter().all(|r| r.skipped) => { + // Every axis was skipped: nothing was attacked, so + // this is not a clean run. Not applicable, with why. + let reasons: Vec<&str> = report + .attack_results + .iter() + .filter_map(|r| r.skip_reason.as_deref()) + .collect(); + let why = if reasons.is_empty() { + "no reason given".to_string() + } else { + reasons.join("; ") + }; + push( + PartStatus::NotApplicable, + format!( + "panic-attack skipped all {} axis result(s), nothing was attacked: {why}", + report.attack_results.len() + ), + 0, + ); + } Ok(report) => { let fs: Vec = dynamic_findings(cmd.name(), &trel, &report) .into_iter() @@ -713,6 +735,7 @@ mod tests { fail: Vec<&'static str>, healthy: bool, crash: bool, + all_skipped: bool, verdict: &'static str, } @@ -726,6 +749,7 @@ mod tests { fail: Vec::new(), healthy: true, crash: true, + all_skipped: false, verdict: "fail", } } @@ -809,30 +833,42 @@ mod tests { self.note(mode, timeout)?; self.keeps.borrow_mut().push(keep.to_path_buf()); Ok(DynamicReport { - attack_results: vec![ - AxisResult { + attack_results: if self.all_skipped { + vec![AxisResult { axis: "cpu".into(), - success: !self.crash, - skipped: false, - skip_reason: None, - exit_code: None, - crashes: if self.crash { - vec![serde_json::json!({})] - } else { - vec![] - }, - signatures_detected: vec![], - }, - AxisResult { - axis: "time".into(), success: false, skipped: true, - skip_reason: Some("n/a".into()), + skip_reason: Some("no runnable program".into()), exit_code: None, crashes: vec![], signatures_detected: vec![], - }, - ], + }] + } else { + vec![ + AxisResult { + axis: "cpu".into(), + success: !self.crash, + skipped: false, + skip_reason: None, + exit_code: None, + crashes: if self.crash { + vec![serde_json::json!({})] + } else { + vec![] + }, + signatures_detected: vec![], + }, + AxisResult { + axis: "time".into(), + success: false, + skipped: true, + skip_reason: Some("n/a".into()), + exit_code: None, + crashes: vec![], + signatures_detected: vec![], + }, + ] + }, total_crashes: 1, total_signatures: 0, }) @@ -1132,6 +1168,36 @@ mod tests { assert!(detail_of(&run, Subcommand::Assault).contains("1 axis result(s) skipped")); } + #[test] + fn a_dynamic_report_with_every_axis_skipped_is_not_applicable_never_ran() { + let repo = repo_with_program(); + let fake = Fake { + all_skipped: true, + ..Fake::new() + }; + let run = run_suite_with( + &fake, + repo.path(), + &PanicbotConfig::default(), + &opts(Some("prog"), Some("pull_request")), + ); + for cmd in [Subcommand::Assault, Subcommand::Ambush] { + assert_eq!(status_of(&run, cmd), PartStatus::NotApplicable); + let d = detail_of(&run, cmd); + assert!( + d.contains("nothing was attacked") && d.contains("no runnable program"), + "{d}" + ); + } + // Nothing dynamic ran, so there is nothing to adjudicate. + assert_eq!( + status_of(&run, Subcommand::Adjudicate), + PartStatus::NotApplicable + ); + assert!(!fake.called().contains(&"adjudicate".to_string())); + assert!(run.errored().is_empty()); + } + #[test] fn a_passing_verdict_is_not_a_finding() { let repo = repo_with_program(); diff --git a/bots/panicbot/tests/envelope_parity_test.rs b/bots/panicbot/tests/envelope_parity_test.rs index aade6809..73e93934 100644 --- a/bots/panicbot/tests/envelope_parity_test.rs +++ b/bots/panicbot/tests/envelope_parity_test.rs @@ -294,3 +294,138 @@ fn a_broken_directive_stops_the_run_and_never_falls_back_to_the_full_suite() { ); assert!(String::from_utf8_lossy(&o.stderr).contains("bot directives unreadable")); } + +/// A stub that records the arguments and working directory of every call into +/// `calls.log` beside itself, and answers like `full_stub_body`. +fn recording_stub_body(assay_json: &str) -> String { + format!( + r#"echo "$PWD|$*" >> "$(dirname "$0")/calls.log" +case "$1" in + --version) echo "panic-attack 2.5.5" ;; + diagnostics) exit 0 ;; + assay|assault|ambush) + out="" + a=("$@") + for ((i=0;i<${{#a[@]}};i++)); do [ "${{a[$i]}}" = "-o" ] && out="${{a[$((i+1))]}}"; done + case "$1" in + assay) cat > "$out" <<'J' +{assay_json} +J + ;; + *) cat > "$out" <<'J' +{{"attack_results":[{{"axis":"cpu","success":true,"skipped":false}}],"total_crashes":0,"total_signatures":0}} +J + ;; + esac ;; + adjudicate) + out="" + a=("$@") + for ((i=0;i<${{#a[@]}};i++)); do [ "${{a[$i]}}" = "-o" ] && out="${{a[$((i+1))]}}"; done + echo '{{"verdict":"pass","processed_reports":2,"failed_reports":0}}' > "$out" ;; + *) cat <<'J' +{REAL} +J + ;; +esac +exit 0"# + ) +} + +#[test] +fn relative_scan_dir_and_target_reach_the_children_as_absolute_paths() { + // `panicbot scan .` runs the children in a scratch directory. A relative + // path would be read there (an empty tree) and report clean. + let bin = TempDir::new().unwrap(); + stub(bin.path(), &recording_stub_body(ASSAY_NONE)); + let tree = tree_with_timeout(None); + let prog = tree.path().join("prog"); + fs::write(&prog, "#!/bin/sh\nexit 0\n").unwrap(); + fs::set_permissions(&prog, fs::Permissions::from_mode(0o755)).unwrap(); + let home = TempDir::new().unwrap(); + let o = Command::new(env!("CARGO_BIN_EXE_panicbot")) + .env_clear() + .env("PATH", format!("{}:/usr/bin:/bin", bin.path().display())) + .env("HOME", home.path()) + .env("RUST_LOG", "off") + .current_dir(tree.path()) + .args(["--format", "json", "scan", ".", "--target", "prog"]) + .output() + .unwrap(); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + let log = fs::read_to_string(bin.path().join("calls.log")).unwrap(); + let canon = tree.path().canonicalize().unwrap(); + let canon = canon.display().to_string(); + let mut seen = 0; + for line in log.lines() { + let (_, args) = line.split_once('|').unwrap(); + let words: Vec<&str> = args.split(' ').collect(); + match words[0] { + "assay" => { + seen += 1; + assert_eq!(words[1], canon, "assay target: {line}"); + } + "assault" | "ambush" => { + seen += 1; + assert_eq!(words[1], format!("{canon}/prog"), "program: {line}"); + let i = words.iter().position(|w| *w == "--source").unwrap(); + assert_eq!(words[i + 1], canon, "source: {line}"); + } + _ => {} + } + } + assert_eq!(seen, 3, "assay, assault, ambush were all called: {log}"); +} + +#[test] +fn a_hung_diagnostics_is_an_error_part_not_a_healthy_report() { + let bin = TempDir::new().unwrap(); + stub( + bin.path(), + &format!( + "case \"$1\" in --version) echo 'panic-attack 2.5.5' ;; diagnostics) exec sleep 30 ;; assay) out=\"\"; while [ $# -gt 0 ]; do [ \"$1\" = -o ] && out=\"$2\"; shift; done; echo '{ASSAY_NONE}' > \"$out\" ;; *) cat <<'J'\n{REAL}\nJ\n;; esac" + ), + ); + let t = std::time::Instant::now(); + let o = scan(bin.path(), tree_with_timeout(Some(2)).path()); + assert!(t.elapsed().as_secs() < 20, "diagnostics must be timed"); + assert_eq!( + o.status.code(), + Some(3), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + let v: serde_json::Value = serde_json::from_slice(&o.stdout).expect("an object"); + assert_eq!(v["status"], "error"); + assert!(v["parts"] + .as_array() + .unwrap() + .iter() + .any(|p| p["command"] == "diagnostics" + && p["status"] == "error" + && p["detail"].as_str().unwrap().contains("did not finish"))); +} + +#[test] +fn a_crashed_diagnostics_is_an_error_part() { + let bin = TempDir::new().unwrap(); + stub( + bin.path(), + &format!( + "case \"$1\" in --version) echo 'panic-attack 2.5.5' ;; diagnostics) kill -SEGV $$ ;; assay) out=\"\"; while [ $# -gt 0 ]; do [ \"$1\" = -o ] && out=\"$2\"; shift; done; echo '{ASSAY_NONE}' > \"$out\" ;; *) cat <<'J'\n{REAL}\nJ\n;; esac" + ), + ); + let o = scan(bin.path(), tree_with_timeout(None).path()); + assert_eq!(o.status.code(), Some(3)); + let v: serde_json::Value = serde_json::from_slice(&o.stdout).expect("an object"); + assert!(v["parts"] + .as_array() + .unwrap() + .iter() + .any(|p| p["command"] == "diagnostics" + && p["status"] == "error" + && p["detail"].as_str().unwrap().contains("signal"))); +} From b9c7b38f585e21a63ecf7c62c705e8432b2fd738 Mon Sep 17 00:00:00 2001 From: CLAUDE <355856132+HoTT-stuff@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:33:18 +0000 Subject: [PATCH 7/8] fix(report-back): an adapter exiting other than 0 or 1 is an error even with a valid array translate_or_error accepted any well-formed findings array regardless of exit status, so a crash or signal that left a valid array on stdout read as ran. Only exit 0 and 1 are a completed scan. The existing 'exit 2 with []' case asserted the opposite and now uses exit 1; exit 2, 3, 9 and 137 are new error cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkERqsQWMogHRpM2z8 --- scripts/fleet-report-back.sh | 9 ++++++++- tests/fleet-report-back-test.sh | 14 +++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/fleet-report-back.sh b/scripts/fleet-report-back.sh index 0b4cfa08..0b28bf3a 100755 --- a/scripts/fleet-report-back.sh +++ b/scripts/fleet-report-back.sh @@ -544,7 +544,14 @@ translate_or_error() { # member jq-program write_envelope "$m" error "adapter exceeded the ${timeout_s}s timeout" '[]' elif findings=$(jq -c --arg repo "$repo" "$(relativize_filter) $prog" "$ADAPTER_OUT" 2> "$ADAPTER_ERR.jq") \ && [ -n "$findings" ] && jq -e 'type == "array"' <<<"$findings" >/dev/null 2>&1; then - write_envelope "$m" ran "" "$findings" + if [[ "$ADAPTER_RC" -ne 0 && "$ADAPTER_RC" -ne 1 ]]; then + # An adapter reports findings with exit 0 or 1. Any other status (a crash, + # a signal, a usage error) is not a run, even when stdout happens to hold + # a well-formed findings array. + write_envelope "$m" error "adapter exited with status $ADAPTER_RC: $(first_err_line)" '[]' + else + write_envelope "$m" ran "" "$findings" + fi elif grep -q 'unexpected output shape' "$ADAPTER_ERR.jq" 2>/dev/null; then # The shape guard fired: valid JSON, but not what this adapter documents. write_envelope "$m" error "adapter output had an $(grep -o 'unexpected output shape: [^(]*' "$ADAPTER_ERR.jq" | head -n1 | sed 's/ *$//')" '[]' diff --git a/tests/fleet-report-back-test.sh b/tests/fleet-report-back-test.sh index dfb10ff1..aba4fd15 100755 --- a/tests/fleet-report-back-test.sh +++ b/tests/fleet-report-back-test.sh @@ -18,7 +18,7 @@ # 2. every adapter's output translates to {rule_id, severity, file, line, # message} with repository-relative paths (absolute and "./" stripped). # 3. missing binary, crash without JSON, and timeout are `error` WITH a -# reason; non-zero exit WITH valid JSON is still `ran`. +# reason; exit 1 WITH valid JSON is still `ran`, any other non-zero status is `error`. # 4. echidnabot and sustainabot are `not_run` with a reason; an unknown # member is `error`; the driver never writes `not_applicable`. # 5. the envelope is key-sorted compact JSON with one final LF, carrying @@ -324,10 +324,18 @@ run "$TMP/timeout-json" --enrolment "" --member cipherbot check "a timeout cannot become a clean scan even with valid JSON" \ 'error|adapter exceeded the 300s timeout|0' \ "$(field "$TMP/timeout-json/cipherbot.json" '"\(.status)|\(.reason)|\(.findings | length)"')" -stub cipherbot 'echo "[]"; exit 2' +stub cipherbot 'echo "[]"; exit 1' run "$TMP/nonzero-json" --enrolment "" --member cipherbot -check "valid empty JSON with a nonzero exit is still a completed scan" 'ran|0|null' \ +check "valid empty JSON with exit 1 (findings) is still a completed scan" 'ran|0|null' \ "$(field "$TMP/nonzero-json/cipherbot.json" '"\(.status)|\(.findings | length)|\(.reason)"')" +for rc in 2 3 9 137; do + stub cipherbot "echo '[{\"rule_id\":\"X\",\"severity\":\"info\",\"message\":\"m\"}]'; exit $rc" + run "$TMP/rc-$rc" --enrolment "" --member cipherbot + check "a valid findings array with exit $rc is an error, not a run" \ + "error|0" "$(field "$TMP/rc-$rc/cipherbot.json" '"\(.status)|\(.findings | length)"')" + check "...and the reason names exit $rc" yes \ + "$(field "$TMP/rc-$rc/cipherbot.json" .reason | grep -q "^adapter exited with status $rc" && echo yes || echo no)" +done ECHIDNA_URL=https://engine.invalid run "$TMP/engine" --enrolment "" --member echidnabot check "a configured engine still reports the unsupported dispatch honestly" 'not_run|0' \ From 2570abb8129f86bfba3a77f22e53783a0ba90536 Mon Sep 17 00:00:00 2001 From: CLAUDE <355856132+HoTT-stuff@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:33:49 +0000 Subject: [PATCH 8/8] fix(ponsbot): resolve the pons executable to an absolute path before the cwd changes pons runs with the scanned directory as its cwd, so a relative PONS_BIN or a relative PATH entry was looked up against the scanned tree. find_pons now canonicalises whatever it locates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZkERqsQWMogHRpM2z8 --- bots/ponsbot/src/scanner.rs | 19 ++++++++-- bots/ponsbot/tests/adapter_test.rs | 56 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/bots/ponsbot/src/scanner.rs b/bots/ponsbot/src/scanner.rs index bd84b006..32251384 100644 --- a/bots/ponsbot/src/scanner.rs +++ b/bots/ponsbot/src/scanner.rs @@ -46,7 +46,7 @@ pub fn find_pons() -> Result { if let Some(p) = std::env::var_os(PONS_BIN_ENV).filter(|p| !p.is_empty()) { let p = PathBuf::from(p); return if is_executable(&p) { - Ok(p) + absolute(p) } else { Err(format!( "{PONS_BIN_ENV} is set to {} but that is not an executable file", @@ -58,7 +58,7 @@ pub fn find_pons() -> Result { for dir in std::env::split_paths(&path) { let cand = dir.join("pons"); if is_executable(&cand) { - return Ok(cand); + return absolute(cand); } } } @@ -66,13 +66,26 @@ pub fn find_pons() -> Result { for rel in [".pons/bin/pons", ".cargo/bin/pons"] { let cand = Path::new(&home).join(rel); if is_executable(&cand) { - return Ok(cand); + return absolute(cand); } } } Err("pons is not installed (not on PATH, not at ~/.pons/bin/pons or ~/.cargo/bin/pons, PONS_BIN unset)".into()) } +/// Resolve the located executable to an absolute path NOW. pons is spawned with +/// the scanned directory as its working directory, so a relative `PONS_BIN` or +/// a relative `PATH` entry would otherwise be looked up against the scanned +/// tree (not found, or worse, a binary the tree itself supplies). +fn absolute(p: PathBuf) -> Result { + std::fs::canonicalize(&p).map_err(|e| { + format!( + "cannot resolve the pons executable {} to an absolute path: {e}", + p.display() + ) + }) +} + fn is_executable(p: &Path) -> bool { use std::os::unix::fs::PermissionsExt; p.metadata() diff --git a/bots/ponsbot/tests/adapter_test.rs b/bots/ponsbot/tests/adapter_test.rs index 5657db3a..e418d2ca 100644 --- a/bots/ponsbot/tests/adapter_test.rs +++ b/bots/ponsbot/tests/adapter_test.rs @@ -299,3 +299,59 @@ fn real_pons_end_to_end_when_available() { assert_eq!(o.status.code(), Some(2)); assert_eq!(out_json(&o)["status"], "error"); } + +/// pons is spawned with the scanned directory as its cwd, so a relative +/// `PONS_BIN`, or a relative `PATH` entry, must be made absolute first. +fn relative_run(env: &[(&str, &str)], cwd: &Path, scan: &str) -> Output { + let mut c = Command::new(env!("CARGO_BIN_EXE_ponsbot")); + c.env_clear().env("PATH", "/usr/bin:/bin"); + for (k, v) in env { + c.env(k, v); + } + c.current_dir(cwd) + .args(["--format", "json", "scan", scan]) + .output() + .unwrap() +} + +#[test] +fn a_relative_pons_bin_is_resolved_before_the_cwd_changes() { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("bin")).unwrap(); + fs::create_dir(root.path().join("tree")).unwrap(); + fs::write(root.path().join("tree/a.js"), "if (true) {}\n").unwrap(); + stub( + &root.path().join("bin"), + &emit(&report(&format!("[{WARN}]")), 0), + ); + let o = relative_run(&[("PONS_BIN", "bin/pons")], root.path(), "tree"); + assert_eq!( + o.status.code(), + Some(0), + "stdout: {} stderr: {}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ); + assert_eq!(out_json(&o)[0]["rule_id"], "constant-condition"); +} + +#[test] +fn a_relative_path_entry_is_resolved_before_the_cwd_changes() { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("bin")).unwrap(); + fs::create_dir(root.path().join("tree")).unwrap(); + fs::write(root.path().join("tree/a.js"), "if (true) {}\n").unwrap(); + stub( + &root.path().join("bin"), + &emit(&report(&format!("[{WARN}]")), 0), + ); + let o = relative_run(&[("PATH", "bin:/usr/bin:/bin")], root.path(), "tree"); + assert_eq!( + o.status.code(), + Some(0), + "stdout: {} stderr: {}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ); + assert_eq!(out_json(&o)[0]["rule_id"], "constant-condition"); +}