[pitboss/grind] deferred session-0012 (20260517T044708Z-e058)

This commit is contained in:
pitboss 2026-05-17 04:24:36 -05:00
parent 179c32f85f
commit 704f437cce
3 changed files with 280 additions and 30 deletions

View file

@ -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};

View file

@ -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<HarnessSpec, UnsupportedReason>,
}
/// 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<ChainStepSpec> {
let mut by_hash: HashMap<u64, &Diag> = 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<ChainReverifyResult> {
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)
));
}
}

View file

@ -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:?}"
);
}