Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions bots/panicbot/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//! CLI Interface — clap-based command-line argument parsing for panicbot.
//!
//! Subcommands:
//! - `scan <dir>` — Run panic-attack assail and translate findings
//! - `scan <dir>` — Run the allowed panic-attack suite and translate findings
//! - `fleet <dir>` — Run as a gitbot-fleet member
//! - `report <dir>` — Generate A2ML debt register

Expand Down Expand Up @@ -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<PathBuf>,

/// Wall-clock budget for the whole suite, in seconds (default 240).
#[arg(long)]
budget_seconds: Option<u64>,
},

/// Run as a gitbot-fleet member.
Expand All @@ -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<PathBuf>,
},

/// Generate A2ML debt register for a directory.
Expand Down
284 changes: 265 additions & 19 deletions bots/panicbot/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String>,

/// Per-category confidence overrides.
/// If absent, `translator::default_confidence()` is used.
pub confidence_overrides: Vec<ConfidenceOverride>,
}

/// 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> {
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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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());
}

Expand Down Expand Up @@ -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"]);
}
}
Loading
Loading