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/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 6dd45caa..42f4f727 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.
@@ -182,23 +186,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 +195,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)"
+ );
+ }
+
+ serde_json::from_value::(report_value.clone())
+ .context("panic-attack assail exited 0 without a valid report (unexpected shape)")
+}
- // Direct parse as AssailReport
- serde_json::from_str::(json)
- .context("Failed to parse panic-attack assail output as JSON")
+/// 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.
@@ -273,31 +315,46 @@ 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();
-
- // 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");
- }
+ .chars()
+ .take(80)
+ .collect();
+
+ // `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 {
@@ -306,7 +363,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()],
})
@@ -364,6 +421,296 @@ 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("?")
+ )
+ })
+}
+
+/// 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 = absolute(target)?;
+ 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 = absolute(program)?;
+ let s = absolute(source)?;
+ 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()];
+ 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())
+}
+
+/// 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.
@@ -404,15 +751,42 @@ 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 {
+ 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()?;
tracing::debug!(
@@ -423,52 +797,93 @@ fn invoke_panic_attack(args: &[&str], timeout: Duration) -> Result {
let mut cmd = Command::new(&binary);
cmd.args(args);
- cmd.arg("--output-format");
- cmd.arg("json");
-
- // Capture stdout and stderr
+ 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());
- 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 (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);
+ 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 +912,7 @@ mod tests {
"program_path": "/tmp/test",
"language": "rust",
"frameworks": [],
+ "statistics": {"total_lines": 120},
"weak_points": [
{
"category": "UnsafeCode",
@@ -508,8 +924,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 +1053,190 @@ 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());
+ }
+}
+
+#[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..f5cb113d
--- /dev/null
+++ b/bots/panicbot/src/suite.rs
@@ -0,0 +1,1345 @@
+// 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) 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()
+ .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,
+ all_skipped: 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,
+ all_skipped: false,
+ 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: if self.all_skipped {
+ vec![AxisResult {
+ axis: "cpu".into(),
+ success: false,
+ skipped: true,
+ 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,
+ })
+ }
+ 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_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();
+ 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
new file mode 100644
index 00000000..73e93934
--- /dev/null
+++ b/bots/panicbot/tests/envelope_parity_test.rs
@@ -0,0 +1,431 @@
+// 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);
+ }
+}
+
+/// 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(), &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"));
+}
+
+/// 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")));
+}
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..588485cb
--- /dev/null
+++ b/bots/ponsbot/src/main.rs
@@ -0,0 +1,76 @@
+// 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 = 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);
+ 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..af8204de
--- /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`.
+///
+/// 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.
+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() -> 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
new file mode 100644
index 00000000..32251384
--- /dev/null
+++ b/bots/ponsbot/src/scanner.rs
@@ -0,0 +1,390 @@
+// 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) {
+ absolute(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 absolute(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 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()
+ .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 (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);
+ 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..e418d2ca
--- /dev/null
+++ b/bots/ponsbot/tests/adapter_test.rs
@@ -0,0 +1,357 @@
+// 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().map(|p| p.commit).unwrap_or_default()
+}
+
+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");
+}
+
+/// 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");
+}
diff --git a/docs/REPORT-BACK.adoc b/docs/REPORT-BACK.adoc
index 25ebc21f..9eae5daf 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`
-| 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.
@@ -117,11 +117,32 @@ that emits a path-less finding; rhodibot and seambot already comply.
| 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.
+ 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`. 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 `
+| 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..3ff508c0 100644
--- a/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc
+++ b/docs/migrations/scanner-cutover-ponsbot-panicbot.adoc
@@ -2,20 +2,25 @@
= Scanner cutover: pons-asinorum and panic-attack → ponsbot and panicbot
:toc: left
:toclevels: 2
-:revdate: 2026-11-12
+:revdate: 2026-11-13
-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 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.
== 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 +29,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 +80,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 +125,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 +146,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 +174,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 +209,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; 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
@@ -201,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
|===
@@ -215,19 +238,62 @@ 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.
+
+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.
+
+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.)
-D0. Confirm the reading in §1.1: scanners remain external engines behind
- adapters; only the direct lanes retire.
+=== 8.2 Decided (owner rulings, 2026-11-13)
-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?
+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.
@@ -235,10 +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?
diff --git a/scripts/fleet-report-back-build.sh b/scripts/fleet-report-back-build.sh
index 790c6ad1..f5610dfc 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 3cdea75a..bc63424e 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 ;;
@@ -174,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|seambot|glambot|accessibilitybot|cipherbot) echo "$1" ;;
+ rhodibot|panicbot|ponsbot|seambot|glambot|accessibilitybot|cipherbot) echo "$member" ;;
*) echo "" ;;
esac
}
@@ -185,6 +187,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 +387,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
@@ -440,7 +448,11 @@ 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"
elif $member == "cipherbot" then "cipherbot:crypto-hygiene"
elif $member == "glambot" or $member == "accessibilitybot" then "wcag:accessibility"
@@ -477,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.
@@ -511,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/ *$//')" '[]'
@@ -536,6 +576,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
@@ -584,11 +650,22 @@ 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
+ # 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..c8c1d45d 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 35115936..a8f77b92 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
@@ -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
@@ -324,10 +326,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' \
@@ -465,6 +475,177 @@ 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() { 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": {
+ "ponsbot": { "enrolled": true, "gating": true },
+ "panicbot": { "enrolled": true } } }
+JSON
+runp() { # out-dir extra-args...
+ 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"'
+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
+
+# 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 </dev/null )
+ 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)"
+ 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)"