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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ uphold init --owner OWNER --visibility public # a first policy, claims and hoo
uphold scan # content rules over the tree
uphold scan --text - # a commit message, release note, PR body
uphold check # the claims in policy/upheld.toml still hold
uphold check --probe # add fresh behavior evidence without changing acceptance
uphold check --coverage # which rules here carry a principle
uphold rules --effective # every rule inheritance resolved to, and where each runs
uphold guard --stage pre-push # the guards for that Git hook
Expand Down
18 changes: 17 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,23 @@ silence alone is insufficient evidence of a clean run.
These are criteria for choosing a provider. The reconciler currently credits
external hook ids from their installation and takes the claim's word for what
they enforce; inspecting a provider's local rule definitions is still planned.
Probing is opt-in, not a prerequisite enforced on every claim.
Probing is opt-in, not a prerequisite enforced on every claim. Reconciliation
reports installation independently of behavior, which starts unmeasured.
`uphold check --probe` adds measurements from the same invocation without
changing the installation verdict. A bad-and-clean pair can demonstrate only
the selected manager's named hook at the captured repository revision; missing
controls, unread runs, timeouts and observed failures cannot demonstrate it.
A wrapper's result does not certify its individual engine rules or a same-named
hook supplied through another manager.

Behavioral evidence is transient. The command captures a clean committed
snapshot before loading the policy and evaluating installation, runs that
exact revision, and refuses to certify changes during measurement. Provenance
includes the hook declaration, runner, stage and fixtures, so the exercised
subject is visible. This avoids a storage format or cache whose freshness a
later invocation would have to prove. External policies, machine dependencies
and remote services remain outside that snapshot; even a qualifying pair proves
only its exercised cases.

## Evidence: what a provider reports and what a policy may read

Expand Down
62 changes: 61 additions & 1 deletion docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3800,7 +3800,13 @@ when the result is refused or unavailable:
"principle": "explicit-unknown",
"rule": "catalog-tests",
"status": "reconciled",
"suppliers": [{"component": "local", "seam": "a hook installed here"}]
"suppliers": [{"component": "local", "seam": "a hook installed here"}],
"behavior": [{
"rule": "catalog-tests", "subject": "rule",
"manager": null, "stage": null, "source": null,
"state": "unmeasured", "outcome": "not_requested",
"detail": "no behavioral probe requested", "provenance": null
}]
}
],
"violations": [],
Expand Down Expand Up @@ -3831,6 +3837,60 @@ A range-only hook configured exclusively at stages without a push range does
not supply a claim. A valid declaration of the same id at another stage or
through another runner still supplies it independently.

Installation and behavior are separate evidence dimensions. A reconciled claim
establishes installation from repository configuration. Each claim's `behavior`
array says whether a control was exercised: `unmeasured`, `demonstrated`, or
`measured_failure`. Ordinary checks report `unmeasured` with
`outcome = "not_requested"`; they need no runner or Git repository to do so.
The human report prints behavior separately from the installation evidence.

```sh
uphold check --probe # installation plus fresh behavioral evidence
uphold check --probe --json # the same evidence as a structured report
```

`--probe` runs the file probes in `policy/hooks.toml` during this invocation,
using the runner detected from root configuration and PATH, as `uphold probe`
does. It adds evidence without changing reconciliation's exit status or making
probes a prerequisite for claims. A failed probe can therefore accompany a
clean installation verdict. Use `uphold probe` when the probe verdict itself
must determine the process exit. `--coverage` is a separate mode.

A demonstrated measurement requires both refusal and acceptance controls. Its
subject is the exact hook id in the selected manager's root configuration at
the probed stage. The report names `manager`, `stage` and `source` separately;
a same-named hook under another manager remains unmeasured. A hook probe does
not certify an engine rule behind its wrapper. Empty-range push probes exercise
Git's delegate, so their labels are not matched to runner-hook claims.

`cannot_fail`, `refuses_everything` and `unexpected_refusal` are measured
failures. Known startup, signal and snapshot-read failures during a control
are `unread` measured failures; an unread prerequisite leaves behavior
unmeasured. `timeout`,
`missing_clean_control`, `no_matching_probe` and `unmatched` remain unmeasured.
No missing probe, timeout or recognized unread condition establishes a
demonstration. Runner exit codes follow the existing probe contract: nonzero
for the bad control, zero for the clean control. Third-party runners have no
universal code for an internal error; declare `expect` to bind a nonzero exit
to the intended refusal rather than relying on the exit alone.

Measured evidence carries `provenance`: the runner executable path, manager,
hook, stage, repository revision, configuration source, canonical hook
declaration, upstream and pin, and the fixture's path, refusal contents,
acceptance contents and expected refusal text. A clean committed snapshot is
captured before loading the policy and reconciling. Probes run a detached
checkout of that exact revision. Changes outside the fixture during either
control invalidate the pair. A dirty tree or a changed revision before or after
measurement leaves behavior unmeasured with the reason reported.

No behavioral evidence is saved or reused: changing a rule, fixture or revision
requires a new run. Demonstration establishes only the exercised cases at that
snapshot, not universal correctness, coverage of every seam, or independence
from the author of the fixtures. The snapshot does not capture external
inherited policies, runner dependencies, machine configuration, or remote
services; the report is evidence of this invocation, not a promise of future
reproducibility.

The Python review and OSCAL consumers read this JSON contract. They reject
unknown schema versions, malformed or inconsistent reports, unexpected process
statuses, and any unavailable evidence. Review accepts an evaluated refusal
Expand Down
59 changes: 57 additions & 2 deletions src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,8 @@ pub(crate) struct EvaluatedClaim {
pub(crate) rule: String,
status: ClaimStatus,
suppliers: Vec<Supplier>,
/// Independent observations; these never change the installation verdict.
behavior: Vec<crate::probe::Evidence>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
Expand All @@ -782,7 +784,13 @@ struct Supplier {
}

/// `uphold check`, its JSON form, and the separate coverage report.
pub(crate) fn run(root: &Path, policy: &Policy, coverage: bool, json: bool) -> Result<Exit> {
pub(crate) fn run(
root: &Path,
policy: &Policy,
coverage: bool,
json: bool,
probe_revision: Option<Result<String>>,
) -> Result<Exit> {
let path = root.join(DECLARATION);
if !path.is_file() {
return Err(Fatal::new(format!(
Expand All @@ -799,7 +807,41 @@ pub(crate) fn run(root: &Path, policy: &Policy, coverage: bool, json: bool) -> R
if coverage {
return report_coverage(policy, &declaration, &installed, &supply);
}
let report = evaluate(&path, &declaration, &installed, &supply)?;
let mut report = evaluate(&path, &declaration, &installed, &supply)?;
let measured = probe_revision
.map(|revision| revision.and_then(|revision| crate::probe::measure(root, &revision)));
for claim in &mut report.claims {
let (outcome, detail) = match &measured {
None => ("not_requested", "no behavioral probe requested".to_owned()),
Some(Err(error)) => ("unread", error.to_string()),
Some(Ok(_)) => (
"no_matching_probe",
"no qualifying probe ran for this rule".to_owned(),
),
};
if let Some(Ok(evidence)) = &measured {
claim.behavior.extend(
evidence
.iter()
.filter(|item| item.rule == claim.rule)
.cloned(),
);
}
// A wrapper hook's probe cannot certify the engine's separate rule,
// even when both happen to use the same identifier.
if claim.behavior.is_empty()
|| claim
.suppliers
.iter()
.any(|supplier| supplier.component == "uphold")
{
claim.behavior.push(crate::probe::Evidence::unmeasured(
&claim.rule,
outcome,
detail,
));
}
}
if json {
println!(
"{}",
Expand Down Expand Up @@ -935,6 +977,7 @@ fn evaluate(
principle: principle.to_owned(),
rule: rule.to_owned(),
status,
behavior: Vec::new(),
suppliers: if status == ClaimStatus::Reconciled {
by.into_iter()
.flatten()
Expand Down Expand Up @@ -1006,6 +1049,18 @@ fn report_human(report: &Report) -> Result<Exit> {
.join(", ")
);
}
for claim in &report.claims {
for evidence in &claim.behavior {
let manager = evidence
.manager
.as_ref()
.map_or(String::new(), |manager| format!(" ({manager})"));
println!(
" behavior {}{manager}: {} [{}] — {}",
claim.rule, evidence.state, evidence.outcome, evidence.detail
);
}
}
for note in &report.notes {
println!(" note {note}");
}
Expand Down
22 changes: 15 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ usage:
uphold scan --text [FILE|-] run the host-identity rules over text
uphold guard --stage STAGE run the guards that fire at STAGE
uphold guard --text [FILE|-] run the text-capable guards over text
uphold check [--json] reconcile policy/upheld.toml against what runs
uphold check [--json] [--probe] reconcile claims; optionally measure hook behavior
uphold check --coverage which rules run here and carry no principle
uphold audit --for-publication what a private->public flip would republish
uphold init --owner OWNER write a first policy, claims and hook config
Expand Down Expand Up @@ -362,11 +362,18 @@ fn run() -> Result<Exit> {
"guard" => guard_command(rest),
"audit" => audit_command(rest),
"check" => match rest {
[] => check_command(false, false),
[flag] if flag == "--coverage" => check_command(true, false),
[flag] if flag == "--json" => check_command(false, true),
[] => check_command(false, false, false),
[flag] if flag == "--coverage" => check_command(true, false, false),
[flag] if flag == "--json" => check_command(false, true, false),
[flag] if flag == "--probe" => check_command(false, false, true),
[check_flag, other_flag]
if (check_flag == "--probe" && other_flag == "--json")
|| (check_flag == "--json" && other_flag == "--probe") =>
{
check_command(false, true, true)
}
_ => Err(Fatal::new(format!(
"usage: uphold check [--coverage | --json]\n\n{USAGE}"
"usage: uphold check [--json] [--probe] | --coverage\n\n{USAGE}"
))),
},
"hooks" => match rest {
Expand Down Expand Up @@ -581,11 +588,12 @@ fn run() -> Result<Exit> {
/// The loader runs first and its answer is what the reconcile reads, which is
/// the whole point of moving this in: `uphold_check.py` re-implemented
/// `config::load` to answer the same question and was free to disagree with it.
fn check_command(coverage: bool, json: bool) -> Result<Exit> {
fn check_command(coverage: bool, json: bool, probe: bool) -> Result<Exit> {
let working = std::env::current_dir()?;
let (root, policy_path) = discover(&working).ok_or_else(|| no_policy_here(&working))?;
let probe_revision = probe.then(|| probe::snapshot(&root));
let policy = config::load(&root, &policy_path)?;
check::run(&root, &policy, coverage, json)
check::run(&root, &policy, coverage, json, probe_revision)
}

fn scan_command(arguments: &[OsString]) -> Result<Exit> {
Expand Down
Loading
Loading