Skip to content

Commit a0f565a

Browse files
Haofeiclaude
andcommitted
Add provenance gate: --require-verified-capabilities (review #3)
REIR facts already carry acquisition_mode + confidence, but nothing gated on them: a required capability backed only by an author declaration (rsspkg.toml binding) passed like independently-established evidence. - CiGatePolicy gains `require_verified_capabilities`; the gate now counts required capability facts whose acquisition_mode is author-declared (binding_manifest / manual_declaration / manual_exception) as `total_unverified` and fails when the flag is set. - `reir report-pr --require-verified-capabilities` and an action input (off by default; recommended for high-security gates). - test: an author-declared required fact passes the default gate (counted) but fails under require_verified_capabilities. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b89ba0c commit a0f565a

3 files changed

Lines changed: 74 additions & 3 deletions

File tree

.github/actions/rsscript-review/action.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ inputs:
2727
description: 'Fail the action if granted capabilities exceed requirements (over-privilege). Recommended true for protected branches.'
2828
required: false
2929
default: 'true'
30+
require-verified-capabilities:
31+
description: 'Fail if a required capability is only author-declared (rsspkg.toml binding) with no independent evidence. Off by default; turn on for high-security gates.'
32+
required: false
33+
default: 'false'
3034

3135
outputs:
3236
status:
@@ -71,6 +75,7 @@ runs:
7175
INPUT_FAIL_ON_MISSING: ${{ inputs.fail-on-missing }}
7276
INPUT_FAIL_ON_UNKNOWN: ${{ inputs.fail-on-unknown }}
7377
INPUT_FAIL_ON_EXCESS: ${{ inputs.fail-on-excess }}
78+
INPUT_REQUIRE_VERIFIED: ${{ inputs.require-verified-capabilities }}
7479
run: |
7580
TEMP_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/rsscript-review.XXXXXX")"
7681
PACKAGE_REVIEW_JSON="$TEMP_DIR/package-review.json"
@@ -105,6 +110,7 @@ runs:
105110
[ "$INPUT_FAIL_ON_MISSING" = "true" ] || REPORT_FLAGS+=(--allow-missing)
106111
[ "$INPUT_FAIL_ON_UNKNOWN" = "true" ] && REPORT_FLAGS+=(--fail-on-unknown)
107112
[ "$INPUT_FAIL_ON_EXCESS" = "true" ] && REPORT_FLAGS+=(--fail-on-excess)
113+
[ "$INPUT_REQUIRE_VERIFIED" = "true" ] && REPORT_FLAGS+=(--require-verified-capabilities)
108114
109115
set +e
110116
reir report-pr \

reir/src/format.rs

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ pub struct CiGateSummary {
209209
pub total_missing: usize,
210210
pub total_excess: usize,
211211
pub total_unknown: usize,
212+
#[serde(default)]
213+
pub total_unverified: usize,
212214
pub unknown_ratio: f64,
213215
}
214216

@@ -277,6 +279,9 @@ pub struct CiGatePolicy {
277279
pub fail_on_missing: bool,
278280
pub fail_on_unknown: bool,
279281
pub fail_on_excess: bool,
282+
/// Fail when a required capability is backed only by author-declared evidence
283+
/// (e.g. an `rsspkg.toml` binding) with no independent corroboration.
284+
pub require_verified_capabilities: bool,
280285
}
281286

282287
impl Default for CiGatePolicy {
@@ -285,10 +290,22 @@ impl Default for CiGatePolicy {
285290
fail_on_missing: true,
286291
fail_on_unknown: false,
287292
fail_on_excess: false,
293+
require_verified_capabilities: false,
288294
}
289295
}
290296
}
291297

298+
/// Whether a fact's acquisition mode is an author declaration (the package author
299+
/// asserted it) rather than independently established evidence.
300+
fn is_author_declared(fact: &Fact) -> bool {
301+
matches!(
302+
fact.acquisition_mode,
303+
AcquisitionMode::BindingManifest
304+
| AcquisitionMode::ManualDeclaration
305+
| AcquisitionMode::ManualException
306+
)
307+
}
308+
292309
/// Produce CI gate JSON output from reconciliation results, failing only on
293310
/// missing capabilities (the default policy).
294311
pub fn format_ci_gate_output(
@@ -337,11 +354,25 @@ pub fn format_ci_gate_output_with_policy(
337354
})
338355
.collect();
339356

357+
// Required capabilities backed only by author declaration (not unknown, which
358+
// is reported separately).
359+
let unverified_facts: Vec<_> = required_capability_facts
360+
.iter()
361+
.copied()
362+
.filter(|f| {
363+
is_author_declared(f)
364+
&& f.value != FactValue::Unknown
365+
&& f.unknown_reason.is_none()
366+
&& f.confidence.level != ConfidenceLevel::Unknown
367+
})
368+
.collect();
369+
340370
let total_required = required_capability_facts.len();
341371
let total_granted = granted_capability_facts.len();
342372
let total_missing = missing.len();
343373
let total_excess = excess.len();
344374
let total_unknown = unknown_facts.len();
375+
let total_unverified = unverified_facts.len();
345376
let unknown_ratio = if total_required > 0 {
346377
total_unknown as f64 / total_required as f64
347378
} else {
@@ -350,7 +381,8 @@ pub fn format_ci_gate_output_with_policy(
350381

351382
let fail = (policy.fail_on_missing && !missing.is_empty())
352383
|| (policy.fail_on_unknown && !unknown_facts.is_empty())
353-
|| (policy.fail_on_excess && !excess.is_empty());
384+
|| (policy.fail_on_excess && !excess.is_empty())
385+
|| (policy.require_verified_capabilities && !unverified_facts.is_empty());
354386
let status = if fail {
355387
CiGateStatus::Fail
356388
} else if !unknown_facts.is_empty() {
@@ -424,6 +456,7 @@ pub fn format_ci_gate_output_with_policy(
424456
total_missing,
425457
total_excess,
426458
total_unknown,
459+
total_unverified,
427460
unknown_ratio,
428461
},
429462
required_capabilities,
@@ -1477,9 +1510,40 @@ mod tests {
14771510
std::slice::from_ref(&granted),
14781511
std::slice::from_ref(&excess),
14791512
CiGatePolicy {
1480-
fail_on_missing: true,
1481-
fail_on_unknown: false,
14821513
fail_on_excess: true,
1514+
..Default::default()
1515+
},
1516+
);
1517+
assert_eq!(strict.status, CiGateStatus::Fail);
1518+
}
1519+
1520+
#[test]
1521+
fn require_verified_capabilities_blocks_author_declared() {
1522+
let mut required = capability_fact("fact.req.s3", FactRole::Required, FactValue::True);
1523+
required.acquisition_mode = AcquisitionMode::BindingManifest; // author-declared
1524+
let granted = capability_fact("fact.grant.s3", FactRole::Granted, FactValue::True);
1525+
let recon = crate::reconcile_capabilities(
1526+
std::slice::from_ref(&required),
1527+
std::slice::from_ref(&granted),
1528+
);
1529+
1530+
// Default policy accepts author-declared evidence (but counts it).
1531+
let default_out = format_ci_gate_output(
1532+
std::slice::from_ref(&required),
1533+
std::slice::from_ref(&granted),
1534+
&recon,
1535+
);
1536+
assert_ne!(default_out.status, CiGateStatus::Fail);
1537+
assert_eq!(default_out.summary.total_unverified, 1);
1538+
1539+
// require_verified_capabilities blocks it.
1540+
let strict = format_ci_gate_output_with_policy(
1541+
std::slice::from_ref(&required),
1542+
std::slice::from_ref(&granted),
1543+
&recon,
1544+
CiGatePolicy {
1545+
require_verified_capabilities: true,
1546+
..Default::default()
14831547
},
14841548
);
14851549
assert_eq!(strict.status, CiGateStatus::Fail);

reir/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ fn try_run_report_pr(args: &[String]) -> Result<(ExitCode, String), CliError> {
298298
"--ci-json" => ci_json = true,
299299
"--fail-on-unknown" => policy.fail_on_unknown = true,
300300
"--fail-on-excess" => policy.fail_on_excess = true,
301+
"--require-verified-capabilities" => policy.require_verified_capabilities = true,
301302
"--allow-missing" => policy.fail_on_missing = false,
302303
unknown => {
303304
return Err(CliError::usage(format!(

0 commit comments

Comments
 (0)