From 704f437cce6384218805c5c4a31aba56471e9e7b Mon Sep 17 00:00:00 2001 From: pitboss Date: Sun, 17 May 2026 04:24:36 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0012 (20260517T044708Z-e058) --- src/chain/mod.rs | 5 +- src/chain/reverify.rs | 188 +++++++++++++++++++++++++++++++++++----- tests/chain_reverify.rs | 117 +++++++++++++++++++++++-- 3 files changed, 280 insertions(+), 30 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 67bcd6b3..39861634 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -47,8 +47,9 @@ pub use finding::{ChainFinding, ChainMember, ChainSeverity, ChainSink}; pub use impact::{IMPACT_LATTICE, ImpactCategory, ImpactRule, lookup_impact}; #[cfg(feature = "dynamic")] pub use reverify::{ - ChainReverifyResult, CompositeReverifier, DefaultCompositeReverifier, reverify_chain, - reverify_chain_with, reverify_top_chains, reverify_top_chains_with, + ChainReverifyResult, ChainStepSpec, CompositeReverifier, DefaultCompositeReverifier, + chain_step_specs, reverify_chain, reverify_chain_with, reverify_top_chains, + reverify_top_chains_with, }; pub use score::{ChainScoreConfig, category_weight, min_score_default, score_path}; pub use search::{ChainSearchConfig, find_chains, find_chains_with_reach}; diff --git a/src/chain/reverify.rs b/src/chain/reverify.rs index c18905dc..bd6e3d67 100644 --- a/src/chain/reverify.rs +++ b/src/chain/reverify.rs @@ -18,6 +18,20 @@ //! `Inconclusive` drops the chain one bucket and records a reason; //! every other status leaves the severity intact. //! +//! # Per-member harness specs +//! +//! Both the default reverifier and out-of-tree callers consume +//! [`chain_step_specs`] to materialise one [`HarnessSpec`] per +//! `chain.members` slot. The helper looks each member up in the +//! caller-supplied `member_diags` slice by +//! [`crate::chain::edges::FindingRef::stable_hash`] and reuses +//! [`HarnessSpec::from_finding_full`] so the chain's per-step specs +//! match what the per-finding verifier would have derived. This is +//! the API-shape sub-task of the Phase 26 live-execution split: it +//! lets callers (today: the default reverifier; tomorrow: a live +//! sandbox composer) inspect whether every step is drivable before +//! committing to a build / run pass. +//! //! # Cost control //! //! Re-verification is opt-in via @@ -36,9 +50,12 @@ //! be exercised without a live sandbox backend. use crate::chain::finding::{ChainFinding, ChainSeverity}; +use crate::commands::scan::Diag; +use crate::dynamic::spec::HarnessSpec; use crate::dynamic::verify::VerifyOptions; -use crate::evidence::{InconclusiveReason, VerifyResult, VerifyStatus}; +use crate::evidence::{InconclusiveReason, UnsupportedReason, VerifyResult, VerifyStatus}; use crate::surface::SurfaceMap; +use std::collections::HashMap; /// Outcome of composite re-verification for a single chain. /// @@ -71,18 +88,90 @@ impl ChainReverifyResult { } } +/// Per-member harness-spec derivation result. +/// +/// One entry per `chain.members` slot, in chain order. `member_hash` +/// is copied from the [`crate::chain::edges::FindingRef::stable_hash`]; +/// `result` is the outcome of running [`HarnessSpec::from_finding_full`] +/// against the matching [`Diag`] from the caller's slice. +/// +/// A member whose hash has no diag match records +/// [`UnsupportedReason::NoFlowSteps`] so the caller can distinguish +/// "spec derivation failed" from "diag missing from the scan input". +#[derive(Debug, Clone)] +pub struct ChainStepSpec { + pub member_hash: u64, + pub result: Result, +} + +/// Derive one [`HarnessSpec`] per chain member, in chain order. +/// +/// Looks each member up in `member_diags` by stable hash (zero-hash +/// diags are skipped — the pre-`compute_stable_hash` placeholder +/// produced by tests and synthetic harnesses). Members whose hash has +/// no diag match record [`UnsupportedReason::NoFlowSteps`] so the +/// caller can tell the difference between "spec derivation failed" and +/// "diag missing from the scan input". +/// +/// The function does **not** run anything: it returns derived specs so +/// the caller (today: [`DefaultCompositeReverifier`]; tomorrow: a live +/// sandbox composer) can decide whether to commit to a build / run +/// pass. Used as the API-shape half of the Phase 26 live-execution +/// split — see the crate-level docs for the wider design. +pub fn chain_step_specs( + chain: &ChainFinding, + member_diags: &[Diag], + opts: &VerifyOptions, +) -> Vec { + let mut by_hash: HashMap = HashMap::with_capacity(member_diags.len()); + for d in member_diags { + if d.stable_hash != 0 { + by_hash.insert(d.stable_hash, d); + } + } + chain + .members + .iter() + .map(|m| { + let result = match by_hash.get(&m.stable_hash).copied() { + Some(d) => HarnessSpec::from_finding_full( + d, + opts.verify_all_confidence, + opts.summaries.as_deref(), + opts.callgraph.as_deref(), + ), + None => Err(UnsupportedReason::NoFlowSteps), + }; + ChainStepSpec { + member_hash: m.stable_hash, + result, + } + }) + .collect() +} + /// Pluggable composite-reverifier surface. /// /// Production callers use [`DefaultCompositeReverifier`] (which drives /// the per-step harness compose path). Tests substitute a stub that /// returns canned [`VerifyResult`]s so the downgrade-and-record /// machinery can be exercised without a live sandbox backend. +/// +/// `member_diags` carries the [`Diag`]s that produced `chain.members`, +/// in any order — implementations look them up by +/// [`crate::chain::edges::FindingRef::stable_hash`] via +/// [`chain_step_specs`]. Threading the slice (instead of a pre-built +/// `HashMap`) mirrors how +/// [`crate::dynamic::verify::VerifyOptions::summaries`] flows: +/// callers hold the full project diag list and the trait surface +/// stays free of cross-coupling. pub trait CompositeReverifier { /// Run the composite dynamic re-verification for `chain` and return /// the resulting verdict. fn reverify( &self, chain: &ChainFinding, + member_diags: &[Diag], surface: &SurfaceMap, opts: &VerifyOptions, ) -> VerifyResult; @@ -90,29 +179,36 @@ pub trait CompositeReverifier { /// Phase 26 default composite reverifier. /// -/// The composite-harness composer walks `chain.members`, calls -/// [`crate::dynamic::lang::compose_chain_step`] for each member's -/// language to assemble a per-step harness, and threads the previous -/// step's stdout into the next via -/// [`crate::dynamic::lang::ChainStepHarness::PREV_OUTPUT_ENV`]. +/// The composite-harness composer walks `chain.members`, derives one +/// [`HarnessSpec`] per member via [`chain_step_specs`], and (in a +/// future session) will call +/// [`crate::dynamic::lang::compose_chain_step`] per step to assemble a +/// per-step harness with `NYX_PREV_OUTPUT` threading. /// -/// Today the default reverifier surfaces `Inconclusive(BackendInsufficient)` -/// when invoked: chain composer scaffolding lands in Phase 26 but the -/// live composite execution path depends on the per-emitter probe-shim -/// splicing that several language emitters still defer (see the -/// Phase 06 / 15 / 16 follow-ups in `.pitboss/play/deferred.md`). -/// Callers that need a deterministic outcome (tests, CI) use -/// [`reverify_chain_with`] with a stubbed reverifier. +/// Today the default reverifier surfaces +/// `Inconclusive(BackendInsufficient)` when invoked, but the `detail` +/// field reports how many of `chain.members` produced a derivable +/// [`HarnessSpec`] so operators (and the [`reverify_top_chains`] +/// caller) can see the spec-derivation coverage before the live +/// execution path lands. Callers that need a deterministic outcome +/// (tests, CI) use [`reverify_chain_with`] with a stubbed reverifier. pub struct DefaultCompositeReverifier; impl CompositeReverifier for DefaultCompositeReverifier { fn reverify( &self, chain: &ChainFinding, + member_diags: &[Diag], _surface: &SurfaceMap, - _opts: &VerifyOptions, + opts: &VerifyOptions, ) -> VerifyResult { let finding_id = format!("chain-{:016x}", chain.stable_hash); + let specs = chain_step_specs(chain, member_diags, opts); + let total = specs.len(); + let derived = specs.iter().filter(|s| s.result.is_ok()).count(); + let detail = format!( + "composite chain re-verification not yet wired for live runs; derived {derived}/{total} harness specs" + ); VerifyResult { finding_id, status: VerifyStatus::Inconclusive, @@ -122,10 +218,7 @@ impl CompositeReverifier for DefaultCompositeReverifier { backend: "composite-chain".to_owned(), oracle_kind: "chain-step-harness".to_owned(), }), - detail: Some( - "composite chain re-verification not yet wired for live runs; per-emitter probe-shim splicing pending — see Phase 26 deferred follow-ups" - .to_owned(), - ), + detail: Some(detail), attempts: vec![], toolchain_match: None, differential: None, @@ -142,10 +235,11 @@ impl CompositeReverifier for DefaultCompositeReverifier { /// Wraps [`reverify_chain_with`] with the [`DefaultCompositeReverifier`]. pub fn reverify_chain( chain: &mut ChainFinding, + member_diags: &[Diag], surface: &SurfaceMap, opts: &VerifyOptions, ) -> ChainReverifyResult { - reverify_chain_with(chain, surface, opts, &DefaultCompositeReverifier) + reverify_chain_with(chain, member_diags, surface, opts, &DefaultCompositeReverifier) } /// Inject-the-reverifier flavour of [`reverify_chain`]. @@ -156,13 +250,14 @@ pub fn reverify_chain( /// the transition. pub fn reverify_chain_with( chain: &mut ChainFinding, + member_diags: &[Diag], surface: &SurfaceMap, opts: &VerifyOptions, reverifier: &dyn CompositeReverifier, ) -> ChainReverifyResult { let chain_hash = chain.stable_hash; let severity_before = chain.severity; - let verdict = reverifier.reverify(chain, surface, opts); + let verdict = reverifier.reverify(chain, member_diags, surface, opts); chain.apply_dynamic_verdict(verdict.clone()); ChainReverifyResult { chain_hash, @@ -180,21 +275,34 @@ pub fn reverify_chain_with( /// so the slice prefix is already the right set). `top_n == 0` /// short-circuits the entire pass. /// +/// `member_diags` is the full project diag list — each chain's +/// reverifier looks up its own constituent diags by stable hash via +/// [`chain_step_specs`]. +/// /// Mutates `chains` in place; returns one [`ChainReverifyResult`] per /// re-verified chain. Chains past the `top_n` cut keep their /// pre-existing `dynamic_verdict` / `reverify_reason` / `severity`. pub fn reverify_top_chains( chains: &mut [ChainFinding], + member_diags: &[Diag], surface: &SurfaceMap, opts: &VerifyOptions, top_n: usize, ) -> Vec { - reverify_top_chains_with(chains, surface, opts, top_n, &DefaultCompositeReverifier) + reverify_top_chains_with( + chains, + member_diags, + surface, + opts, + top_n, + &DefaultCompositeReverifier, + ) } /// Inject-the-reverifier flavour of [`reverify_top_chains`]. pub fn reverify_top_chains_with( chains: &mut [ChainFinding], + member_diags: &[Diag], surface: &SurfaceMap, opts: &VerifyOptions, top_n: usize, @@ -207,7 +315,7 @@ pub fn reverify_top_chains_with( chains .iter_mut() .take(bound) - .map(|c| reverify_chain_with(c, surface, opts, reverifier)) + .map(|c| reverify_chain_with(c, member_diags, surface, opts, reverifier)) .collect() } @@ -266,6 +374,7 @@ mod tests { fn reverify( &self, _chain: &ChainFinding, + _member_diags: &[Diag], _surface: &SurfaceMap, _opts: &VerifyOptions, ) -> VerifyResult { @@ -280,6 +389,7 @@ mod tests { let opts = VerifyOptions::default(); let result = reverify_chain_with( &mut chain, + &[], &surface, &opts, &StubReverifier(VerifyStatus::Confirmed), @@ -298,6 +408,7 @@ mod tests { let opts = VerifyOptions::default(); let result = reverify_chain_with( &mut chain, + &[], &surface, &opts, &StubReverifier(VerifyStatus::Inconclusive), @@ -316,6 +427,7 @@ mod tests { let opts = VerifyOptions::default(); let result = reverify_chain_with( &mut chain, + &[], &surface, &opts, &StubReverifier(VerifyStatus::Inconclusive), @@ -337,6 +449,7 @@ mod tests { let opts = VerifyOptions::default(); let results = reverify_top_chains_with( &mut chains, + &[], &surface, &opts, 0, @@ -359,6 +472,7 @@ mod tests { let opts = VerifyOptions::default(); let results = reverify_top_chains_with( &mut chains, + &[], &surface, &opts, 2, @@ -378,7 +492,7 @@ mod tests { let mut chain = mk_chain(99, ChainSeverity::Critical, ImpactCategory::Rce); let surface = SurfaceMap::new(); let opts = VerifyOptions::default(); - let result = reverify_chain(&mut chain, &surface, &opts); + let result = reverify_chain(&mut chain, &[], &surface, &opts); assert_eq!(result.verdict.status, VerifyStatus::Inconclusive); assert!(matches!( result.verdict.inconclusive_reason, @@ -387,4 +501,32 @@ mod tests { // Severity dropped one bucket because the default is inconclusive. assert_eq!(chain.severity, ChainSeverity::High); } + + #[test] + fn default_reverifier_detail_reports_spec_derivation_coverage() { + let mut chain = mk_chain(0xDE, ChainSeverity::High, ImpactCategory::SessionHijack); + // No diags threaded in — every member should fall through to + // `NoFlowSteps` and the detail string should report 0/N. + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let result = reverify_chain(&mut chain, &[], &surface, &opts); + let detail = result.verdict.detail.as_deref().expect("detail populated"); + assert!( + detail.contains("0/1"), + "detail must report 0/1 specs derived for a single-member chain with no diags; got {detail:?}" + ); + } + + #[test] + fn chain_step_specs_reports_no_flow_steps_for_missing_diag() { + let chain = mk_chain(7, ChainSeverity::Medium, ImpactCategory::InfoDisclosure); + let opts = VerifyOptions::default(); + let specs = chain_step_specs(&chain, &[], &opts); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].member_hash, 7); + assert!(matches!( + specs[0].result, + Err(UnsupportedReason::NoFlowSteps) + )); + } } diff --git a/tests/chain_reverify.rs b/tests/chain_reverify.rs index e45dae35..3329f4ff 100644 --- a/tests/chain_reverify.rs +++ b/tests/chain_reverify.rs @@ -21,11 +21,12 @@ use nyx_scanner::chain::edges::FindingRef; use nyx_scanner::chain::finding::{ChainFinding, ChainSeverity, ChainSink}; use nyx_scanner::chain::impact::ImpactCategory; use nyx_scanner::chain::reverify::{ - CompositeReverifier, reverify_chain_with, reverify_top_chains_with, + CompositeReverifier, chain_step_specs, reverify_chain_with, reverify_top_chains_with, }; +use nyx_scanner::commands::scan::Diag; use nyx_scanner::dynamic::lang::{ChainStepHarness, compose_chain_step}; use nyx_scanner::dynamic::verify::VerifyOptions; -use nyx_scanner::evidence::{InconclusiveReason, VerifyResult, VerifyStatus}; +use nyx_scanner::evidence::{InconclusiveReason, UnsupportedReason, VerifyResult, VerifyStatus}; use nyx_scanner::surface::{SourceLocation, SurfaceMap}; use nyx_scanner::symbol::Lang; @@ -85,6 +86,7 @@ impl CompositeReverifier for StubReverifier { fn reverify( &self, _chain: &ChainFinding, + _member_diags: &[Diag], _surface: &SurfaceMap, _opts: &VerifyOptions, ) -> VerifyResult { @@ -99,7 +101,7 @@ fn composite_confirms_keeps_severity_and_attaches_verdict() { let opts = VerifyOptions::default(); let stub = StubReverifier(verdict(VerifyStatus::Confirmed, None)); - let result = reverify_chain_with(&mut chain, &surface, &opts, &stub); + let result = reverify_chain_with(&mut chain, &[], &surface, &opts, &stub); assert!(!result.was_downgraded(), "Confirmed must not downgrade"); assert_eq!(result.severity_before, ChainSeverity::Critical); assert_eq!(result.severity_after, ChainSeverity::Critical); @@ -119,7 +121,7 @@ fn composite_inconclusive_downgrades_one_bucket_and_records_reason() { Some(InconclusiveReason::BuildFailed), )); - let result = reverify_chain_with(&mut chain, &surface, &opts, &stub); + let result = reverify_chain_with(&mut chain, &[], &surface, &opts, &stub); assert!(result.was_downgraded(), "Inconclusive must downgrade"); assert_eq!(result.severity_before, ChainSeverity::Critical); assert_eq!(result.severity_after, ChainSeverity::High); @@ -151,7 +153,7 @@ fn top_n_limits_composite_reverification() { let opts = VerifyOptions::default(); let stub = StubReverifier(verdict(VerifyStatus::Confirmed, None)); - let results = reverify_top_chains_with(&mut chains, &surface, &opts, 2, &stub); + let results = reverify_top_chains_with(&mut chains, &[], &surface, &opts, 2, &stub); assert_eq!(results.len(), 2); assert!(chains[0].dynamic_verdict.is_some()); assert!(chains[1].dynamic_verdict.is_some()); @@ -201,3 +203,108 @@ fn compose_chain_step_with_no_prev_output_has_empty_extra_env() { let step = compose_chain_step(Lang::Python, None); assert!(step.extra_env.is_empty()); } + +#[test] +fn chain_step_specs_aligns_results_to_member_order_and_reports_missing_diags() { + let chain = ChainFinding { + stable_hash: 0x1234, + members: vec![ + FindingRef { + finding_id: "f-1".into(), + stable_hash: 1, + location: loc("a.py", 10), + rule_id: "r1".into(), + cap_bits: 0, + }, + FindingRef { + finding_id: "f-2".into(), + stable_hash: 2, + location: loc("a.py", 20), + rule_id: "r2".into(), + cap_bits: 0, + }, + FindingRef { + finding_id: "f-3".into(), + stable_hash: 3, + location: loc("a.py", 30), + rule_id: "r3".into(), + cap_bits: 0, + }, + ], + sink: ChainSink { + file: "a.py".into(), + line: 40, + col: 1, + function_name: "sink".into(), + cap_bits: 0, + }, + implied_impact: ImpactCategory::Rce, + severity: ChainSeverity::Critical, + score: 100.0, + dynamic_verdict: None, + reverify_reason: None, + }; + // No diags threaded in — every member misses lookup and records + // `NoFlowSteps`. Result order must match member order. + let opts = VerifyOptions::default(); + let specs = chain_step_specs(&chain, &[], &opts); + assert_eq!(specs.len(), 3); + assert_eq!(specs[0].member_hash, 1); + assert_eq!(specs[1].member_hash, 2); + assert_eq!(specs[2].member_hash, 3); + for s in &specs { + assert!( + matches!(s.result, Err(UnsupportedReason::NoFlowSteps)), + "missing-diag fallback got {:?}", + s.result + ); + } +} + +#[test] +fn default_reverifier_detail_carries_zero_over_member_count() { + use nyx_scanner::chain::reverify::reverify_chain; + let mut chain = ChainFinding { + stable_hash: 0xCAFE, + members: vec![ + FindingRef { + finding_id: "f-1".into(), + stable_hash: 11, + location: loc("a.py", 1), + rule_id: "r".into(), + cap_bits: 0, + }, + FindingRef { + finding_id: "f-2".into(), + stable_hash: 22, + location: loc("a.py", 2), + rule_id: "r".into(), + cap_bits: 0, + }, + ], + sink: ChainSink { + file: "a.py".into(), + line: 5, + col: 1, + function_name: "sink".into(), + cap_bits: 0, + }, + implied_impact: ImpactCategory::Rce, + severity: ChainSeverity::Critical, + score: 100.0, + dynamic_verdict: None, + reverify_reason: None, + }; + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let result = reverify_chain(&mut chain, &[], &surface, &opts); + let detail = result + .verdict + .detail + .as_deref() + .expect("default reverifier populates detail"); + assert!( + detail.contains("0/2"), + "detail must report 0/2 specs derived for the two-member chain; got {detail:?}" + ); +}