From d8bd38cc06cb39b1978d5dcd9742c7c725ab227c Mon Sep 17 00:00:00 2001 From: Aslak Felin Date: Tue, 25 Aug 2026 08:54:05 +0200 Subject: [PATCH 1/4] feat: offline curbpack review wedge (draft preserve) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add repo-free review of received packs, sample fixtures, Phase 0 installer/SDD honesty, and reviewers-first site copy. Preserve on a branch for human review — not merge-ready until claim-safety outside sandbox. Co-authored-by: Cursor --- AGENTS.md | 3 +- CHANGELOG.md | 3 + CLAUDE.md | 1 + docs/internal/evaluator-tip-drift.md | 32 ++ docs/internal/historical-verify-target.md | 36 ++ docs/internal/phase6-kill-test.md | 67 +++ docs/internal/post-kill-test-gates.md | 22 + docs/internal/sdd-gap-analysis.md | 2 +- docs/migration-cyberready-to-curbpack.md | 6 +- docs/software-design-document.md | 31 +- internal/cli/cli.go | 3 + internal/cli/completion.go | 2 +- internal/cli/help.go | 5 + internal/cli/registry.go | 1 + internal/cli/review.go | 52 ++ internal/review/references.go | 45 ++ internal/review/review.go | 488 ++++++++++++++++++ internal/review/review_test.go | 135 +++++ internal/review/sample_fixture_test.go | 31 ++ scripts/evaluator-behavior-check.sh | 13 + site/art14/index.html | 1 + site/for-authorities/index.html | 1 + site/for-builders/index.html | 1 + site/for-reviewers/index.html | 19 +- site/how-it-works/index.html | 3 +- site/index.html | 40 +- site/llms.txt | 2 + site/receiving-submissions/index.html | 59 +++ .../samples/review-pack/01-gate-failures.json | 35 ++ site/samples/review-pack/02-action-report.md | 10 + .../review-pack/03-executive-summary.md | 12 + site/samples/review-pack/README.md | 11 + site/samples/review-pack/buyer-onepager.html | 28 + site/samples/review-pack/index.html | 37 ++ .../sample-review-pack/01-gate-failures.json | 35 ++ .../sample-review-pack/02-action-report.md | 10 + .../03-executive-summary.md | 12 + testdata/sample-review-pack/README.md | 11 + .../sample-review-pack/buyer-onepager.html | 27 + 39 files changed, 1294 insertions(+), 38 deletions(-) create mode 100644 docs/internal/evaluator-tip-drift.md create mode 100644 docs/internal/historical-verify-target.md create mode 100644 docs/internal/phase6-kill-test.md create mode 100644 docs/internal/post-kill-test-gates.md create mode 100644 internal/cli/review.go create mode 100644 internal/review/references.go create mode 100644 internal/review/review.go create mode 100644 internal/review/review_test.go create mode 100644 internal/review/sample_fixture_test.go create mode 100755 scripts/evaluator-behavior-check.sh create mode 100644 site/receiving-submissions/index.html create mode 100644 site/samples/review-pack/01-gate-failures.json create mode 100644 site/samples/review-pack/02-action-report.md create mode 100644 site/samples/review-pack/03-executive-summary.md create mode 100644 site/samples/review-pack/README.md create mode 100644 site/samples/review-pack/buyer-onepager.html create mode 100644 site/samples/review-pack/index.html create mode 100644 testdata/sample-review-pack/01-gate-failures.json create mode 100644 testdata/sample-review-pack/02-action-report.md create mode 100644 testdata/sample-review-pack/03-executive-summary.md create mode 100644 testdata/sample-review-pack/README.md create mode 100644 testdata/sample-review-pack/buyer-onepager.html diff --git a/AGENTS.md b/AGENTS.md index 0e889e8..15b9b2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ curbpack init # when ready — house-policy default curbpack check # exit code authoritative # red: curbpack check --heal && curbpack ask .github/curbpack/cache/latest_failure.json --propose # green: curbpack ask-my-suppliers # durable buyer checklist + pack draft +# green (share): curbpack share → hand review-pack/; reviewers: curbpack review (offline triage) # optional pathway sidecar (never gates check): curbpack pathway status # human next ask by default (--technical for phase path) # optional research sidecar (never gates check): curbpack research [--fetch] [--gate-id=…] @@ -41,7 +42,7 @@ trust-import · review-sign · Last tabletop: · confirm-* · attest · pin-bump 1. After doc/dep edits → run `curbpack check` (exit code authoritative). 2. On red → `curbpack check --heal` then `curbpack ask … --propose` — never invent certification; never auto-attest. -3. On green → optional `curbpack export --context-pack` / `--buyer-questions` for humans. +3. On green → optional `curbpack export --context-pack` / `--buyer-questions` for humans; reviewers receiving a share can run `curbpack review ` offline (document triage — not confirm/attest). 4. Prefer ContextPack + dual-rep IR over guessing cache files. 5. Pin Action / examples at **`@v0.5.2`**. Never claim CE / notified-body approval. 6. **Pathway:** call `curbpack pathway status|suggest|note` only — never forge `pathway-seed.json` or invent pack ids. Stop for human `confirm-*` and `attest`. Prefer ContextPack pathway next + RKG after confirm-packs; post-attest next is local proof verify (human). MCP never confirms/attests. Seed is not a gate input. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b665a1..92b302f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased (on `main`, not yet pinned — Action pin stays `@v0.5.2`) +- **Reader review wedge (comms + CLI)** — `curbpack review ` offline document triage (confirmed / unconfirmed / contradicted) on curbpack-native review-packs — not a product verdict. Teaching sample at `site/samples/review-pack/`; site page `site/receiving-submissions/` (“Are you receiving submissions?”). Home / for-reviewers promote artifact trust table + review path. Pin stays `@v0.5.2`. Not certification. +- **Phase 0-slim honesty** — installer fallback no longer prints a failing `go install …/RI-SE/curbpack` line; claim-safe guidance points to RI-SE binary releases / `docs/getting-started/install.md` and notes the Go module path remains `github.com/afelin/curbpack` until wave-2 (strangers: binary only). SDD banner + §8–§10 clarify **`curbpack verify` is not a shipped CLI verb** (future reader wedge: `curbpack review`). Directional evaluator tip-drift note + `scripts/evaluator-behavior-check.sh`. Pin stays `@v0.5.2`. Not certification. +- **Reader wedge (`curbpack review`)** — offline triage of a received curbpack-native `review-pack/` (no git, no network): confirmed / unconfirmed / contradicted on structure, digests, and references (allowlisted URLs recorded never fetched). Pasteable triage note; exit 1 on contradicted. Frozen sample: `testdata/sample-review-pack` + `site/samples/review-pack`. Site: reviewers-first home, receiving-submissions page, trust-table links. Phase 6 scaffold + post-kill-test gates for intake/lint/batch. Document triage only — not a product verdict. Pin stays `@v0.5.2`. - **Slice A HITL honesty** — structural evidence for human review — not certification and not a CRA-compliant / CE / notified-body claim. GateFailure IR + ContextPack populate optional `agent_id` / `model_hash` / `active_mandate_id` from env; `source` is `self-declared` vs `bridge` when the Coreward sock path is present; missing sock fail-opens (`not_installed` / `unavailable`) and does not fail check; AgentIdentity is not in `state_hash`; no new sock ops. `TestNoVerdictSurface` locks public renderers so compliant / merge-allow are not a Curbpack verdict (`readiness_score` stays on GateFailurePayload). Opt-in `cra-baseline` file gate `docs/incident/art14-path.md` (Art 14 reporting rehearsal vs later handling clock; not house-policy default). **`--diff` always runs `anti_placeholder`** (committed heal stub + unrelated README change still fails `HOUSE-ANTI-PLACEHOLDER` / `CRA-ANTI-PLACEHOLDER` with scaffold body overlap). **`pathway confirm-prose` requires every displayed prose path independent** (not one-of); always runs inward cite-check (repo artifact or allowlisted cite; heal stubs / empty / agent-cache are not grounding). **Existing `cra-baseline` greens go red until `docs/incident/art14-path.md` is real prose.** `--heal` remaining red is intended. `--i-am-human` / `CURBPACK_ALLOW_CONFIRM=1` unchanged. Counsel note: Art 14 reporting vs handling; AI Act Art 50 grace is not blanket. Example workflow: compose Trivy/Gitleaks beside check; never set `CURBPACK_ALLOW_CONFIRM=1` on the Action. Pin stays `@v0.5.2`. Pack catalog frozen (three ids). Trust-surface freeze continues (no Action resolve / SafeJoin / OCC / airlock / sock / pack catalog). - **Handoff honesty** — one-pager cover sheet (files-to-read front, gate score on the back); `anti_placeholder` fails DefaultScaffoldBody overlap (`--heal` remaining red is intended); drift `docs_changed_since_attest` / `docs_unchanged_since_attest` plus optional security.txt contact signals; medtech formhints guess path `docs/medtech/…`. Proof yes/no stamp copy; share prints `share_stale` first; doctor warns on `CURBPACK_ALLOW_CONFIRM=1`; optional attest `--reviewed-by` in evidence only. Structural evidence for human review — not certification. Pin stays `@v0.5.2`. Trust-surface freeze continues (no Action resolve / SafeJoin / OCC / airlock / sock / pack catalog). - **Premortem production fixes (PR #57)** — `init` gitignores cache/evidence; Action `heal` default **false** + scaffold≠readiness warning; pathway confirm requires `--i-am-human` / `CURBPACK_ALLOW_CONFIRM=1` (TTY alone refused); `LatestNoteCommit` walks notes (not HEAD-without-note); Action refuses Windows runners + red `REMEDIATION REVIEW` artifacts; claim-safety scans `*.ps1`; redteam **18/18**; maintainer playbook [`docs/getting-started/release-v0.5.2.md`](docs/getting-started/release-v0.5.2.md) diff --git a/CLAUDE.md b/CLAUDE.md index 3e4d2d7..2e46ef0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ curbpack check # curbpack pathway note --set last_draft_pick=A|B|edited # before confirm-prose: curbpack research --cite-check # green (optional share): curbpack export --context-pack +# reviewers (offline): curbpack review # document triage — not confirm/attest ``` ## Human-only acts diff --git a/docs/internal/evaluator-tip-drift.md b/docs/internal/evaluator-tip-drift.md new file mode 100644 index 0000000..aa5d5d9 --- /dev/null +++ b/docs/internal/evaluator-tip-drift.md @@ -0,0 +1,32 @@ +# Evaluator tip drift (directional) + +> Maintainer note. Dual-remote mirror sync was removed (`scripts/curb-sync.sh` deprecated). **RI-SE/curbpack** is the sole public source of truth; **afelin/curbpack** is private development. + +## Behavior bar (do not re-derive) + +Vacuous-pass / not-started / target-absent semantics for tip evaluators are already locked by tests under `internal/validate`. Treat those cases as the behavior bar — do not invent a second definition of “aligned.” + +Relevant cases include: + +- `TestAntiPlaceholderTargetAbsentWhenAllMissing` — missing annex targets must **not** vacuous-pass `anti_placeholder` (target absent). +- `TestNPMDepBanVacuousPassPresentManifest` — present manifest with no banned pin may pass. +- `TestNPMDepBanTargetAbsentMissingManifest` — missing `package.json` must **not** vacuous-pass dep-ban (target absent). +- Related `anti_placeholder` stub / fresh-stub tests in the same package. + +`AllNotStarted` / `IsNotStartedFailure` in `internal/validate` classify scaffold / absent / not-started findings for TTY; tip comparison must respect that split (○ not-started ≠ ✘ fail ≠ silent green). + +Lean runner: [`scripts/evaluator-behavior-check.sh`](../../scripts/evaluator-behavior-check.sh). + +## Drift checks must be directional + +When comparing public tip to another tip (or a private development tip): + +1. Report **which side is ahead** (commit/date), not only whether hashes differ. +2. **Fail loudly** if evaluator behavior diverges (vacuous-pass / AllNotStarted / anti_placeholder target-absent semantics). +3. Do **not** treat hash-only mirror noise as a pass/fail signal — missing dual-remote sync means tip inequality alone is expected noise, not a gate. + +## Public SoR + +Public tip comparison is against **RI-SE `main`**. Private afelin work is development; it is not a second public mirror to keep byte-identical for its own sake. + +Not conformity assessment. Exit codes and local `curbpack check` remain authoritative for repo gates. diff --git a/docs/internal/historical-verify-target.md b/docs/internal/historical-verify-target.md new file mode 100644 index 0000000..8b24e1e --- /dev/null +++ b/docs/internal/historical-verify-target.md @@ -0,0 +1,36 @@ +# HISTORICAL / TARGET — `curbpack verify` sketches + +> **NOT SHIPPED.** `curbpack verify` is **not** a CLI verb. Do not run it; do not teach it as available. +> +> Recipient-side document triage is intended as future **`curbpack review`**. Do not resurrect `verify` as the ship name. +> +> Structural evidence for human review — not conformity assessment, not certification, not CE / notified-body opinion. + +These snippets were design-intent examples formerly presented without enough shipping fences in the SDD. Kept here so implementers can see the old shape without mistaking it for product surface. + +## Asker loop (historical wording) + +``` +asker publishes signed pack → supplier runs it locally → +supplier returns signed evidence → asker runs `curbpack verify` +``` + +**Today:** stop after signed evidence; humans review artifacts. **Target ship name:** `curbpack review`. + +## Operations sketch (historical) + +``` +curbpack packs sign +curbpack packs trust import # human-only; not built yet +curbpack verify # NOT SHIPPED — historical name only +``` + +## Artifact footer (historical) + +``` +Generated locally by curbpack. Nothing was uploaded. Verify it yourself: `curbpack verify `. +``` + +**Today:** do not print a `curbpack verify` invitation. Prefer claim-safe review language until `curbpack review` ships. + +See also: [software-design-document.md](../software-design-document.md) banner + §9.1; [sdd-gap-analysis.md](sdd-gap-analysis.md). diff --git a/docs/internal/phase6-kill-test.md b/docs/internal/phase6-kill-test.md new file mode 100644 index 0000000..07e0534 --- /dev/null +++ b/docs/internal/phase6-kill-test.md @@ -0,0 +1,67 @@ +# Phase 6 — kill-test scaffold + +> Maintainer note. Procedure scaffold only — results TBD. Not conformity assessment. Not certification. + +## Goal + +Falsify or support product value of offline **document triage** (`curbpack review` on curbpack-native review-packs): a useful mix of **confirmed** / **unconfirmed** / **contradicted** states across real submissions. + +**Variance required:** ~100% confirmed **or** ~100% unconfirmed across the public set **falsifies** product value (no discriminative signal). Report **distribution**, never name-and-shame projects. + +## Procedure + +### 1. Dogfood curbpack-on-itself + +1. Run the existing dogfood path: [`.github/workflows/curbpack-dogfood.yml`](../../.github/workflows/curbpack-dogfood.yml) (Action against this repo; local equivalent: build binary → `curbpack check` / share path as documented). +2. If a local curbpack-native **review-pack** directory is present (e.g. after prepare-release / share; often gitignored as `/review-pack/`), run: + + ```bash + curbpack review + ``` + +3. Record triage counts only (confirmed / unconfirmed / contradicted). Document triage ≠ product verdict. + +### 2. Ten public OSS projects (distribution only) + +1. Select **ten** public open-source projects that publish **security documentation** (e.g. SECURITY.md, security.txt, disclosure policy — claim-safe selection criteria only). +2. For each, produce or obtain a curbpack-native review-pack via the normal local loop (scan/init/check/share as applicable on a clone) — **on the evaluator’s machine**; do not upload proprietary trees. +3. Run `curbpack review ` on each pack. +4. Aggregate **only** the distribution of confirmed / unconfirmed / contradicted counts (or percentages) across the ten. **Do not** publish a per-project shame table. Internal worksheets may keep ids; public write-ups stay anonymized aggregates. + +### 3. Named org case study + +A **named** organization case study requires **prior written consent**. Without consent: keep anonymized or omit. + +## Results table (placeholder) + +| Cohort | n | % confirmed | % unconfirmed | % contradicted | Notes | +|--------|---|-------------|---------------|----------------|-------| +| Self dogfood (this repo `review-pack/`) | 1 | 43% (23) | 57% (30) | 0% (0) | 2026-08-25 local `curbpack review ./review-pack` — state mix present | +| Frozen sample fixture | 1 | 53% (10) | 47% (9) | 0% (0) | `testdata/sample-review-pack` — aha path | +| Public OSS (security docs) | 10 | TBD | TBD | TBD | Cohort list below — aggregate only; no project names in public report | +| Named org (consent) | TBD | TBD | TBD | TBD | Omit until written consent | + +**Early signal:** self dogfood + sample are **not** ~100% confirmed or ~100% unconfirmed — discriminative mix exists on curbpack-native packs. Full OSS n=10 still required before opening intake/lint/batch ([post-kill-test-gates.md](post-kill-test-gates.md)). + +## Public OSS cohort (internal worksheet — consent-free) + +Ten public projects with published security documentation (selection only; results stay aggregated): + +1. curl/curl +2. openssl/openssl +3. golang/go +4. kubernetes/kubernetes +5. torvalds/linux (security docs only — expect heavy; optional substitute: aquasecurity/trivy) +6. sigstore/cosign +7. aquasecurity/trivy +8. github/codeql-action +9. rustls/rustls +10. python/cpython + +Procedure per clone: shallow clone → `curbpack init --yes` (or house profile) → `check` / `share` as far as the tree allows → `curbpack review ` → record counts only. Never publish a per-project shame table. + +## Claim discipline + +- Triage is about the **received document**, not legal conformity. +- Never equate kill-test metrics with CE, notified-body, or CRA compliance. +- Pin stays as documented in AGENTS.md until a human approves a bump. diff --git a/docs/internal/post-kill-test-gates.md b/docs/internal/post-kill-test-gates.md new file mode 100644 index 0000000..7d09fe0 --- /dev/null +++ b/docs/internal/post-kill-test-gates.md @@ -0,0 +1,22 @@ +# Post kill-test gates (compress-after) + +> Maintainer note. Structural evidence for human review — not conformity assessment. + +## Rule + +**Intake**, **packs lint**, and **`--batch`** (strategy phases 2–4) stay **gated** until either: + +1. **Phase 6 kill-test** completes with an acceptable outcome (see [phase6-kill-test.md](phase6-kill-test.md)), **or** +2. An **explicit human risk accept** is recorded (written maintainer decision — not an agent invent, not a chat greenlight). + +Do **not** implement intake, packs-lint expansion, or `--batch` while this gate is closed. + +## Why + +The reader wedge (`curbpack review` + review-pack triage) must prove distributional value on real packs before we widen intake/batch surface. Shipping those paths early creates operational load without a falsifiable product signal. + +## Out of scope here + +- Implementing intake / packs lint / `--batch` +- Pin bumps, trust-import, attest, or HPURL work +- Claiming certification or CE / notified-body outcomes from kill-test metrics diff --git a/docs/internal/sdd-gap-analysis.md b/docs/internal/sdd-gap-analysis.md index 4b02af5..6547e9f 100644 --- a/docs/internal/sdd-gap-analysis.md +++ b/docs/internal/sdd-gap-analysis.md @@ -28,7 +28,7 @@ |----------|------------| | `internal/report` | Missing — findings re-declared across ~8 structures | | `internal/sign` | Missing — attest uses ssh-agent notes, not `ssh-keygen -Y` pack verify | -| `verify` command | Missing — proof page is client-side hash compare only | +| `verify` command | **Missing** — **`curbpack verify` is not a shipped CLI verb**; proof page is client-side hash compare only. Intended reader wedge name: future `curbpack review` (document triage). See [historical-verify-target.md](historical-verify-target.md). | | `references` check primitive | Missing | | Check-kind registry (§6.3) | Partial — `checkRegistry` map in `checks.go`, not unified `CheckKind` descriptor | | `packs init --from-repo` | Missing | diff --git a/docs/migration-cyberready-to-curbpack.md b/docs/migration-cyberready-to-curbpack.md index cadc87d..1debd41 100644 --- a/docs/migration-cyberready-to-curbpack.md +++ b/docs/migration-cyberready-to-curbpack.md @@ -47,11 +47,9 @@ curl -fsSL https://raw.githubusercontent.com/RI-SE/curbpack/main/scripts/install curb doctor && curb demo ``` -From source: +From source (maintainers / clone holders only): -```bash -go install github.com/afelin/curbpack/cmd/curbpack@v0.5.0 -``` +The Go module path remains `github.com/afelin/curbpack` until wave-2 migration. With the development repo private, strangers should use the binary installers above — do not treat `go install github.com/RI-SE/curbpack/...` as a working path (module path differs), and do not rely on `go install github.com/afelin/curbpack/...` as a public install. ## Pedagogy (unchanged gates) diff --git a/docs/software-design-document.md b/docs/software-design-document.md index c637de1..2335ab8 100644 --- a/docs/software-design-document.md +++ b/docs/software-design-document.md @@ -1,12 +1,12 @@ # Curbpack — Software Design Document -> **Target state:** This document describes design intent — not everything here is shipped yet (e.g. `verify` is not in the CLI). For repository baseline vs this doc, see [internal SDD gap analysis](internal/sdd-gap-analysis.md). +> **Target state — not a full shipped command catalog.** This document describes design intent. **`curbpack verify` is not a shipped CLI verb** (do not run it; do not resurrect that name). Recipient-side document triage **is shipped** as **`curbpack review `** (offline; confirmed / unconfirmed / contradicted — document only, not a product verdict). Nothing in this doc is certification, CE marking, or notified-body opinion. For repository baseline vs this doc, see [internal SDD gap analysis](internal/sdd-gap-analysis.md). Historical / target-only `verify` sketches: [internal/historical-verify-target.md](internal/historical-verify-target.md). **Version:** 1.0 · **Date:** 19 August 2026 · **Status:** target state **Applies to:** `github.com/RI-SE/curbpack` **Audience:** implementers and the agents working this repository -This document describes the state curbpack is being built toward — the design, the reasoning behind it, and the work required to get there. It is the single source of design intent. Where it conflicts with older notes, plans or specifications, this document wins. +This document describes the state curbpack is being built toward — the design, the reasoning behind it, and the work required to get there. It is the single source of design intent. Where it conflicts with older notes, plans or specifications, this document wins. **Shipped verbs** live in the CLI help / `AGENTS.md` loop — not in the aspirational tables below. Two lists in this document are load-bearing and must not be confused: @@ -271,10 +271,12 @@ This closes the loop with no new infrastructure: ``` asker publishes signed pack → supplier runs it locally → -supplier returns signed evidence → asker runs `curbpack verify` +supplier returns signed evidence → asker runs recipient triage + (TARGET ONLY — not shipped: historically sketched as `curbpack verify`; + intended ship name for document triage is `curbpack review`) ``` -No questionnaire round-trip, no portal, no registry. +No questionnaire round-trip, no portal, no registry. Not conformity assessment. --- @@ -288,10 +290,13 @@ Signing and verification use **OpenSSH signatures and `allowed_signers`**. No be ### 8.2 Operations +> **TARGET / NOT SHIPPED:** the `verify` lines below are design sketches only. **`curbpack verify` is not a CLI verb today.** Recipient document triage is intended as future **`curbpack review`**. Pack signing / trust-import remain Wave B. Not certification. + ``` -curbpack packs sign # ssh-keygen -Y sign -n -curbpack packs trust import # HUMAN-ONLY — see §12 -curbpack verify # ssh-keygen -Y verify +curbpack packs sign # TARGET: ssh-keygen -Y sign -n +curbpack packs trust import # HUMAN-ONLY — see §12; not built yet +# NOT SHIPPED — do not run: +# curbpack verify # historical name; prefer future: curbpack review ``` ### 8.3 Verdict states @@ -349,18 +354,20 @@ The verb count does not grow with regimes or stakeholders. Each stakeholder uses | `init` | Configuration, hooks, editor integration — offered after first value | Yes | | `check` | Evaluate; exit code authoritative | Cache only | | `ask-my-suppliers` | Emit the question set and the pack | Yes, to a durable path | -| **`verify`** | Verify a received artifact | **No** | +| ~~`verify`~~ → **`review`** *(TARGET)* | Recipient document triage / artifact check | **No** — **not a shipped CLI verb**; do not run `curbpack verify` | | `packs init --from-repo` | Author a pack by example | Yes, one file | | `packs sign` / `packs trust import` | Sign a pack / import signers | Yes | | `export --` | SARIF, context pack, buyer questions | Yes | | `attest` | Human-only capsule | Yes | | `doctor` | Environment diagnosis | No | -### 9.1 `verify` — why it exists +### 9.1 Recipient triage (`review`) — why it exists *(SHIPPED)* + +> **Shipped:** `curbpack review ` triages a curbpack-native review-pack offline (no git, no network). States are **confirmed** / **unconfirmed** / **contradicted** about the **document** — never a product verdict. Historical SDD text used the verb name `verify`; **do not implement `curbpack verify`**. Not certification, not a conformity gate. Curbpack today serves producers. Buyers, assessors and distributors receive HTML they cannot check. One read-only verb converts every artifact recipient into a potential installer — and the recipient is the party deciding whether a supplier keeps the contract. A producer install serves one repository; a recipient install serves every supplier that recipient has. -Output: the verdict, which claims resolve to real artifacts, which do not, and what could not be checked. Offline, no account. +Output: the verdict, which claims resolve to real artifacts, which do not, and what could not be checked. Offline, no account. Structural evidence for human review only. --- @@ -371,7 +378,7 @@ Every artifact that leaves the building carries, without exception: - the computed `assurance_class` and **"mechanically evidenced: X of Y"**; - the verification verdict, in the vocabulary of §8.3; - the claim boundary: *prepares evidence for human review — not a conformity assessment*; -- the footer: *Generated locally by curbpack. Nothing was uploaded. Verify it yourself: `curbpack verify `.* +- the footer: *Generated locally by curbpack. Nothing was uploaded. Review the artifact yourself (TARGET: future `curbpack review` — **`curbpack verify` is not shipped**).* That footer converts a claim into an invitation, which is both the honest framing and the distribution mechanism. @@ -489,7 +496,7 @@ Each wave states a precondition. Work that cannot name its consumer does not sta |---|---| | Check-kind registry (§6.3) | 1 d | | `references` (§6.1) | 2 d | -| `verify` (§9.1) | 2 d | +| Recipient triage / `review` (§9.1; historical name `verify`) | 2 d | | Signing, trust import, three verdict states (§8) | 1 d | | `packs init --from-repo` (§7.1) | 2 d | | Computed `assurance_class`, reviewer attestation, self-contained bundle | 1.5 d | diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 71fc92e..9852288 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -139,6 +139,7 @@ func usage() { fmt.Fprintf(os.Stderr, " ask-my-suppliers [--stdout-only] [--out path]\n") fmt.Fprintf(os.Stderr, " Supplier checklist → stdout + review-pack/ (writes files)\n") fmt.Fprintf(os.Stderr, " share [--bundle] [--reveal] check → context-pack → buyer-questions → prepare-release\n") + fmt.Fprintf(os.Stderr, " review Offline triage of a received review-pack (no git / no network)\n") fmt.Fprintf(os.Stderr, " drift [--json] Multi-signal evidence checklist (exit 0 always)\n") fmt.Fprintf(os.Stderr, " prepare-release Review-pack + evidence\n") fmt.Fprintf(os.Stderr, " attest [--allow-dirty] [--reviewed-by=Name] Human Git Notes capsule (then proof verify)\n\n") @@ -150,6 +151,7 @@ func usage() { fmt.Fprintf(os.Stderr, " export --sarif|--explain-packet|--watchlist-join|--buyer-questions|--lay-of-land|--context-pack [--spdx] [--slsa]\n") fmt.Fprintf(os.Stderr, " Standards / airlock / buyer checklist / instrument map / ContextPack\n") fmt.Fprintf(os.Stderr, " share [--bundle] [--reveal] Recipe + optional Explorer/Finder reveal\n") + fmt.Fprintf(os.Stderr, " review [--json] Received-pack triage: confirmed / unconfirmed / contradicted\n") fmt.Fprintf(os.Stderr, " drift [--json] Evidence drift checklist (informational; exit 0)\n") fmt.Fprintf(os.Stderr, " pathway status|suggest|confirm-packs|confirm-prose|confirm-share|note\n") fmt.Fprintf(os.Stderr, " Warm-start seed + HITL ticks + session notes (sole writer of pathway-seed.json)\n") @@ -159,6 +161,7 @@ func usage() { fmt.Fprintf(os.Stderr, " view Show attest capsule for HEAD\n\n") fmt.Fprintf(os.Stderr, "Exit codes: 0=success/soft-ok 1=gates/error 2=usage/env (incl. doctor --repair missing binary)\n") fmt.Fprintf(os.Stderr, " scan uses exit 0 when diagnosis completes, even when findings remain.\n") + fmt.Fprintf(os.Stderr, " review exits 1 when any finding is contradicted (document triage, not product verdict).\n") fmt.Fprintf(os.Stderr, " doctor may report advisory issues without signalling gate failure.\n") fmt.Fprintf(os.Stderr, " Only check provides repository gate pass/fail.\n") } diff --git a/internal/cli/completion.go b/internal/cli/completion.go index d62540a..5bae50e 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -10,7 +10,7 @@ var completionCommands = []string{ "help", "version", "doctor", "demo", "scan", "fix", "init", "check", "validate", "ask-my-suppliers", "prepare-release", "packs", "ask", "attest", "view", - "export", "share", "pathway", "research", "drift", "completion", + "export", "share", "review", "pathway", "research", "drift", "completion", } var completionExportFlags = []string{ diff --git a/internal/cli/help.go b/internal/cli/help.go index bab437c..2168d16 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -26,6 +26,11 @@ func commandUsage(cmd string) { case "share": fmt.Fprintf(os.Stderr, "Usage: curbpack share [--bundle] [--reveal] [--packs a,b] [--skip-prepare-release]\n") fmt.Fprintf(os.Stderr, " Recipe: check → context-pack → buyer-questions → prepare-release.\n") + case "review": + fmt.Fprintf(os.Stderr, "Usage: curbpack review [--json]\n") + fmt.Fprintf(os.Stderr, " Offline triage of a curbpack-native review-pack (no git, no network).\n") + fmt.Fprintf(os.Stderr, " States: confirmed | unconfirmed | contradicted — document only, not a product verdict.\n") + fmt.Fprintf(os.Stderr, " Exit 1 if any finding is contradicted; exit 2 on usage errors.\n") case "drift": fmt.Fprintf(os.Stderr, "Usage: curbpack drift [--json]\n") fmt.Fprintf(os.Stderr, " Multi-signal evidence checklist — informational; exit 0 always.\n") diff --git a/internal/cli/registry.go b/internal/cli/registry.go index 493f189..fc67ba5 100644 --- a/internal/cli/registry.go +++ b/internal/cli/registry.go @@ -24,6 +24,7 @@ var commandRegistry = []commandEntry{ {name: "demo", handler: cmdDemo}, {name: "export", handler: cmdExport}, {name: "share", handler: cmdShare}, + {name: "review", handler: cmdReview}, {name: "drift", handler: cmdDrift}, {name: "pathway", handler: cmdPathway}, {name: "research", handler: cmdResearch}, diff --git a/internal/cli/review.go b/internal/cli/review.go new file mode 100644 index 0000000..6f8710d --- /dev/null +++ b/internal/cli/review.go @@ -0,0 +1,52 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/afelin/curbpack/internal/review" + "github.com/afelin/curbpack/internal/tty" +) + +func cmdReview(args []string) error { + jsonOut := false + var path string + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "-h" || a == "--help": + return helpShownErr("review") + case a == "--json": + jsonOut = true + case strings.HasPrefix(a, "-"): + return usageErr("unknown flag for review: " + a) + default: + if path != "" { + return usageErr("review accepts a single pack directory path") + } + path = a + } + } + if path == "" { + commandUsage("review") + return usageErr("review requires a path to a received review-pack directory") + } + + tty.PrintHeader("curbpack review") + fmt.Fprintf(os.Stderr, "%s\n", tty.C(tty.Dim, "Offline document triage — not a product verdict.")) + + rep, err := review.Run(review.Options{ + BundleRoot: path, + Writer: os.Stdout, + JSONOut: jsonOut, + }) + if err != nil { + return usageErr(err.Error()) + } + if review.HasContradictions(rep) { + fmt.Fprintf(os.Stderr, "%s\n", tty.C(tty.Yellow, "Contradicted findings present — see triage note above.")) + return gatesErr() + } + return nil +} diff --git a/internal/review/references.go b/internal/review/references.go new file mode 100644 index 0000000..6237fe9 --- /dev/null +++ b/internal/review/references.go @@ -0,0 +1,45 @@ +package review + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// resolveBundleAnchor lifts the in-repository path resolver idea from +// research/ground into the offline review path: a path is confirmed only when +// it exists inside the received bundle. Repo-only anchors stay unconfirmed +// (no network, no supplier tree). +// +// Four Phase 1b resolvers in the review path: +// 1. in-bundle anchor (this function) +// 2. pack citation / claim id (HOUSE-|CRA-|MEDTECH-) — see checkReferences +// 3. manifest coordinate (SBOM/VEX digests) — see checkDigests +// 4. external link via research.ValidateSourceURL — recorded, never fetched, +// never elevated to confirmed +func resolveBundleAnchor(bundleRoot, cand string, bundleFiles map[string]struct{}) (State, string) { + cand = filepath.ToSlash(strings.TrimSpace(cand)) + if cand == "" { + return StateUnconfirmed, "empty path" + } + if _, ok := bundleFiles[cand]; ok { + return StateConfirmed, "in-bundle path: " + cand + } + base := filepath.Base(cand) + if _, ok := bundleFiles[base]; ok { + return StateConfirmed, "in-bundle basename: " + cand + " → " + base + } + abs := filepath.Join(bundleRoot, filepath.FromSlash(cand)) + if st, err := os.Stat(abs); err == nil && !st.IsDir() { + return StateConfirmed, "in-bundle relative path: " + cand + } + if looksLikeRepoPath(cand) { + return StateUnconfirmed, "repo-shaped path not in bundle: " + cand + } + return StateUnconfirmed, "unresolved path: " + cand +} + +func referenceKindDetail(kind, detail string) string { + return fmt.Sprintf("[%s] %s", kind, detail) +} diff --git a/internal/review/review.go b/internal/review/review.go new file mode 100644 index 0000000..be47c13 --- /dev/null +++ b/internal/review/review.go @@ -0,0 +1,488 @@ +// Package review triages a received curbpack-native review-pack offline. +// It reports on the document (structure, digest self-consistency, reference +// resolvability) — never a product verdict or conformity assessment. +package review + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "html" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/afelin/curbpack/internal/ir" + "github.com/afelin/curbpack/internal/research" +) + +const schemaVersion = "curbpack-review-report:1" + +// State is one finding outcome. Never conflate these three. +type State string + +const ( + StateConfirmed State = "confirmed" + StateUnconfirmed State = "unconfirmed" + StateContradicted State = "contradicted" +) + +// Finding is one triage row about the received document. +type Finding struct { + ID string `json:"id"` + Category string `json:"category"` // structure | digest | reference + State State `json:"state"` + Detail string `json:"detail"` +} + +// Report is the offline review result (document triage only). +type Report struct { + Schema string `json:"schema"` + BundleRoot string `json:"bundle_root"` + Findings []Finding `json:"findings"` + ConfirmedCount int `json:"confirmed_count"` + UnconfirmedCount int `json:"unconfirmed_count"` + ContradictedCount int `json:"contradicted_count"` + Disclaimer string `json:"disclaimer"` +} + +// Options for Run. +type Options struct { + BundleRoot string + Writer io.Writer // triage markdown; default stdout + JSONOut bool +} + +var ( + reOnePagerFP = regexp.MustCompile(` + +
+
Rule packs
house-policy
+
result_digest
` + digest[:12] + `…
+
+

See https://example.com/docs for context.

+` + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(html)) + + var buf bytes.Buffer + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: &buf}) + if err != nil { + t.Fatal(err) + } + if rep.ConfirmedCount == 0 { + t.Fatalf("expected some confirmed findings, got %+v", rep) + } + if review.HasContradictions(rep) { + t.Fatalf("expected no contradictions on minimal consistent pack: %+v", rep.Findings) + } + md := buf.String() + if !strings.Contains(md, "Document triage only") { + t.Fatalf("triage missing disclaimer: %s", md) + } + if !strings.Contains(md, "Unconfirmed") { + t.Fatalf("expected unconfirmed section for external URL: %s", md) + } + // No product verdict language + banned := []string{"CRA-compliant", "CE marking", "notified-body approved", "we are certified"} + for _, b := range banned { + if strings.Contains(strings.ToLower(md), strings.ToLower(b)) { + t.Fatalf("triage must not emit %q", b) + } + } +} + +func TestReviewMissingRequiredContradicted(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "01-gate-failures.json"), []byte(`{"schema_version":"1","pack_id":"house-policy","readiness_score":0}`+"\n")) + var buf bytes.Buffer + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: &buf}) + if err != nil { + t.Fatal(err) + } + if !review.HasContradictions(rep) { + t.Fatal("missing required layers must contradict") + } +} + +func TestReviewDigestMismatch(t *testing.T) { + dir := t.TempDir() + payload := ir.GateFailurePayload{SchemaVersion: "1", PackID: "house-policy", ReadinessScore: 10} + raw, _ := json.MarshalIndent(payload, "", " ") + mustWrite(t, filepath.Join(dir, "01-gate-failures.json"), append(raw, '\n')) + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("ok\n")) + mustWrite(t, filepath.Join(dir, "03-executive-summary.md"), []byte("ok\n")) + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(` +
Rule packs
house-policy
+
result_digest
000000000000…
`)) + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}}) + if err != nil { + t.Fatal(err) + } + found := false + for _, f := range rep.Findings { + if f.ID == "digest:result-digest-match" && f.State == review.StateContradicted { + found = true + } + } + if !found { + t.Fatalf("expected result_digest contradiction, findings=%+v", rep.Findings) + } +} + +func TestReviewNoGitRequired(t *testing.T) { + // Bundle outside any git repo — temp dir is enough. + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "01-gate-failures.json"), []byte(`{"schema_version":"1","pack_id":"house-policy","readiness_score":100}`+"\n")) + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("x\n")) + mustWrite(t, filepath.Join(dir, "03-executive-summary.md"), []byte("x\n")) + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(`
`)) + cwd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + _, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}}) + if err != nil { + t.Fatalf("review must work without git cwd: %v", err) + } +} + +type ioDiscard struct{} + +func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } + +func mustWrite(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/review/sample_fixture_test.go b/internal/review/sample_fixture_test.go new file mode 100644 index 0000000..5c3e5bb --- /dev/null +++ b/internal/review/sample_fixture_test.go @@ -0,0 +1,31 @@ +package review_test + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/afelin/curbpack/internal/review" +) + +func TestSampleReviewPackFixture(t *testing.T) { + root := filepath.Join("..", "..", "testdata", "sample-review-pack") + if _, err := filepath.Abs(root); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + rep, err := review.Run(review.Options{BundleRoot: root, Writer: &buf}) + if err != nil { + t.Fatal(err) + } + if review.HasContradictions(rep) { + t.Fatalf("frozen sample must not contradict: %+v\n%s", rep.Findings, buf.String()) + } + if rep.ConfirmedCount == 0 || rep.UnconfirmedCount == 0 { + t.Fatalf("sample should show state mix (confirmed+unconfirmed); got c=%d u=%d", rep.ConfirmedCount, rep.UnconfirmedCount) + } + if !strings.Contains(buf.String(), "Document triage only") { + t.Fatal("missing disclaimer") + } +} diff --git a/scripts/evaluator-behavior-check.sh b/scripts/evaluator-behavior-check.sh new file mode 100755 index 0000000..3d3f960 --- /dev/null +++ b/scripts/evaluator-behavior-check.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Lean tip-evaluator behavior bar: vacuous-pass / target-absent / anti_placeholder cases. +# Exit non-zero on failure. No new deps. See docs/internal/evaluator-tip-drift.md. +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +echo "evaluator-behavior-check: internal/validate vacuous-pass / target-absent / anti_placeholder" +go test ./internal/validate/ \ + -run 'TestAntiPlaceholderTargetAbsentWhenAllMissing|TestNPMDepBanVacuousPassPresentManifest|TestNPMDepBanTargetAbsentMissingManifest|TestAntiPlaceholderUntouchedStubFails|TestAntiPlaceholderStubPlusProductNameStillFails|TestFreshStubPathsSkipsAntiPlaceholderSameRun' \ + -count=1 +echo "evaluator-behavior-check: ok" diff --git a/site/art14/index.html b/site/art14/index.html index 885dc28..48856d1 100644 --- a/site/art14/index.html +++ b/site/art14/index.html @@ -27,6 +27,7 @@
  • Builders
  • Reviewers
  • +
  • Receiving
  • Authorities
  • Art 14
  • How it works
  • diff --git a/site/for-authorities/index.html b/site/for-authorities/index.html index 0593ac1..9f4a195 100644 --- a/site/for-authorities/index.html +++ b/site/for-authorities/index.html @@ -17,6 +17,7 @@
    • Builders
    • Reviewers
    • +
    • Receiving
    • Authorities
    • How it works
    • Art 14
    • diff --git a/site/for-builders/index.html b/site/for-builders/index.html index 5279b26..a459068 100644 --- a/site/for-builders/index.html +++ b/site/for-builders/index.html @@ -17,6 +17,7 @@
      • Builders
      • Reviewers
      • +
      • Receiving
      • Authorities
      • How it works
      • Source
      • diff --git a/site/for-reviewers/index.html b/site/for-reviewers/index.html index 003503c..8994eba 100644 --- a/site/for-reviewers/index.html +++ b/site/for-reviewers/index.html @@ -4,7 +4,7 @@ For reviewers — Curbpack - + @@ -17,6 +17,7 @@
        • Builders
        • Reviewers
        • +
        • Receiving
        • Authorities
        • How it works
        • Source
        • @@ -27,14 +28,21 @@

          For reviewers

          Ask the supplier for a buyer one-pager (supplier evidence summary) and, if needed, the review pack (JSON + markdown). Then decide whether to dig deeper.

          Not conformity assessment. Not CE marking. Not a notified-body opinion.

          -

          Open sample review page

          +

          Open sample review page + Sample review-pack + Receiving submissions?

          • Local gate score on this tree is not a certification score.
          • -
          • Gates passed means deterministic rule-pack checks did not fail — humans still judge annex drafts and risk.
          • +
          • Gates passed means deterministic rule-pack checks did not fail — humans still judge annex drafts and risk. No language model decides pass/fail.
          • UNSIGNED — not cryptographically verified until an ssh-agent signed attest. Unsigned ≠ verified.
          -

          Artifact trust table

          +

          Offline document triage

          +

          When you receive a curbpack-native review-pack, unzip it on your machine and run:

          +
          curbpack review <received-pack-dir>
          +

          You get pasteable triage: confirmed, unconfirmed, or contradicted findings about the document (structure, digests, references) — not a product verdict. Teaching folder: /curbpack/samples/review-pack/. Longer path: Are you receiving submissions?

          + +

          Artifact trust table

          @@ -42,6 +50,7 @@

          Artifact trust table

          + @@ -54,7 +63,7 @@

          Artifact trust table

          diff --git a/site/how-it-works/index.html b/site/how-it-works/index.html index f49c84c..9777405 100644 --- a/site/how-it-works/index.html +++ b/site/how-it-works/index.html @@ -17,6 +17,7 @@
          • Builders
          • Reviewers
          • +
          • Receiving
          • Authorities
          • How it works
          • Source
          • @@ -26,7 +27,7 @@

            How it works

            Three ways in → same local check → review pack → human review. Optional drafts never replace check.

            -

            Building a product? For builders · Reviewing a supplier? For reviewers (trust table) · Authority or auditor? For authorities.

            +

            Building a product? For builders · Reviewing a supplier? For reviewers (trust table) · Receiving a pack? Receiving submissions · Authority or auditor? For authorities.

            Not conformity assessment. Not CE marking. Not a notified-body opinion.

            Three ways in — same local check

            diff --git a/site/index.html b/site/index.html index ed12418..c662715 100644 --- a/site/index.html +++ b/site/index.html @@ -100,8 +100,9 @@ Curbpack
            - Builders Reviewers + Receiving + Builders Authorities Art 14 How it works @@ -125,9 +126,15 @@

            Article 14 reporting starts in 23 days (11 September 2026)

            -

            +

            One command, writes nothing — not certification. Curbpack scans your repo read-only on your machine; we never see your code.

            +

            + The gate is deterministic, runs offline, and cannot be argued with. +

            +

            + We do not use a language model to decide whether you passed. +

            @@ -259,35 +268,37 @@

            Human review

            -

            Suppliers & builders

            +

            Reviewers

            - Green checks in your repo in under ten minutes. Dual install for Windows (PowerShell) and macOS/Linux — same doctor → demo → init → check → share ladder. + Ask for a buyer one-pager and review-pack, then use the artifact trust table — not a certification score. Receiving submissions? Triage offline with curbpack review.

            - - Full ladder + + For reviewers

            - Install: docs/getting-started/install.md + Artifact trust table + · Receiving submissions + · Sample review-pack

            -

            Buyers & reviewers

            +

            Suppliers & builders

            - Ask for a buyer one-pager, then use the trust table — not a certification score. + Green checks in your repo in under ten minutes. Dual install for Windows (PowerShell) and macOS/Linux — same doctor → demo → init → check → share ladder.

            - - For reviewers + + Full ladder

            - Sample: buyer one-pager + Install: docs/getting-started/install.md

            Authorities

            - What each artifact means and does not mean. + What each artifact means and does not mean — same trust table reviewers use, without treating gate green as conformity.

            View docs @@ -321,6 +332,9 @@

            Authorities

            White paper Security Agent index (llms.txt) + Artifact trust table + Receiving submissions + Sample review-pack One-pager sample Art 14 scan
            diff --git a/site/llms.txt b/site/llms.txt index 9aac714..1c14dd4 100644 --- a/site/llms.txt +++ b/site/llms.txt @@ -32,6 +32,8 @@ Write starts with **curb outlines (your pathway sketch)**. After pack confirm, a - Install (dual fence — PowerShell | macOS/Linux): https://github.com/RI-SE/curbpack/blob/main/docs/getting-started/install.md - Troubleshooting: https://github.com/RI-SE/curbpack/blob/main/docs/getting-started/troubleshooting.md - Three ways in / builders: https://ri-se.github.io/curbpack/for-builders/ +- Reviewers / trust table: https://ri-se.github.io/curbpack/for-reviewers/ +- Receiving submissions (`curbpack review`): https://ri-se.github.io/curbpack/receiving-submissions/ - Pathway (Write depth): https://github.com/RI-SE/curbpack/blob/main/docs/getting-started/pathway.md - Authorities: https://ri-se.github.io/curbpack/for-authorities/ - White paper: https://ri-se.github.io/curbpack/whitepaper/ diff --git a/site/receiving-submissions/index.html b/site/receiving-submissions/index.html new file mode 100644 index 0000000..03d62bd --- /dev/null +++ b/site/receiving-submissions/index.html @@ -0,0 +1,59 @@ + + + + + + Receiving submissions — Curbpack + + + + + + + +
            + + +
            +

            Are you receiving submissions?

            +

            The check runs on your machine, the submission never leaves it, and the result is the same every time.

            +

            Not conformity assessment. Not CE marking. Not a notified-body opinion.

            + +

            What you do

            +
              +
            1. Unzip a curbpack-native review-pack from the supplier (folder of gate JSON, action report, executive summary, buyer one-pager, and optional SBOM/VEX layers).
            2. +
            3. Run offline document triage: curbpack review <dir>
            4. +
            5. Paste the triage into your notes — rows are confirmed, unconfirmed, or contradicted about the received document (structure, digests, references). That is not a product verdict.
            6. +
            +
            curbpack review ./received-review-pack
            + +

            What the gate is

            +

            The supplier’s pack gates are deterministic and run offline. Curbpack does not use a language model to decide whether a tree passed. Your curbpack review pass is likewise offline document triage — same inputs, same triage.

            + +

            Samples and trust

            + + +

            Procurement language must stay claim-safe: never equate gate pass or review triage with CE marking or notified-body approval. Deeper brief: for authorities.

            +
            + + +
            + + diff --git a/site/samples/review-pack/01-gate-failures.json b/site/samples/review-pack/01-gate-failures.json new file mode 100644 index 0000000..9fb4380 --- /dev/null +++ b/site/samples/review-pack/01-gate-failures.json @@ -0,0 +1,35 @@ +{ + "schema_version": "1", + "timestamp": "2026-08-25T00:00:00Z", + "concurrency_control": { + "expected_parent_commit_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state_version_token": "v3.33-OCC" + }, + "statechart_context": { + "active_parent_state_path": ["Root"], + "failed_orthogonal_regions": null + }, + "agent_identity": { + "agent_id": "", + "model_hash": "", + "active_mandate_id": "", + "source": "self-declared", + "reason": "fixture" + }, + "failures": [ + { + "gate_id": "HOUSE-SECURITY-MD", + "severity": "high", + "type": "missing", + "sanitized_description": "SECURITY.md missing or incomplete", + "ast_coordinates": { + "target_file": "SECURITY.md" + }, + "remediation": { + "action_required": "Add SECURITY.md with a disclosure path" + } + } + ], + "pack_id": "house-policy", + "readiness_score": 62 +} diff --git a/site/samples/review-pack/02-action-report.md b/site/samples/review-pack/02-action-report.md new file mode 100644 index 0000000..697e42b --- /dev/null +++ b/site/samples/review-pack/02-action-report.md @@ -0,0 +1,10 @@ +# COMPLIANCE STATUS: FINDINGS + +Deterministic pack evaluation found open findings. + +**Note:** This is evidence preparation for human review — not a certification. + +## HOUSE-SECURITY-MD +- Severity: high +- Target: `SECURITY.md` +- Action: Add SECURITY.md with a disclosure path diff --git a/site/samples/review-pack/03-executive-summary.md b/site/samples/review-pack/03-executive-summary.md new file mode 100644 index 0000000..9ab3000 --- /dev/null +++ b/site/samples/review-pack/03-executive-summary.md @@ -0,0 +1,12 @@ +# Executive Summary — Supplier Readiness + +> Curbpack prepares evidence for **human review**. It does not certify conformity. + +- **Generated:** 2026-08-25T00:00:00Z +- **Packs:** house-policy +- **Readiness score:** 62% +- **Open findings:** 1 + +## Top actions + +1. **[HOUSE-SECURITY-MD]** high — Add SECURITY.md with a disclosure path diff --git a/site/samples/review-pack/README.md b/site/samples/review-pack/README.md new file mode 100644 index 0000000..51f06d7 --- /dev/null +++ b/site/samples/review-pack/README.md @@ -0,0 +1,11 @@ +# Sample review-pack (teaching) + +Minimal **curbpack-native** review-pack for offline document triage demos. + +```bash +curbpack review path/to/this/folder +``` + +Triage reports **confirmed** / **unconfirmed** / **contradicted** findings about the received document — not a product verdict, not conformity assessment, not CE / notified-body approval. + +Required layers (v1): `01-gate-failures.json`, `02-action-report.md`, `03-executive-summary.md`, `buyer-onepager.html`. diff --git a/site/samples/review-pack/buyer-onepager.html b/site/samples/review-pack/buyer-onepager.html new file mode 100644 index 0000000..b7a6f48 --- /dev/null +++ b/site/samples/review-pack/buyer-onepager.html @@ -0,0 +1,28 @@ + + + + + Curbpack — Buyer One-Pager (sample) + + + +
            +

            sample-review-pack

            +

            UNSIGNED — not cryptographically verified. Structural evidence for human review — not conformity assessment.

            +

            Packs: House Policy Example

            +
            Needs remediation
            +

            Files to open

            +

          ArtifactWhat you can trustWhat you must not assume
          SARIF (Static Analysis Results Interchange Format)Same findings in CI/IDE formA security audit complete
          Buyer-questions / lay-of-landHonest checklist and mapA CVE or GRC program
          Buyer one-pager / review packShareable evidence snapshotA certificate of conformity
          curbpack review triageOffline document consistency (confirmed / unconfirmed / contradicted)A product verdict or conformity assessment
          Sources (allowlisted links)Informational citation trail on the back of the one-pagerLegal authority or gate pass/fail input
          SBOM / OpenVEX draftsBest-effort inventory / draft notesComplete vulnerability status
          Attest capsuleSigned only if ssh-agent signedUnsigned equals verified
          SECURITY.mdFor human review: Is a disclosure path present?
          +

          Back — provenance

          +
          +
          Rule packs
          house-policy
          +
          Commit
          aaaaaaaaaaaa…
          +
          Attest
          UNSIGNED — not cryptographically verified
          +
          state_hash
          (none — run curbpack attest after human review)
          +
          result_digest
          484f02efcf8b…
          +
          +

          Informational source: https://eur-lex.europa.eu/eli/reg/2024/2847/oj

          +

          Aha: curbpack review ./site/samples/review-pack — document triage only.

          + + + diff --git a/site/samples/review-pack/index.html b/site/samples/review-pack/index.html new file mode 100644 index 0000000..109b1b5 --- /dev/null +++ b/site/samples/review-pack/index.html @@ -0,0 +1,37 @@ + + + + + + Sample review-pack — Curbpack + + + + +
          + +
          +

          Sample review-pack

          +

          Teaching folder for curbpack review <dir> — document triage only.

          +

          Not conformity assessment. Not CE marking. Not a notified-body opinion.

          +
          curbpack review ./site/samples/review-pack
          + +

          Receiving submissions · Artifact trust table

          +
          +
          + + diff --git a/testdata/sample-review-pack/01-gate-failures.json b/testdata/sample-review-pack/01-gate-failures.json new file mode 100644 index 0000000..9fb4380 --- /dev/null +++ b/testdata/sample-review-pack/01-gate-failures.json @@ -0,0 +1,35 @@ +{ + "schema_version": "1", + "timestamp": "2026-08-25T00:00:00Z", + "concurrency_control": { + "expected_parent_commit_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state_version_token": "v3.33-OCC" + }, + "statechart_context": { + "active_parent_state_path": ["Root"], + "failed_orthogonal_regions": null + }, + "agent_identity": { + "agent_id": "", + "model_hash": "", + "active_mandate_id": "", + "source": "self-declared", + "reason": "fixture" + }, + "failures": [ + { + "gate_id": "HOUSE-SECURITY-MD", + "severity": "high", + "type": "missing", + "sanitized_description": "SECURITY.md missing or incomplete", + "ast_coordinates": { + "target_file": "SECURITY.md" + }, + "remediation": { + "action_required": "Add SECURITY.md with a disclosure path" + } + } + ], + "pack_id": "house-policy", + "readiness_score": 62 +} diff --git a/testdata/sample-review-pack/02-action-report.md b/testdata/sample-review-pack/02-action-report.md new file mode 100644 index 0000000..697e42b --- /dev/null +++ b/testdata/sample-review-pack/02-action-report.md @@ -0,0 +1,10 @@ +# COMPLIANCE STATUS: FINDINGS + +Deterministic pack evaluation found open findings. + +**Note:** This is evidence preparation for human review — not a certification. + +## HOUSE-SECURITY-MD +- Severity: high +- Target: `SECURITY.md` +- Action: Add SECURITY.md with a disclosure path diff --git a/testdata/sample-review-pack/03-executive-summary.md b/testdata/sample-review-pack/03-executive-summary.md new file mode 100644 index 0000000..9ab3000 --- /dev/null +++ b/testdata/sample-review-pack/03-executive-summary.md @@ -0,0 +1,12 @@ +# Executive Summary — Supplier Readiness + +> Curbpack prepares evidence for **human review**. It does not certify conformity. + +- **Generated:** 2026-08-25T00:00:00Z +- **Packs:** house-policy +- **Readiness score:** 62% +- **Open findings:** 1 + +## Top actions + +1. **[HOUSE-SECURITY-MD]** high — Add SECURITY.md with a disclosure path diff --git a/testdata/sample-review-pack/README.md b/testdata/sample-review-pack/README.md new file mode 100644 index 0000000..fa88d8e --- /dev/null +++ b/testdata/sample-review-pack/README.md @@ -0,0 +1,11 @@ +# Sample review-pack (frozen fixture) + +Frozen curbpack-native review-pack for the offline reader aha: + +```bash +curbpack review ./testdata/sample-review-pack +# or: +curbpack review ./site/samples/review-pack +``` + +Document triage only — not a product verdict. Pin stays `@v0.5.2`. diff --git a/testdata/sample-review-pack/buyer-onepager.html b/testdata/sample-review-pack/buyer-onepager.html new file mode 100644 index 0000000..75530c5 --- /dev/null +++ b/testdata/sample-review-pack/buyer-onepager.html @@ -0,0 +1,27 @@ + + + + + Curbpack — Buyer One-Pager (sample) + + + +
          +

          sample-review-pack

          +

          UNSIGNED — not cryptographically verified. Structural evidence for human review — not conformity assessment.

          +

          Packs: House Policy Example

          +
          Needs remediation
          +

          Files to open

          +
          SECURITY.mdFor human review: Is a disclosure path present?
          +

          Back — provenance

          +
          +
          Rule packs
          house-policy
          +
          Commit
          aaaaaaaaaaaa…
          +
          Attest
          UNSIGNED — not cryptographically verified
          +
          state_hash
          (none — run curbpack attest after human review)
          +
          result_digest
          484f02efcf8b…
          +
          +

          Informational source: https://eur-lex.europa.eu/eli/reg/2024/2847/oj

          +
          + + From 10ea7e22e5f0785a413105e02d8c69ee0ba72e4a Mon Sep 17 00:00:00 2001 From: Aslak Felin Date: Tue, 25 Aug 2026 09:05:40 +0200 Subject: [PATCH 2/4] fix: allow historical-verify-target in claim-safety verb skip Co-authored-by: Cursor --- scripts/claim-safety.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/claim-safety.sh b/scripts/claim-safety.sh index 3e9389d..8b023dd 100755 --- a/scripts/claim-safety.sh +++ b/scripts/claim-safety.sh @@ -250,6 +250,7 @@ allowed = verbs | aliases | {"version", "help", "curb"} skip_verb_files = { "docs/software-design-document.md", "docs/internal/sdd-gap-analysis.md", + "docs/internal/historical-verify-target.md", # fences phantom verify; not a ship verb } doc_globs = ["README.md", "docs", "site", "papers", "AGENTS.md", "CLAUDE.md"] paths = [] From ac073290cd1956dc1b5aadced5c9464cda0e0b7d Mon Sep 17 00:00:00 2001 From: Aslak Felin Date: Tue, 25 Aug 2026 22:59:03 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(review):=20signal=20integrity=20?= =?UTF-8?q?=E2=80=94=20digests,=20classifier,=20airlock,=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make offline review metrics interpretable and safe on hostile packs: write payload/file digests into one-pager provenance (fingerprint includes digests so prepare rewrites), classify references once with causes, redact-then-emit airlock, and rank --batch with parent expand. Document schema v2 + assessor offline binary path. Not conformity assessment. Co-authored-by: Cursor --- docs/getting-started/buyer-evidence.md | 8 + docs/getting-started/install.md | 2 + docs/internal/phase6-kill-test.md | 53 +- docs/internal/post-kill-test-gates.md | 10 +- docs/stable-contracts.md | 17 +- internal/cli/help.go | 8 +- internal/cli/review.go | 168 ++++- internal/cli/review_batch_test.go | 123 ++++ internal/release/fingerprint.go | 16 +- internal/release/fingerprint_test.go | 59 +- internal/release/release.go | 76 +- internal/release/release_test.go | 177 +++++ internal/release/templates/onepager.go | 38 +- internal/release/templates/onepager_test.go | 5 + internal/review/references.go | 72 +- internal/review/review.go | 745 +++++++++++++++----- internal/review/review_test.go | 359 +++++++++- site/for-reviewers/index.html | 4 +- 18 files changed, 1669 insertions(+), 271 deletions(-) create mode 100644 internal/cli/review_batch_test.go diff --git a/docs/getting-started/buyer-evidence.md b/docs/getting-started/buyer-evidence.md index dad660c..00f6440 100644 --- a/docs/getting-started/buyer-evidence.md +++ b/docs/getting-started/buyer-evidence.md @@ -20,6 +20,14 @@ Suppliers may reach green via Write, Bring, or CI; that path is theirs. Optional | **Local gate score** | Not a certification score or market-access percentage | | **UNSIGNED** | Capsule may be present; not cryptographically verified | | **ssh-agent-signed** | Real SSH signature on the attest capsule | +| **`curbpack review` triage** | Offline document consistency (confirmed / unconfirmed / contradicted, with cause split) — not a product verdict | + +**Offline / locked laptop:** install via [Ladder 3 — manual binary + checksums](install.md#ladder-3--manual-binary--checksums) (receive binary as file + `checksums.txt`, verify fail-closed). Then: + +```bash +curbpack review +# terse default; --full for all findings + dropped tokens; --json for schema v2 +``` Use the full [artifact trust table](../../site/for-reviewers/) before equating any artifact with legal conformity or CE readiness. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 6cd1d48..43d4f89 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -89,6 +89,8 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned ### Ladder 3 — Manual binary + checksums +For locked-down or offline assessor laptops: receive the release binary **as a file** (email attachment, USB, or internal file drop) together with `checksums.txt`, verify sha256 fail-closed, then place on PATH — same steps as below. No installer required. See also [buyer evidence](buyer-evidence.md) and [for reviewers](../../site/for-reviewers/). + 1. Open the release for pin **`v0.5.4`**: https://github.com/RI-SE/curbpack/releases/tag/v0.5.4 2. Download the asset for your OS from the manifest (`curbpack_darwin_*`, `curbpack_linux_*`, or `curbpack_windows_amd64.exe`) **and** `checksums.txt`. 3. Verify sha256 (fail closed if mismatch). diff --git a/docs/internal/phase6-kill-test.md b/docs/internal/phase6-kill-test.md index 07e0534..6154a46 100644 --- a/docs/internal/phase6-kill-test.md +++ b/docs/internal/phase6-kill-test.md @@ -4,44 +4,69 @@ ## Goal -Falsify or support product value of offline **document triage** (`curbpack review` on curbpack-native review-packs): a useful mix of **confirmed** / **unconfirmed** / **contradicted** states across real submissions. +Falsify or support product value of offline **document triage** (`curbpack review` on curbpack-native review-packs): a useful mix of **confirmed** / **unconfirmed** / **contradicted** states across real submissions — reported as the **cause split**, never vanity summed `% unconfirmed` alone. **Variance required:** ~100% confirmed **or** ~100% unconfirmed across the public set **falsifies** product value (no discriminative signal). Report **distribution**, never name-and-shame projects. +## Gate before cohort + +**Negative (sensitivity) + specificity PASS is required before any cohort / `--batch` kill-test n=10.** + +| Control | Pass rule | +|---------|-----------| +| Sensitivity matrix | Broken packs must detect: absent cited path → `unconfirmed`+`genuine`; altered `result_digest` / wrong sbom digest → `contradicted`+`self_disagree` | +| Specificity | Known-good bundle → `UnconfirmedGenuine == 0` **and** `ContradictedCount == 0` exactly | +| Extractor | Dogfood `UnconfirmedExtractor == 0` (non-zero is a classifier regression — fix before cohort) | + +P4 red → no cohort, no circular OSS share→review as sole kill-test. See tests in `internal/review`. + ## Procedure -### 1. Dogfood curbpack-on-itself +### 1. Dogfood curbpack-on-itself (split metrics) 1. Run the existing dogfood path: [`.github/workflows/curbpack-dogfood.yml`](../../.github/workflows/curbpack-dogfood.yml) (Action against this repo; local equivalent: build binary → `curbpack check` / share path as documented). 2. If a local curbpack-native **review-pack** directory is present (e.g. after prepare-release / share; often gitignored as `/review-pack/`), run: ```bash - curbpack review + curbpack review --full ``` -3. Record triage counts only (confirmed / unconfirmed / contradicted). Document triage ≠ product verdict. +3. Record **split** counts: confirmed; unconfirmed by cause (`producer` / `extractor` / `genuine` / `external`); contradicted by cause (`self_disagree` / …). Document triage ≠ product verdict. -### 2. Ten public OSS projects (distribution only) +### 2. Ten public OSS projects (only after P4 green) 1. Select **ten** public open-source projects that publish **security documentation** (e.g. SECURITY.md, security.txt, disclosure policy — claim-safe selection criteria only). 2. For each, produce or obtain a curbpack-native review-pack via the normal local loop (scan/init/check/share as applicable on a clone) — **on the evaluator’s machine**; do not upload proprietary trees. -3. Run `curbpack review ` on each pack. -4. Aggregate **only** the distribution of confirmed / unconfirmed / contradicted counts (or percentages) across the ten. **Do not** publish a per-project shame table. Internal worksheets may keep ids; public write-ups stay anonymized aggregates. +3. Prefer **independent docs+code or intake-completeness** pilots when negatives fire and specificity holds — do **not** treat circular OSS share→review as the sole kill-test. +4. Run `curbpack review ` (or `--batch` over prepared dirs) on each pack. +5. Aggregate **only** the distribution of split counts across the ten. **Do not** publish a per-project shame table. Internal worksheets may keep ids; public write-ups stay anonymized aggregates. ### 3. Named org case study A **named** organization case study requires **prior written consent**. Without consent: keep anonymized or omit. +## Cohort decision (after dogfood + P4) + +| Observation | Decision | +|-------------|----------| +| Negatives fire; specificity holds; dogfood genuine low | Detector works; prefer independent docs+code or intake-completeness pilot | +| Sensitivity or specificity fails | No cohort | +| `extractor` non-zero on dogfood | Classifier regression — fix first | + +Defer `intake` / `packs lint` per [post-kill-test-gates.md](post-kill-test-gates.md). + ## Results table (placeholder) -| Cohort | n | % confirmed | % unconfirmed | % contradicted | Notes | -|--------|---|-------------|---------------|----------------|-------| -| Self dogfood (this repo `review-pack/`) | 1 | 43% (23) | 57% (30) | 0% (0) | 2026-08-25 local `curbpack review ./review-pack` — state mix present | -| Frozen sample fixture | 1 | 53% (10) | 47% (9) | 0% (0) | `testdata/sample-review-pack` — aha path | -| Public OSS (security docs) | 10 | TBD | TBD | TBD | Cohort list below — aggregate only; no project names in public report | +| Cohort | n | confirmed | unconfirmed split (P/E/G/X) | contradicted | Notes | +|--------|---|-----------|-----------------------------|--------------|-------| +| Self dogfood (this repo `review-pack/`) | 1 | 23 | 0 / 0 / 0 / 0 | 0 | After digest-fingerprint + triage-surface harden: digests confirmed; extractor 0; genuine 0 (no cache-path flood) | +| Frozen sample fixture | 1 | 10 | 7 / 0 / 1 / 1 | 0 | `testdata/sample-review-pack` — aha path; extractor 0; genuine=SECURITY.md cite | +| Public OSS (security docs) | 10 | TBD | TBD | TBD | **Blocked until human records dogfood + decides cohort type** | | Named org (consent) | TBD | TBD | TBD | TBD | Omit until written consent | -**Early signal:** self dogfood + sample are **not** ~100% confirmed or ~100% unconfirmed — discriminative mix exists on curbpack-native packs. Full OSS n=10 still required before opening intake/lint/batch ([post-kill-test-gates.md](post-kill-test-gates.md)). +**P4 controls (automated):** sensitivity matrix + specificity known-good — PASS in `internal/review` (`TestSensitivityMatrix`, `TestSpecificityKnownGood`). Extractor on sample = 0. + +**Cohort decision (process):** Prefer independent docs+code or intake-completeness pilot once a human records self-dogfood split metrics; do **not** use circular OSS share→review as the sole kill-test. Intake/lint remain deferred. ## Public OSS cohort (internal worksheet — consent-free) @@ -58,7 +83,7 @@ Ten public projects with published security documentation (selection only; resul 9. rustls/rustls 10. python/cpython -Procedure per clone: shallow clone → `curbpack init --yes` (or house profile) → `check` / `share` as far as the tree allows → `curbpack review ` → record counts only. Never publish a per-project shame table. +Procedure per clone: shallow clone → `curbpack init --yes` (or house profile) → `check` / `share` as far as the tree allows → `curbpack review ` → record **split** counts only. Never publish a per-project shame table. ## Claim discipline diff --git a/docs/internal/post-kill-test-gates.md b/docs/internal/post-kill-test-gates.md index 7d09fe0..fbd5128 100644 --- a/docs/internal/post-kill-test-gates.md +++ b/docs/internal/post-kill-test-gates.md @@ -4,19 +4,21 @@ ## Rule -**Intake**, **packs lint**, and **`--batch`** (strategy phases 2–4) stay **gated** until either: +**Intake** and **packs lint** stay **gated** until either: 1. **Phase 6 kill-test** completes with an acceptable outcome (see [phase6-kill-test.md](phase6-kill-test.md)), **or** 2. An **explicit human risk accept** is recorded (written maintainer decision — not an agent invent, not a chat greenlight). -Do **not** implement intake, packs-lint expansion, or `--batch` while this gate is closed. +`curbpack review --batch` is available after **sensitivity + specificity** controls pass (see phase6 gate table) for ranked local triage. It is **not** a greenlight for intake/lint or public cohort advice. + +Do **not** implement intake or packs-lint expansion while this gate is closed. ## Why -The reader wedge (`curbpack review` + review-pack triage) must prove distributional value on real packs before we widen intake/batch surface. Shipping those paths early creates operational load without a falsifiable product signal. +The reader wedge (`curbpack review` + review-pack triage) must prove distributional value on real packs before we widen intake surface. Shipping intake early creates operational load without a falsifiable product signal. ## Out of scope here -- Implementing intake / packs lint / `--batch` +- Implementing intake / packs lint - Pin bumps, trust-import, attest, or HPURL work - Claiming certification or CE / notified-body outcomes from kill-test metrics diff --git a/docs/stable-contracts.md b/docs/stable-contracts.md index 35154ea..a3fd8b9 100644 --- a/docs/stable-contracts.md +++ b/docs/stable-contracts.md @@ -83,4 +83,19 @@ Frozen ops (unchanged): `validate_delta`, `get_latest_failure`, `graph_summary`, `curbpack share --bundle` writes `review-pack/evidence-bundle.html` with ``, optional REMEDIATION banner on red gates, and embedded hpurl pointer JSON for offline verify. -See also: [Strategy boundary](strategy-boundary.md) · [Coreward bridge](coreward-bridge.md) · [Security model](security-model.md) +## Review report (`curbpack review`) + +Offline document triage of a received curbpack-native review-pack. **Not** a product verdict or conformity assessment. No current MCP / `exportx` consumer — CLI-local assessor surface; treat JSON as the product contract for future intake. + +| Contract | Rule | +|----------|------| +| Schema | `curbpack-review-report:2` (`--json`) | +| Classifier | `classifier_version` string (e.g. `refclass:1`) — golden list; changes that move the reference denominator must be visible | +| States | `confirmed` \| `unconfirmed` \| `contradicted` only | +| Additive `cause` | Unconfirmed: `producer` \| `extractor` \| `genuine` \| `external`. Contradicted: `self_disagree` (digest/structure contradictions) | +| Counters | Split unconfirmed/contradicted-by-cause fields; `dropped_count` (+ `dropped` under `--full`) | +| Digests | Producer emits payload/file digests; bind disagreements appear as sibling `*_bind` keys — reader contradicts, never silently prefers bind | +| Airlock | Redact-then-emit home-path/PEM in findings, then fail closed via `PacketLooksAirlocked` | +| Exit | `1` if any contradicted (or `--batch` child unreadable/contradicted); usage → `2`. `--batch --full` / `--batch --json` → usage | + +See also: [Strategy boundary](strategy-boundary.md) · [Coreward bridge](coreward-bridge.md) · [Security model](security-model.md) · [Phase 6 kill-test](internal/phase6-kill-test.md) diff --git a/internal/cli/help.go b/internal/cli/help.go index 2168d16..48c9e07 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -27,10 +27,14 @@ func commandUsage(cmd string) { fmt.Fprintf(os.Stderr, "Usage: curbpack share [--bundle] [--reveal] [--packs a,b] [--skip-prepare-release]\n") fmt.Fprintf(os.Stderr, " Recipe: check → context-pack → buyer-questions → prepare-release.\n") case "review": - fmt.Fprintf(os.Stderr, "Usage: curbpack review [--json]\n") + fmt.Fprintf(os.Stderr, "Usage: curbpack review [--json] [--full]\n") + fmt.Fprintf(os.Stderr, " curbpack review --batch …\n") fmt.Fprintf(os.Stderr, " Offline triage of a curbpack-native review-pack (no git, no network).\n") fmt.Fprintf(os.Stderr, " States: confirmed | unconfirmed | contradicted — document only, not a product verdict.\n") - fmt.Fprintf(os.Stderr, " Exit 1 if any finding is contradicted; exit 2 on usage errors.\n") + fmt.Fprintf(os.Stderr, " Default output is terse; --full dumps all findings + dropped tokens; --json emits schema v2.\n") + fmt.Fprintf(os.Stderr, " --batch expands a non-pack parent to immediate child packs; ranks unreadable, then contradicted, then genuine-desc.\n") + fmt.Fprintf(os.Stderr, " --batch does not combine with --full or --json (run per-pack with --full / --json).\n") + fmt.Fprintf(os.Stderr, " Exit 1 if any finding is contradicted (or any --batch child is contradicted/unreadable); exit 2 on usage errors.\n") case "drift": fmt.Fprintf(os.Stderr, "Usage: curbpack drift [--json]\n") fmt.Fprintf(os.Stderr, " Multi-signal evidence checklist — informational; exit 0 always.\n") diff --git a/internal/cli/review.go b/internal/cli/review.go index 6f8710d..25474bc 100644 --- a/internal/cli/review.go +++ b/internal/cli/review.go @@ -3,6 +3,8 @@ package cli import ( "fmt" "os" + "path/filepath" + "sort" "strings" "github.com/afelin/curbpack/internal/review" @@ -11,7 +13,9 @@ import ( func cmdReview(args []string) error { jsonOut := false - var path string + full := false + batch := false + var paths []string for i := 0; i < len(args); i++ { a := args[i] switch { @@ -19,27 +23,41 @@ func cmdReview(args []string) error { return helpShownErr("review") case a == "--json": jsonOut = true + case a == "--full": + full = true + case a == "--batch": + batch = true case strings.HasPrefix(a, "-"): return usageErr("unknown flag for review: " + a) default: - if path != "" { - return usageErr("review accepts a single pack directory path") - } - path = a + paths = append(paths, a) } } - if path == "" { + if len(paths) == 0 { commandUsage("review") return usageErr("review requires a path to a received review-pack directory") } + if batch { + if jsonOut { + return usageErr("review --batch does not combine with --json (run per-child with --json)") + } + if full { + return usageErr("batch prints rank lines only; run per-pack with --full") + } + return runReviewBatch(paths) + } + if len(paths) != 1 { + return usageErr("review accepts a single pack directory (use --batch for many)") + } tty.PrintHeader("curbpack review") fmt.Fprintf(os.Stderr, "%s\n", tty.C(tty.Dim, "Offline document triage — not a product verdict.")) rep, err := review.Run(review.Options{ - BundleRoot: path, + BundleRoot: paths[0], Writer: os.Stdout, JSONOut: jsonOut, + Full: full, }) if err != nil { return usageErr(err.Error()) @@ -50,3 +68,139 @@ func cmdReview(args []string) error { } return nil } + +type batchRow struct { + path string + base string + rep review.Report + err error + unreadable bool + contradict bool + genuine int +} + +func runReviewBatch(paths []string) error { + tty.PrintHeader("curbpack review --batch") + fmt.Fprintf(os.Stderr, "%s\n", tty.C(tty.Dim, "Offline document triage — not a product verdict.")) + + children := expandBatchPaths(paths) + if len(children) == 0 { + return usageErr("review --batch found no review-pack directories") + } + + var rows []batchRow + for _, p := range children { + row := batchRow{path: p, base: filepath.Base(p)} + rep, err := review.Run(review.Options{ + BundleRoot: p, + Writer: ioDiscard{}, + }) + if err != nil { + row.err = err + row.unreadable = true + } else { + row.rep = rep + row.contradict = review.HasContradictions(rep) + row.genuine = rep.UnconfirmedGenuine + } + rows = append(rows, row) + } + + sort.SliceStable(rows, func(i, j int) bool { + // (1) unreadable / parse-fail first, (2) contradictions, (3) genuine desc, (4) basename + if rows[i].unreadable != rows[j].unreadable { + return rows[i].unreadable + } + if rows[i].contradict != rows[j].contradict { + return rows[i].contradict + } + if rows[i].genuine != rows[j].genuine { + return rows[i].genuine > rows[j].genuine + } + return rows[i].base < rows[j].base + }) + + anyBad := false + for _, r := range rows { + status := "ok" + extra := "" + switch { + case r.unreadable: + status = "UNREADABLE" + extra = r.err.Error() + anyBad = true + case r.contradict: + status = "CONTRADICTED" + extra = fmt.Sprintf("%d contradicted · %d genuine", r.rep.ContradictedCount, r.rep.UnconfirmedGenuine) + anyBad = true + default: + extra = fmt.Sprintf("%d genuine · %d extractor · confirmed %d", + r.rep.UnconfirmedGenuine, r.rep.UnconfirmedExtractor, r.rep.ConfirmedCount) + } + fmt.Printf("%s\t%s\t%s\n", status, r.base, extra) + } + if anyBad { + return gatesErr() + } + return nil +} + +// looksLikeReviewPack reports whether dir has the required triage layers. +func looksLikeReviewPack(dir string) bool { + for _, name := range []string{"01-gate-failures.json", "buyer-onepager.html"} { + st, err := os.Stat(filepath.Join(dir, name)) + if err != nil || st.IsDir() || st.Size() == 0 { + return false + } + } + return true +} + +// expandBatchPaths expands each arg: a pack dir is used as-is; a non-pack directory +// expands to immediate child dirs that look like review-packs. Junk children are ignored. +func expandBatchPaths(paths []string) []string { + seen := map[string]struct{}{} + var out []string + add := func(p string) { + p = filepath.Clean(p) + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + out = append(out, p) + } + for _, p := range paths { + p = filepath.Clean(p) + st, err := os.Stat(p) + if err != nil || !st.IsDir() { + // Keep unreadable/non-dir paths so batch can mark UNREADABLE. + add(p) + continue + } + if looksLikeReviewPack(p) { + add(p) + continue + } + entries, err := os.ReadDir(p) + if err != nil { + add(p) + continue + } + foundChild := false + for _, e := range entries { + if !e.IsDir() { + continue + } + child := filepath.Join(p, e.Name()) + if looksLikeReviewPack(child) { + add(child) + foundChild = true + } + } + if !foundChild { + // Parent was not a pack and had no pack children — surface as unreadable child. + add(p) + } + } + return out +} diff --git a/internal/cli/review_batch_test.go b/internal/cli/review_batch_test.go new file mode 100644 index 0000000..11f1495 --- /dev/null +++ b/internal/cli/review_batch_test.go @@ -0,0 +1,123 @@ +package cli + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExpandBatchPaths_parentChildren(t *testing.T) { + parent := t.TempDir() + writeMinimalPack(t, filepath.Join(parent, "pack-a")) + writeMinimalPack(t, filepath.Join(parent, "pack-b")) + junk := filepath.Join(parent, "junk") + if err := os.MkdirAll(junk, 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(junk, "readme.txt"), []byte("nope\n"), 0o644) + + got := expandBatchPaths([]string{parent}) + if len(got) != 2 { + t.Fatalf("want 2 pack children, got %d: %v", len(got), got) + } + bases := map[string]bool{} + for _, p := range got { + bases[filepath.Base(p)] = true + } + if !bases["pack-a"] || !bases["pack-b"] { + t.Fatalf("want pack-a and pack-b, got %v", got) + } + if bases["junk"] { + t.Fatal("junk dir must be ignored") + } +} + +func TestExpandBatchPaths_singlePack(t *testing.T) { + dir := t.TempDir() + pack := filepath.Join(dir, "one") + writeMinimalPack(t, pack) + got := expandBatchPaths([]string{pack}) + if len(got) != 1 || got[0] != filepath.Clean(pack) { + t.Fatalf("pack dir must be single child, got %v", got) + } +} + +func TestCmdReviewBatch(t *testing.T) { + parent := t.TempDir() + writeMinimalPack(t, filepath.Join(parent, "alpha")) + writeMinimalPack(t, filepath.Join(parent, "beta")) + _ = os.MkdirAll(filepath.Join(parent, "noise"), 0o755) + + stdout, _ := captureReview(t, func() { + err := Run([]string{"review", "--batch", parent}) + // Producer-unconfirmed optional layers are fine; contradictions exit gates. + if err != nil && ExitCode(err) != ExitOK && ExitCode(err) != ExitGates { + t.Fatalf("unexpected exit: %v code=%d", err, ExitCode(err)) + } + }) + if !strings.Contains(stdout, "alpha") || !strings.Contains(stdout, "beta") { + t.Fatalf("batch ranks must name both packs: %q", stdout) + } + if strings.Contains(stdout, "noise") { + t.Fatalf("junk child must not appear: %q", stdout) + } + + err := Run([]string{"review", "--batch", parent, "--full"}) + if ExitCode(err) != ExitUsage { + t.Fatalf("--batch --full must usage-exit, got %d (%v)", ExitCode(err), err) + } + if err == nil || !strings.Contains(err.Error(), "batch prints rank lines only") { + t.Fatalf("--batch --full message missing: %v", err) + } +} + +func writeMinimalPack(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + payload := `{"schema_version":"1","pack_id":"house-policy","readiness_score":80}` + "\n" + _ = os.WriteFile(filepath.Join(dir, "01-gate-failures.json"), []byte(payload), 0o644) + _ = os.WriteFile(filepath.Join(dir, "02-action-report.md"), []byte("ok\n"), 0o644) + _ = os.WriteFile(filepath.Join(dir, "03-executive-summary.md"), []byte("ok\n"), 0o644) + _ = os.WriteFile(filepath.Join(dir, "buyer-onepager.html"), []byte( + `
          Rule packs
          house-policy
          `, + ), 0o644) +} + +func captureReview(t *testing.T, fn func()) (stdout, stderr string) { + t.Helper() + oldOut, oldErr := os.Stdout, os.Stderr + rOut, wOut, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + rErr, wErr, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout, os.Stderr = wOut, wErr + defer func() { + os.Stdout, os.Stderr = oldOut, oldErr + }() + doneOut := make(chan string) + doneErr := make(chan string) + go func() { + b, _ := io.ReadAll(rOut) + doneOut <- string(b) + }() + go func() { + b, _ := io.ReadAll(rErr) + doneErr <- string(b) + }() + fn() + _ = wOut.Close() + _ = wErr.Close() + stdout = <-doneOut + stderr = <-doneErr + _ = rOut.Close() + _ = rErr.Close() + return stdout, stderr +} diff --git a/internal/release/fingerprint.go b/internal/release/fingerprint.go index fe903d2..b116883 100644 --- a/internal/release/fingerprint.go +++ b/internal/release/fingerprint.go @@ -29,7 +29,8 @@ func LoadCachedGatePayload(repoRoot string) (ir.GateFailurePayload, bool) { } // FingerprintFromGatePayload computes the same stable marker as buyerOnePager without HTML. -func FingerprintFromGatePayload(payload ir.GateFailurePayload, bind attest.BindInfo, score int, passed bool) string { +// reviewPackDir is the on-disk review-pack (hashes 04-sbom / 05-vex when present). +func FingerprintFromGatePayload(payload ir.GateFailurePayload, bind attest.BindInfo, score int, passed bool, reviewPackDir string) string { line, _, unsignedLoud := attest.AttestDisplay(bind) var failures []templates.OnePagerFailure for _, f := range payload.Failures { @@ -37,16 +38,25 @@ func FingerprintFromGatePayload(payload ir.GateFailurePayload, bind attest.BindI GateID: f.GateID, Severity: f.Severity, }) } + resultDigest := ir.ComputeResultDigest(payload) + sbomDigest := "" + vexDigest := "" + if reviewPackDir != "" { + sbomDigest = fileSHA256Hex(filepath.Join(reviewPackDir, "04-sbom.cdx.json")) + vexDigest = fileSHA256Hex(filepath.Join(reviewPackDir, "05-vex-draft.json")) + } return templates.OnePagerFingerprint(templates.OnePagerDTO{ Score: score, Passed: passed, PackID: payload.PackID, Failures: failures, Bind: bind, AttestLine: line, UnsignedLoud: unsignedLoud, + ResultDigest: resultDigest, SBOMDigest: sbomDigest, VEXDigest: vexDigest, }) } // ShareStaleReport compares on-disk buyer-onepager.html fp to expected fp from cache+bind. // Returns signal id and human detail (empty signal when no comparison possible). func ShareStaleReport(repoRoot string, bind attest.BindInfo, score int, passed bool) (signal, detail string) { - onepagerPath := filepath.Join(repoRoot, "review-pack", "buyer-onepager.html") + packDir := filepath.Join(repoRoot, "review-pack") + onepagerPath := filepath.Join(packDir, "buyer-onepager.html") prev, err := os.ReadFile(onepagerPath) if err != nil { return "share_no_review_pack", "review-pack/buyer-onepager.html missing — run curbpack share" @@ -55,7 +65,7 @@ func ShareStaleReport(repoRoot string, bind attest.BindInfo, score int, passed b if !ok { return "share_cache_missing", "no gate cache JSON — run curbpack check or share" } - expected := FingerprintFromGatePayload(payload, bind, score, passed) + expected := FingerprintFromGatePayload(payload, bind, score, passed, packDir) onDisk := extractOnePagerFP(string(prev)) if onDisk == "" { return "share_stale", "buyer-onepager.html missing fingerprint marker — re-run share" diff --git a/internal/release/fingerprint_test.go b/internal/release/fingerprint_test.go index f47007d..3ade1b4 100644 --- a/internal/release/fingerprint_test.go +++ b/internal/release/fingerprint_test.go @@ -1,7 +1,9 @@ package release_test import ( + "crypto/sha256" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -39,18 +41,20 @@ func TestShareStale_match(t *testing.T) { raw, _ := json.Marshal(payload) _ = os.WriteFile(filepath.Join(cache, "latest_result.json"), raw, 0o644) bind := attest.BindInfo{Found: false} - fp := release.FingerprintFromGatePayload(payload, bind, 100, true) + packDir := filepath.Join(dir, "review-pack") + fp := release.FingerprintFromGatePayload(payload, bind, 100, true, packDir) htmlDoc := templates.BuyerOnePagerHTML(templates.OnePagerDTO{ RepoName: "test", Score: 100, Passed: true, PackID: "house-policy", Timestamp: "2026-01-01T00:00:00Z", AttestLine: "UNSIGNED — not cryptographically verified", AttestClass: "unsigned", UnsignedLoud: true, ProvenanceHTML: "
          ", FooterPrefix: "x · ", + ResultDigest: ir.ComputeResultDigest(payload), }) if !strings.Contains(htmlDoc, fp) { t.Fatalf("html missing fp marker %s", fp) } - _ = os.MkdirAll(filepath.Join(dir, "review-pack"), 0o755) - _ = os.WriteFile(filepath.Join(dir, "review-pack", "buyer-onepager.html"), []byte(htmlDoc), 0o644) + _ = os.MkdirAll(packDir, 0o755) + _ = os.WriteFile(filepath.Join(packDir, "buyer-onepager.html"), []byte(htmlDoc), 0o644) sig, _ := release.ShareStaleReport(dir, bind, 100, true) if sig != "share_current" { t.Fatalf("want share_current, got %s", sig) @@ -80,15 +84,60 @@ func TestFingerprintFromGatePayload_MatchesHTML(t *testing.T) { }, } bind := attest.BindInfo{CommitSHA: "abc", StateHash: "def"} + rd := ir.ComputeResultDigest(payload) dto := templates.OnePagerDTO{ Score: 80, Passed: false, PackID: payload.PackID, Failures: []templates.OnePagerFailure{{GateID: "G-1", Severity: "high"}}, Bind: bind, AttestLine: "UNSIGNED — not cryptographically verified", - UnsignedLoud: true, + UnsignedLoud: true, ResultDigest: rd, } htmlFP := templates.OnePagerFingerprint(dto) - payloadFP := release.FingerprintFromGatePayload(payload, bind, 80, false) + payloadFP := release.FingerprintFromGatePayload(payload, bind, 80, false, "") if htmlFP != payloadFP { t.Fatalf("fp mismatch html=%s payload=%s", htmlFP, payloadFP) } } + +func TestShareStale_coherentWithDigests(t *testing.T) { + dir := t.TempDir() + payload := ir.GateFailurePayload{PackID: "house-policy", ReadinessScore: 100} + cache := filepath.Join(dir, ".github", "curbpack", "cache") + _ = os.MkdirAll(cache, 0o755) + raw, _ := json.Marshal(payload) + _ = os.WriteFile(filepath.Join(cache, "latest_result.json"), raw, 0o644) + packDir := filepath.Join(dir, "review-pack") + _ = os.MkdirAll(packDir, 0o755) + sbom := []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`) + _ = os.WriteFile(filepath.Join(packDir, "04-sbom.cdx.json"), sbom, 0o644) + vex := []byte(`{"@context":"https://openvex.dev/ns","statements":[]}`) + _ = os.WriteFile(filepath.Join(packDir, "05-vex-draft.json"), vex, 0o644) + bind := attest.BindInfo{Found: false} + fp := release.FingerprintFromGatePayload(payload, bind, 100, true, packDir) + htmlDoc := templates.BuyerOnePagerHTML(templates.OnePagerDTO{ + RepoName: "test", Score: 100, Passed: true, PackID: "house-policy", + Timestamp: "2026-01-01T00:00:00Z", AttestLine: "UNSIGNED — not cryptographically verified", + AttestClass: "unsigned", UnsignedLoud: true, + ProvenanceHTML: "
          result_digest
          x
          ", FooterPrefix: "x · ", + ResultDigest: ir.ComputeResultDigest(payload), + SBOMDigest: mustFileSHA(t, filepath.Join(packDir, "04-sbom.cdx.json")), + VEXDigest: mustFileSHA(t, filepath.Join(packDir, "05-vex-draft.json")), + }) + if !strings.Contains(htmlDoc, fp) { + t.Fatalf("html missing fp %s", fp) + } + _ = os.WriteFile(filepath.Join(packDir, "buyer-onepager.html"), []byte(htmlDoc), 0o644) + sig, detail := release.ShareStaleReport(dir, bind, 100, true) + if sig != "share_current" { + t.Fatalf("want share_current with digests present, got %s (%s)", sig, detail) + } +} + +func mustFileSHA(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(b) + return fmt.Sprintf("%x", sum) +} diff --git a/internal/release/release.go b/internal/release/release.go index 53bd210..ed68de4 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -13,6 +13,7 @@ import ( "github.com/afelin/curbpack/internal/attest" "github.com/afelin/curbpack/internal/config" "github.com/afelin/curbpack/internal/exportx" + "github.com/afelin/curbpack/internal/ir" "github.com/afelin/curbpack/internal/packs" "github.com/afelin/curbpack/internal/release/templates" "github.com/afelin/curbpack/internal/research" @@ -117,7 +118,8 @@ func Prepare(opts Options) error { record(copyFile(joinPath, filepath.Join(out, "07-watchlist-sbom-join.json"))) } // Buyer one-pager HTML — skip rewrite when gate snapshot fingerprint unchanged. - htmlDoc := buyerOnePager(root, res) + // Digests are computed from payload + written layers (not silently preferred from bind). + htmlDoc := buyerOnePager(root, out, res) onepagerPath := filepath.Join(out, "buyer-onepager.html") wrote, err := writeOnePagerIfChanged(onepagerPath, htmlDoc) if err != nil { @@ -257,7 +259,7 @@ func executiveSummary(res validate.Result) string { return b.String() } -func buyerOnePager(root string, res validate.Result) string { +func buyerOnePager(root, outDir string, res validate.Result) string { name := filepath.Base(root) bind, _ := attest.LatestBind(root) line, class, unsignedLoud := attest.AttestDisplay(bind) @@ -300,6 +302,9 @@ func buyerOnePager(root string, res validate.Result) string { }) } } + resultDigest := ir.ComputeResultDigest(res.Payload) + sbomDigest := fileSHA256Hex(filepath.Join(outDir, "04-sbom.cdx.json")) + vexDigest := fileSHA256Hex(filepath.Join(outDir, "05-vex-draft.json")) dto := templates.OnePagerDTO{ RepoName: name, Score: res.Score, @@ -315,9 +320,12 @@ func buyerOnePager(root string, res validate.Result) string { UnsignedLoud: unsignedLoud, AssuranceClass: assuranceClass, MechanicalSummary: mechanicalSummary, - ProvenanceHTML: provenanceDL(res.Payload.PackID, bind, line, unsignedLoud), + ProvenanceHTML: provenanceDL(res.Payload, bind, line, unsignedLoud, resultDigest, sbomDigest, vexDigest), SourcesHTML: sourcesStrip(root, res.Payload.PackID), FooterPrefix: footerHTML(line, unsignedLoud), + ResultDigest: resultDigest, + SBOMDigest: sbomDigest, + VEXDigest: vexDigest, } return templates.BuyerOnePagerHTML(dto) } @@ -342,7 +350,7 @@ func footerHTML(line string, unsignedLoud bool) string { return html.EscapeString(line) + " · " } -func provenanceDL(packID string, bind attest.BindInfo, line string, unsignedLoud bool) string { +func provenanceDL(payload ir.GateFailurePayload, bind attest.BindInfo, line string, unsignedLoud bool, payloadDigest, sbomDigest, vexDigest string) string { commit := bind.CommitSHA if commit == "" || commit == "unknown" { commit = "(no commit)" @@ -363,19 +371,39 @@ func provenanceDL(packID string, bind attest.BindInfo, line string, unsignedLoud if !unsignedLoud { signOff = "Human-bound on this commit (ssh-agent-signed). Still not conformity assessment." } + + // Payload / file digests are source of truth for the share; bind values that + // disagree are emitted alongside so the offline reader can contradict. + // Digests never upgrade UNSIGNED / not-cryptographically-verified rendering. + var b strings.Builder b.WriteString(`
          `) - fmt.Fprintf(&b, "
          Rule packs
          %s
          \n", html.EscapeString(packID)) + fmt.Fprintf(&b, "
          Rule packs
          %s
          \n", html.EscapeString(payload.PackID)) fmt.Fprintf(&b, "
          Commit
          %s
          \n", html.EscapeString(truncateSHA(commit))) fmt.Fprintf(&b, "
          Attest
          %s
          \n", html.EscapeString(line)) fmt.Fprintf(&b, "
          Signer
          %s
          \n", html.EscapeString(signer)) fmt.Fprintf(&b, "
          User touch
          %s
          \n", html.EscapeString(touch)) fmt.Fprintf(&b, "
          state_hash
          %s
          \n", html.EscapeString(state)) - if bind.SBOMDigest != "" { - fmt.Fprintf(&b, "
          sbom_digest
          %s
          \n", html.EscapeString(truncateSHA(bind.SBOMDigest))) - } - if bind.VEXDigest != "" { - fmt.Fprintf(&b, "
          vex_digest
          %s
          \n", html.EscapeString(truncateSHA(bind.VEXDigest))) + fmt.Fprintf(&b, "
          result_digest
          %s
          \n", html.EscapeString(truncateSHA(payloadDigest))) + if bind.ResultDigest != "" && !digestPrefixAgree(payloadDigest, bind.ResultDigest) { + fmt.Fprintf(&b, "
          result_digest_bind
          %s
          \n", html.EscapeString(truncateSHA(bind.ResultDigest))) + } + if sbomDigest != "" { + fmt.Fprintf(&b, "
          sbom_digest
          %s
          \n", html.EscapeString(truncateSHA(sbomDigest))) + if bind.SBOMDigest != "" && !digestPrefixAgree(sbomDigest, bind.SBOMDigest) { + fmt.Fprintf(&b, "
          sbom_digest_bind
          %s
          \n", html.EscapeString(truncateSHA(bind.SBOMDigest))) + } + } else if bind.SBOMDigest != "" { + // No file on disk — still surface bind claim so reader can leave it unconfirmed. + fmt.Fprintf(&b, "
          sbom_digest_bind
          %s
          \n", html.EscapeString(truncateSHA(bind.SBOMDigest))) + } + if vexDigest != "" { + fmt.Fprintf(&b, "
          vex_digest
          %s
          \n", html.EscapeString(truncateSHA(vexDigest))) + if bind.VEXDigest != "" && !digestPrefixAgree(vexDigest, bind.VEXDigest) { + fmt.Fprintf(&b, "
          vex_digest_bind
          %s
          \n", html.EscapeString(truncateSHA(bind.VEXDigest))) + } + } else if bind.VEXDigest != "" { + fmt.Fprintf(&b, "
          vex_digest_bind
          %s
          \n", html.EscapeString(truncateSHA(bind.VEXDigest))) } if name := strings.TrimSpace(bind.ReviewedBy); name != "" { fmt.Fprintf(&b, "
          Reviewed by
          %s — recorded review, not assessment.
          \n", html.EscapeString(name)) @@ -386,6 +414,34 @@ func provenanceDL(packID string, bind attest.BindInfo, line string, unsignedLoud return b.String() } +func fileSHA256Hex(path string) string { + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return "" + } + sum := sha256.Sum256(data) + return fmt.Sprintf("%x", sum) +} + +// digestPrefixAgree mirrors the offline reviewer's truncateSHA / 12-hex prefix contract. +func digestPrefixAgree(full, claimed string) bool { + claimed = strings.TrimSpace(claimed) + claimed = strings.TrimSuffix(claimed, "…") + claimed = strings.TrimSuffix(claimed, "...") + claimed = strings.TrimSpace(claimed) + if claimed == "" || full == "" { + return false + } + n := len(claimed) + if n > len(full) { + n = len(full) + } + if n > 12 { + n = 12 + } + return strings.HasPrefix(full, claimed[:n]) +} + // sourcesStrip adds claim-safe allowlisted citation links when a research packet exists // with PackIDs matching the release packs (or falls back to composed pack citation URLs). // Informational only — not conformity. diff --git a/internal/release/release_test.go b/internal/release/release_test.go index b7806cb..0a9059b 100644 --- a/internal/release/release_test.go +++ b/internal/release/release_test.go @@ -8,7 +8,10 @@ import ( "strings" "testing" + "github.com/afelin/curbpack/internal/attest" "github.com/afelin/curbpack/internal/release" + "github.com/afelin/curbpack/internal/release/templates" + "github.com/afelin/curbpack/internal/review" ) func TestPrepareAggregatesPartialWriteFailures(t *testing.T) { @@ -45,6 +48,180 @@ func TestPrepareAggregatesPartialWriteFailures(t *testing.T) { } } +func TestPrepareEmitsResultDigestAndStaysUnsigned(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "1704067200") + dir := t.TempDir() + initPassingHouse(t, dir) + out := filepath.Join(dir, "review-pack") + if err := release.Prepare(release.Options{ + RepoRoot: dir, PackIDs: []string{"house-policy"}, OutDir: out, AllowFailingGates: true, + }); err != nil { + t.Fatal(err) + } + htmlDoc, err := os.ReadFile(filepath.Join(out, "buyer-onepager.html")) + if err != nil { + t.Fatal(err) + } + s := string(htmlDoc) + if !strings.Contains(s, "
          result_digest
          ") { + t.Fatal("one-pager must emit result_digest from payload") + } + if !strings.Contains(s, "UNSIGNED") { + t.Fatal("emitting digests must not drop UNSIGNED trust rendering") + } + // Share → review stays consistent and unsigned (no crypto upgrade from digests). + rep, err := review.Run(review.Options{BundleRoot: out, Writer: ioDiscard{}}) + if err != nil { + t.Fatal(err) + } + if review.HasContradictions(rep) { + t.Fatalf("fresh prepare-release pack must not contradict: %+v", rep.Findings) + } + matched := false + for _, f := range rep.Findings { + if f.ID == "digest:result-digest-match" && f.State == review.StateConfirmed { + matched = true + } + } + if !matched { + t.Fatalf("expected result_digest match finding, got %+v", rep.Findings) + } +} + +func TestPrepareRewritesOnePagerWhenDigestsMissing(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "1704067200") + dir := t.TempDir() + initPassingHouse(t, dir) + out := filepath.Join(dir, "review-pack") + opts := release.Options{ + RepoRoot: dir, PackIDs: []string{"house-policy"}, OutDir: out, AllowFailingGates: true, + } + if err := release.Prepare(opts); err != nil { + t.Fatal(err) + } + // Simulate pre-digest one-pager: strip digests and stamp gate-only fingerprint + // (empty ResultDigest/SBOMDigest/VEXDigest) so writeOnePagerIfChanged would skip + // without the digest seed fix. + prev, err := os.ReadFile(filepath.Join(out, "buyer-onepager.html")) + if err != nil { + t.Fatal(err) + } + stripped := string(prev) + for _, key := range []string{"result_digest", "sbom_digest", "vex_digest"} { + // Remove
          key
          lines (and bind variants). + for { + dt := "
          " + key + i := strings.Index(stripped, dt) + if i < 0 { + break + } + j := strings.Index(stripped[i:], "") + if j < 0 { + break + } + stripped = stripped[:i] + stripped[i+j+len(""):] + } + } + gateOnly := templates.OnePagerFingerprint(templates.OnePagerDTO{ + Score: 100, Passed: true, PackID: "house-policy", + AttestLine: "UNSIGNED — not cryptographically verified", UnsignedLoud: true, + }) + const marker = ""); j >= 0 { + stripped = stripped[:i] + marker + gateOnly + " -->" + rest[j+len(" -->"):] + } + } + if strings.Contains(stripped, "
          result_digest
          ") { + t.Fatal("setup: digests should be stripped before rewrite test") + } + if err := os.WriteFile(filepath.Join(out, "buyer-onepager.html"), []byte(stripped), 0o644); err != nil { + t.Fatal(err) + } + + if err := release.Prepare(opts); err != nil { + t.Fatal(err) + } + rewritten, err := os.ReadFile(filepath.Join(out, "buyer-onepager.html")) + if err != nil { + t.Fatal(err) + } + s := string(rewritten) + if !strings.Contains(s, "
          result_digest
          ") { + t.Fatal("Prepare must rewrite one-pager to include result_digest") + } + if _, err := os.Stat(filepath.Join(out, "04-sbom.cdx.json")); err == nil { + if !strings.Contains(s, "
          sbom_digest
          ") { + t.Fatal("Prepare must emit sbom_digest when SBOM file exists") + } + } + if _, err := os.Stat(filepath.Join(out, "05-vex-draft.json")); err == nil { + if !strings.Contains(s, "
          vex_digest
          ") { + t.Fatal("Prepare must emit vex_digest when VEX file exists") + } + } + fp1 := extractFP(s) + if fp1 == "" || fp1 == gateOnly { + t.Fatalf("rewritten fp must include digests (got %q, gate-only %q)", fp1, gateOnly) + } + + // Second identical Prepare → fingerprint match → no rewrite (content stable aside from Generated). + before := s + if err := release.Prepare(opts); err != nil { + t.Fatal(err) + } + again, err := os.ReadFile(filepath.Join(out, "buyer-onepager.html")) + if err != nil { + t.Fatal(err) + } + if extractFP(string(again)) != fp1 { + t.Fatal("second Prepare must keep fingerprint (unchanged)") + } + // Marker-stable skip: body may only differ on Generated timestamp line. + if stripGenerated(before) != stripGenerated(string(again)) { + t.Fatal("second Prepare must not rewrite one-pager body beyond Generated line") + } + + bind, _ := attest.LatestBind(dir) + payload, ok := release.LoadCachedGatePayload(dir) + if !ok { + t.Fatal("expected gate cache after Prepare") + } + passed := len(payload.Failures) == 0 + sig, detail := release.ShareStaleReport(dir, bind, payload.ReadinessScore, passed) + if sig != "share_current" { + t.Fatalf("ShareStale with digests: want share_current, got %s (%s)", sig, detail) + } +} + +func extractFP(htmlDoc string) string { + const marker = ""); j >= 0 { + return rest[:j] + } + } + return "" +} + +func stripGenerated(htmlDoc string) string { + var b strings.Builder + for _, line := range strings.Split(htmlDoc, "\n") { + if strings.Contains(line, "Generated ") { + continue + } + b.WriteString(line) + b.WriteByte('\n') + } + return b.String() +} + +type ioDiscard struct{} + +func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } + func initPassingHouse(t *testing.T, dir string) { t.Helper() write := func(rel, body string) { diff --git a/internal/release/templates/onepager.go b/internal/release/templates/onepager.go index 88fbade..bd6bbf2 100644 --- a/internal/release/templates/onepager.go +++ b/internal/release/templates/onepager.go @@ -14,23 +14,28 @@ const onePagerCoverMax = 12 // OnePagerDTO is the stable input for buyer one-pager HTML generation. type OnePagerDTO struct { - RepoName string - Score int - Passed bool - PackID string - PackLabels string // plain-words pack names for the cover; not in fingerprint - Timestamp string - Failures []OnePagerFailure - CoverRows []OnePagerCoverRow // path + human question; not in fingerprint - Bind attest.BindInfo - AttestLine string - AttestClass string - UnsignedLoud bool - AssuranceClass string + RepoName string + Score int + Passed bool + PackID string + PackLabels string // plain-words pack names for the cover; not in fingerprint + Timestamp string + Failures []OnePagerFailure + CoverRows []OnePagerCoverRow // path + human question; not in fingerprint + Bind attest.BindInfo + AttestLine string + AttestClass string + UnsignedLoud bool + AssuranceClass string MechanicalSummary string // e.g. "5 of 7 gates mechanically evidenced" - ProvenanceHTML string - SourcesHTML string - FooterPrefix string + ProvenanceHTML string + SourcesHTML string + FooterPrefix string + // Provenance digests (hex); empty when absent. Digests flip the fingerprint so + // prepare rewrites when digests appear, but never upgrade UNSIGNED trust class. + ResultDigest string + SBOMDigest string + VEXDigest string } // OnePagerFailure is one gate row for the one-pager table. @@ -60,6 +65,7 @@ func OnePagerFingerprint(d OnePagerDTO) string { for _, f := range d.Failures { fmt.Fprintf(&fpSeed, "|%s:%s", f.GateID, f.Severity) } + fmt.Fprintf(&fpSeed, "|%s|%s|%s", d.ResultDigest, d.SBOMDigest, d.VEXDigest) sum := sha256.Sum256([]byte(fpSeed.String())) return fmt.Sprintf("%x", sum[:16]) } diff --git a/internal/release/templates/onepager_test.go b/internal/release/templates/onepager_test.go index 9313148..fe919eb 100644 --- a/internal/release/templates/onepager_test.go +++ b/internal/release/templates/onepager_test.go @@ -24,6 +24,11 @@ func TestOnePagerFingerprintStable(t *testing.T) { if templates.OnePagerFingerprint(dto2) == a { t.Fatal("score change must change fingerprint") } + dto3 := dto + dto3.ResultDigest = "abcdef0123456789" + if templates.OnePagerFingerprint(dto3) == a { + t.Fatal("digest change must change fingerprint") + } } func TestOnePagerFingerprintIgnoresCoverAndReviewedBy(t *testing.T) { diff --git a/internal/review/references.go b/internal/review/references.go index 6237fe9..9d57fc4 100644 --- a/internal/review/references.go +++ b/internal/review/references.go @@ -7,37 +7,79 @@ import ( "strings" ) +// RefKind is the reference classifier outcome (ClassifierVersion / refclass:1). +type RefKind string + +const ( + RefClaim RefKind = "claim" + RefURL RefKind = "url" + RefPath RefKind = "path" + RefDrop RefKind = "drop" +) + +// ClassifyReference encodes the reference definition: +// +// claim — HOUSE|CRA|MEDTECH-… +// url — https://… +// path — contains / or known extension or exact SECURITY.md / README.md +// drop — everything else (markup, booleans, JSON keys, versions, truncated hashes) +func ClassifyReference(token string) RefKind { + s := strings.TrimSpace(token) + if s == "" { + return RefDrop + } + if strings.HasPrefix(s, "https://") { + return RefURL + } + if reClaimID.MatchString(s) && reClaimID.FindString(s) == s { + return RefClaim + } + if looksLikeRepoPath(s) { + return RefPath + } + return RefDrop +} + // resolveBundleAnchor lifts the in-repository path resolver idea from // research/ground into the offline review path: a path is confirmed only when // it exists inside the received bundle. Repo-only anchors stay unconfirmed // (no network, no supplier tree). // -// Four Phase 1b resolvers in the review path: -// 1. in-bundle anchor (this function) -// 2. pack citation / claim id (HOUSE-|CRA-|MEDTECH-) — see checkReferences -// 3. manifest coordinate (SBOM/VEX digests) — see checkDigests -// 4. external link via research.ValidateSourceURL — recorded, never fetched, -// never elevated to confirmed -func resolveBundleAnchor(bundleRoot, cand string, bundleFiles map[string]struct{}) (State, string) { +// Identity rule: finding identity is the cleaned relative path as cited. +// Basename fallback affects resolution only — never identity — so docs/x.md +// and x.md remain two keys even if both resolve to the same file. +func resolveBundleAnchor(bundleRoot, cand string, bundleFiles map[string]struct{}) (State, string, Cause) { cand = filepath.ToSlash(strings.TrimSpace(cand)) if cand == "" { - return StateUnconfirmed, "empty path" + return StateUnconfirmed, "empty path", CauseExtractor } if _, ok := bundleFiles[cand]; ok { - return StateConfirmed, "in-bundle path: " + cand + return StateConfirmed, "in-bundle path: " + cand, "" } base := filepath.Base(cand) if _, ok := bundleFiles[base]; ok { - return StateConfirmed, "in-bundle basename: " + cand + " → " + base + // Resolution hit via basename — identity remains cand. + return StateConfirmed, "in-bundle basename: " + cand + " → " + base, "" } - abs := filepath.Join(bundleRoot, filepath.FromSlash(cand)) - if st, err := os.Stat(abs); err == nil && !st.IsDir() { - return StateConfirmed, "in-bundle relative path: " + cand + abs, err := jailJoin(bundleRoot, cand) + if err == nil { + if st, err := os.Lstat(abs); err == nil && st.Mode()&os.ModeSymlink == 0 && !st.IsDir() { + return StateConfirmed, "in-bundle relative path: " + cand, "" + } } if looksLikeRepoPath(cand) { - return StateUnconfirmed, "repo-shaped path not in bundle: " + cand + return StateUnconfirmed, "repo-shaped path not in bundle: " + cand, CauseGenuine + } + return StateUnconfirmed, "unresolved path: " + cand, CauseExtractor +} + +func looksLikeRepoPath(s string) bool { + if strings.Contains(s, "/") || strings.HasSuffix(s, ".md") || strings.HasSuffix(s, ".json") || + strings.HasSuffix(s, ".yml") || strings.HasSuffix(s, ".yaml") || + strings.HasSuffix(s, ".go") || strings.HasSuffix(s, ".txt") { + return true } - return StateUnconfirmed, "unresolved path: " + cand + return strings.EqualFold(s, "SECURITY.md") || strings.EqualFold(s, "README.md") } func referenceKindDetail(kind, detail string) string { diff --git a/internal/review/review.go b/internal/review/review.go index be47c13..90ba0f8 100644 --- a/internal/review/review.go +++ b/internal/review/review.go @@ -1,6 +1,10 @@ // Package review triages a received curbpack-native review-pack offline. // It reports on the document (structure, digest self-consistency, reference // resolvability) — never a product verdict or conformity assessment. +// +// A reference is a claim the triage surfaces make about the system under review: +// a pack claim id, a path-shaped artifact pointer, or an external URL. +// Markup, booleans, JSON keys, versions, and truncated hashes are not references. package review import ( @@ -15,11 +19,19 @@ import ( "sort" "strings" + "github.com/afelin/curbpack/internal/exportx" "github.com/afelin/curbpack/internal/ir" + "github.com/afelin/curbpack/internal/pathjail" "github.com/afelin/curbpack/internal/research" ) -const schemaVersion = "curbpack-review-report:1" +const ( + schemaVersion = "curbpack-review-report:2" + ClassifierVersion = "refclass:1" + + maxFileBytes = 8 << 20 // 8 MiB per file + maxTotalBytes = 64 << 20 // 64 MiB total across reads +) // State is one finding outcome. Never conflate these three. type State string @@ -30,30 +42,51 @@ const ( StateContradicted State = "contradicted" ) +// Cause explains unconfirmed or contradicted findings (schema v2). +type Cause string + +const ( + CauseProducer Cause = "producer" + CauseExtractor Cause = "extractor" + CauseGenuine Cause = "genuine" + CauseExternal Cause = "external" + CauseSelfDisagree Cause = "self_disagree" +) + // Finding is one triage row about the received document. type Finding struct { ID string `json:"id"` Category string `json:"category"` // structure | digest | reference State State `json:"state"` + Cause Cause `json:"cause,omitempty"` Detail string `json:"detail"` } // Report is the offline review result (document triage only). type Report struct { - Schema string `json:"schema"` - BundleRoot string `json:"bundle_root"` - Findings []Finding `json:"findings"` - ConfirmedCount int `json:"confirmed_count"` - UnconfirmedCount int `json:"unconfirmed_count"` - ContradictedCount int `json:"contradicted_count"` - Disclaimer string `json:"disclaimer"` + Schema string `json:"schema"` + ClassifierVersion string `json:"classifier_version"` + BundleRoot string `json:"bundle_root"` + Findings []Finding `json:"findings"` + ConfirmedCount int `json:"confirmed_count"` + UnconfirmedCount int `json:"unconfirmed_count"` + ContradictedCount int `json:"contradicted_count"` + UnconfirmedProducer int `json:"unconfirmed_producer"` + UnconfirmedExtractor int `json:"unconfirmed_extractor"` + UnconfirmedGenuine int `json:"unconfirmed_genuine"` + UnconfirmedExternal int `json:"unconfirmed_external"` + ContradictedSelfDisagree int `json:"contradicted_self_disagree"` + DroppedCount int `json:"dropped_count"` + Dropped []string `json:"dropped,omitempty"` + Disclaimer string `json:"disclaimer"` } // Options for Run. type Options struct { BundleRoot string - Writer io.Writer // triage markdown; default stdout + Writer io.Writer // triage markdown or JSON; default stdout JSONOut bool + Full bool // full dump + dropped list; default is terse } var ( @@ -72,9 +105,23 @@ var requiredFiles = []string{ "buyer-onepager.html", } -var optionalDigestFiles = map[string]string{ - "04-sbom.cdx.json": "sbom_digest", - "05-vex-draft.json": "vex_digest", +var optionalDigestFiles = []struct { + File string + Key string +}{ + {"04-sbom.cdx.json", "sbom_digest"}, + {"05-vex-draft.json", "vex_digest"}, +} + +var optionalStructureFiles = []string{ + "04-sbom.cdx.json", "04-sbom-summary.json", "05-vex-draft.json", + "06-gate-failures.sarif", "07-watchlist-sbom-join.json", + "context-pack.json", "buyer-questions.md", +} + +var triageSurfaces = []string{ + "02-action-report.md", "03-executive-summary.md", + "buyer-questions.md", "buyer-onepager.html", } // Run triages a received review-pack directory. Does not call git or network. @@ -83,57 +130,65 @@ func Run(opts Options) (Report, error) { if root == "" || root == "." { return Report{}, fmt.Errorf("review requires a path to a received review-pack directory") } - st, err := os.Stat(root) + st, err := os.Lstat(root) if err != nil { return Report{}, fmt.Errorf("review pack path: %w", err) } + if st.Mode()&os.ModeSymlink != 0 { + return Report{}, fmt.Errorf("review pack path refuses symlink: %s", root) + } if !st.IsDir() { return Report{}, fmt.Errorf("review pack path must be a directory (got file %s)", root) } rep := Report{ - Schema: schemaVersion, - BundleRoot: root, - Disclaimer: "Document triage only — not a product verdict, not conformity assessment, not CE / notified-body approval.", + Schema: schemaVersion, + ClassifierVersion: ClassifierVersion, + BundleRoot: filepath.Base(root), // basename only — airlock refuses absolute homes + Disclaimer: "Document triage only — not a product verdict, not conformity assessment, not CE / notified-body approval.", } - checkStructure(&rep, root) - payload, payloadOK := loadPayload(&rep, root) - prov := extractProvenance(root) - checkDigests(&rep, root, payload, payloadOK, prov) - checkReferences(&rep, root) - - for _, f := range rep.Findings { - switch f.State { - case StateConfirmed: - rep.ConfirmedCount++ - case StateUnconfirmed: - rep.UnconfirmedCount++ - case StateContradicted: - rep.ContradictedCount++ - } + tallyRoot := root // absolute path for IO only + budget := &readBudget{remaining: maxTotalBytes} + checkStructure(&rep, tallyRoot, budget) + payload, payloadOK := loadPayload(&rep, tallyRoot, budget) + prov := extractProvenance(tallyRoot, budget) + checkDigests(&rep, tallyRoot, payload, payloadOK, prov, budget) + checkReferences(&rep, tallyRoot, budget) + + if redactReportAirlock(&rep) { + add(&rep, Finding{ + ID: "structure:airlock-redacted", Category: "structure", + State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "Bundle triage echoed home-path or PEM-shaped material; redacted before emit", + }) } - sort.SliceStable(rep.Findings, func(i, j int) bool { - if rep.Findings[i].Category != rep.Findings[j].Category { - return rep.Findings[i].Category < rep.Findings[j].Category - } - return rep.Findings[i].ID < rep.Findings[j].ID - }) + + tally(&rep) + sortFindings(rep.Findings) w := opts.Writer if w == nil { w = os.Stdout } + + var out []byte if opts.JSONOut { - enc := json.NewEncoder(w) + var buf strings.Builder + enc := json.NewEncoder(&buf) enc.SetIndent("", " ") if err := enc.Encode(rep); err != nil { return rep, err } + out = []byte(buf.String()) } else { - if _, err := io.WriteString(w, TriageMarkdown(rep)); err != nil { - return rep, err - } + out = []byte(TriageMarkdown(rep, opts.Full)) + } + if err := exportx.PacketLooksAirlocked(out); err != nil { + return rep, fmt.Errorf("review output failed airlock: %w", err) + } + if _, err := w.Write(out); err != nil { + return rep, err } return rep, nil } @@ -143,68 +198,143 @@ func HasContradictions(rep Report) bool { return rep.ContradictedCount > 0 } -func checkStructure(rep *Report, root string) { +func tally(rep *Report) { + for _, f := range rep.Findings { + switch f.State { + case StateConfirmed: + rep.ConfirmedCount++ + case StateUnconfirmed: + rep.UnconfirmedCount++ + switch f.Cause { + case CauseProducer: + rep.UnconfirmedProducer++ + case CauseExtractor: + rep.UnconfirmedExtractor++ + case CauseGenuine: + rep.UnconfirmedGenuine++ + case CauseExternal: + rep.UnconfirmedExternal++ + } + case StateContradicted: + rep.ContradictedCount++ + if f.Cause == CauseSelfDisagree { + rep.ContradictedSelfDisagree++ + } + } + } + rep.DroppedCount = len(rep.Dropped) + sort.Strings(rep.Dropped) +} + +func sortFindings(fs []Finding) { + stateOrd := map[State]int{ + StateContradicted: 0, + StateUnconfirmed: 1, + StateConfirmed: 2, + } + sort.SliceStable(fs, func(i, j int) bool { + si, sj := stateOrd[fs[i].State], stateOrd[fs[j].State] + if si != sj { + return si < sj + } + if fs[i].Category != fs[j].Category { + return fs[i].Category < fs[j].Category + } + return fs[i].ID < fs[j].ID + }) +} + +func checkStructure(rep *Report, root string, budget *readBudget) { for _, name := range requiredFiles { - rel := name - path := filepath.Join(root, name) - if fileNonEmpty(path) { + path, err := jailJoin(root, name) + if err != nil { add(rep, Finding{ - ID: "structure:" + rel, Category: "structure", State: StateConfirmed, - Detail: "Required layer present: " + rel, + ID: "structure:" + name, Category: "structure", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "Required layer path refused: " + name, }) - } else if _, err := os.Stat(path); err == nil { + continue + } + st, err := os.Lstat(path) + switch { + case err != nil: add(rep, Finding{ - ID: "structure:" + rel, Category: "structure", State: StateContradicted, - Detail: "Required layer empty: " + rel, + ID: "structure:" + name, Category: "structure", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "Required layer missing: " + name, }) - } else { + case st.Mode()&os.ModeSymlink != 0: + add(rep, Finding{ + ID: "structure:" + name, Category: "structure", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "Required layer is a symlink (refused): " + name, + }) + case st.Size() == 0: + add(rep, Finding{ + ID: "structure:" + name, Category: "structure", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "Required layer empty: " + name, + }) + default: add(rep, Finding{ - ID: "structure:" + rel, Category: "structure", State: StateContradicted, - Detail: "Required layer missing: " + rel, + ID: "structure:" + name, Category: "structure", State: StateConfirmed, + Detail: "Required layer present: " + name, }) } } - // Optional layers — presence is confirmed; absence is unconfirmed (not contradicted). - for _, name := range []string{"04-sbom.cdx.json", "04-sbom-summary.json", "05-vex-draft.json", "06-gate-failures.sarif", "07-watchlist-sbom-join.json", "context-pack.json", "buyer-questions.md"} { - path := filepath.Join(root, name) - if fileNonEmpty(path) { + for _, name := range optionalStructureFiles { + path, err := jailJoin(root, name) + if err != nil { + continue + } + st, err := os.Lstat(path) + if err == nil && st.Mode()&os.ModeSymlink == 0 && st.Size() > 0 { add(rep, Finding{ ID: "structure:" + name, Category: "structure", State: StateConfirmed, Detail: "Optional layer present: " + name, }) } else { add(rep, Finding{ - ID: "structure:" + name, Category: "structure", State: StateUnconfirmed, + ID: "structure:" + name, Category: "structure", State: StateUnconfirmed, Cause: CauseProducer, Detail: "Optional layer absent: " + name, }) } } + _ = budget } -func loadPayload(rep *Report, root string) (ir.GateFailurePayload, bool) { - path := filepath.Join(root, "01-gate-failures.json") - data, err := os.ReadFile(path) +func loadPayload(rep *Report, root string, budget *readBudget) (ir.GateFailurePayload, bool) { + data, truncated, err := readCapped(root, "01-gate-failures.json", budget) if err != nil { return ir.GateFailurePayload{}, false } + if truncated { + add(rep, Finding{ + ID: "digest:gate-json-size", Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "01-gate-failures.json exceeded size cap (truncated — not silently accepted)", + }) + return ir.GateFailurePayload{}, false + } var p ir.GateFailurePayload if err := json.Unmarshal(data, &p); err != nil { add(rep, Finding{ - ID: "digest:gate-json-parse", Category: "digest", State: StateContradicted, - Detail: "01-gate-failures.json is not valid GateFailurePayload JSON: " + err.Error(), + ID: "digest:gate-json-parse", Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "01-gate-failures.json is not valid GateFailurePayload JSON: " + fence(err.Error()), }) return ir.GateFailurePayload{}, false } add(rep, Finding{ ID: "digest:gate-json-parse", Category: "digest", State: StateConfirmed, - Detail: fmt.Sprintf("01-gate-failures.json parses (pack_id=%s score=%d failures=%d)", strings.TrimSpace(p.PackID), p.ReadinessScore, len(p.Failures)), + Detail: fmt.Sprintf("01-gate-failures.json parses (pack_id=%s score=%d failures=%d)", + fence(strings.TrimSpace(p.PackID)), p.ReadinessScore, len(p.Failures)), }) return p, true } -func checkDigests(rep *Report, root string, payload ir.GateFailurePayload, payloadOK bool, prov map[string]string) { - htmlPath := filepath.Join(root, "buyer-onepager.html") - htmlDoc, _ := os.ReadFile(htmlPath) +func checkDigests(rep *Report, root string, payload ir.GateFailurePayload, payloadOK bool, prov map[string]string, budget *readBudget) { + htmlDoc, truncated, err := readCapped(root, "buyer-onepager.html", budget) + if err == nil && truncated { + add(rep, Finding{ + ID: "digest:onepager-size", Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "buyer-onepager.html exceeded size cap (truncated — not silently accepted)", + }) + } fp := "" if m := reOnePagerFP.FindSubmatch(htmlDoc); m != nil { fp = string(m[1]) @@ -212,9 +342,9 @@ func checkDigests(rep *Report, root string, payload ir.GateFailurePayload, paylo ID: "digest:onepager-fp-marker", Category: "digest", State: StateConfirmed, Detail: "buyer-onepager.html carries curbpack-onepager-fp marker " + fp[:min(12, len(fp))], }) - } else if len(htmlDoc) > 0 { + } else if len(htmlDoc) > 0 && err == nil && !truncated { add(rep, Finding{ - ID: "digest:onepager-fp-marker", Category: "digest", State: StateContradicted, + ID: "digest:onepager-fp-marker", Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, Detail: "buyer-onepager.html missing curbpack-onepager-fp marker", }) } @@ -225,49 +355,78 @@ func checkDigests(rep *Report, root string, payload ir.GateFailurePayload, paylo ID: "digest:result-digest", Category: "digest", State: StateConfirmed, Detail: "Recomputed result_digest from 01-gate-failures.json: " + got[:min(16, len(got))], }) - if claimed, ok := prov["result_digest"]; ok && claimed != "" { - if digestPrefixMatch(got, claimed) { - add(rep, Finding{ - ID: "digest:result-digest-match", Category: "digest", State: StateConfirmed, - Detail: "Provenance result_digest agrees with recomputed digest", - }) - } else { - add(rep, Finding{ - ID: "digest:result-digest-match", Category: "digest", State: StateContradicted, - Detail: fmt.Sprintf("Provenance result_digest %q diverges from recomputed %s…", claimed, got[:min(12, len(got))]), - }) - } - } else { + claimed := strings.TrimSpace(prov["result_digest"]) + bindClaim := strings.TrimSpace(prov["result_digest_bind"]) + switch { + case claimed != "" && digestPrefixMatch(got, claimed): + add(rep, Finding{ + ID: "digest:result-digest-match", Category: "digest", State: StateConfirmed, + Detail: "Provenance result_digest agrees with recomputed digest", + }) + case claimed != "": + add(rep, Finding{ + ID: "digest:result-digest-match", Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: fmt.Sprintf("Provenance result_digest %q diverges from recomputed %s…", fence(claimed), got[:min(12, len(got))]), + }) + default: add(rep, Finding{ - ID: "digest:result-digest-match", Category: "digest", State: StateUnconfirmed, + ID: "digest:result-digest-match", Category: "digest", State: StateUnconfirmed, Cause: CauseProducer, Detail: "No result_digest in one-pager provenance — recomputed digest recorded only", }) } + if bindClaim != "" && !digestPrefixMatch(got, bindClaim) { + add(rep, Finding{ + ID: "digest:result-digest-bind", Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: fmt.Sprintf("Bind result_digest_bind %q disagrees with recomputed %s…", fence(bindClaim), got[:min(12, len(got))]), + }) + } } - for file, key := range optionalDigestFiles { - path := filepath.Join(root, file) - if !fileNonEmpty(path) { + for _, pair := range optionalDigestFiles { + path, jerr := jailJoin(root, pair.File) + if jerr != nil { + continue + } + st, err := os.Lstat(path) + if err != nil || st.Mode()&os.ModeSymlink != 0 || st.Size() == 0 { + continue + } + data, truncated, rerr := readCapped(root, pair.File, budget) + if rerr != nil { continue } - got := fileSHA256(path) - claimed, ok := prov[key] - if !ok || strings.TrimSpace(claimed) == "" { + if truncated { add(rep, Finding{ - ID: "digest:" + key, Category: "digest", State: StateUnconfirmed, - Detail: fmt.Sprintf("%s present but no %s in one-pager provenance", file, key), + ID: "digest:" + pair.Key, Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: pair.File + " exceeded size cap (truncated — not silently accepted)", }) continue } - if digestPrefixMatch(got, claimed) { + sum := sha256.Sum256(data) + got := fmt.Sprintf("%x", sum) + claimed := strings.TrimSpace(prov[pair.Key]) + bindClaim := strings.TrimSpace(prov[pair.Key+"_bind"]) + switch { + case claimed == "": add(rep, Finding{ - ID: "digest:" + key, Category: "digest", State: StateConfirmed, - Detail: fmt.Sprintf("%s matches provenance %s (prefix)", file, key), + ID: "digest:" + pair.Key, Category: "digest", State: StateUnconfirmed, Cause: CauseProducer, + Detail: fmt.Sprintf("%s present but no %s in one-pager provenance", pair.File, pair.Key), }) - } else { + case digestPrefixMatch(got, claimed): + add(rep, Finding{ + ID: "digest:" + pair.Key, Category: "digest", State: StateConfirmed, + Detail: fmt.Sprintf("%s matches provenance %s (prefix)", pair.File, pair.Key), + }) + default: + add(rep, Finding{ + ID: "digest:" + pair.Key, Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: fmt.Sprintf("%s sha256 diverges from provenance %s=%q", pair.File, pair.Key, fence(claimed)), + }) + } + if bindClaim != "" && !digestPrefixMatch(got, bindClaim) { add(rep, Finding{ - ID: "digest:" + key, Category: "digest", State: StateContradicted, - Detail: fmt.Sprintf("%s sha256 diverges from provenance %s=%q", file, key, claimed), + ID: "digest:" + pair.Key + "-bind", Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: fmt.Sprintf("Bind %s_bind %q disagrees with file hash", pair.Key, fence(bindClaim)), }) } } @@ -280,107 +439,128 @@ func checkDigests(rep *Report, root string, payload ir.GateFailurePayload, paylo }) } else { add(rep, Finding{ - ID: "digest:pack-id-agree", Category: "digest", State: StateContradicted, - Detail: fmt.Sprintf("pack_id mismatch: json=%q onepager=%q", payload.PackID, packClaim), + ID: "digest:pack-id-agree", Category: "digest", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: fmt.Sprintf("pack_id mismatch: json=%q onepager=%q", fence(payload.PackID), fence(packClaim)), }) } } } -func checkReferences(rep *Report, root string) { - bundleFiles := map[string]struct{}{} - _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return nil - } - rel, err := filepath.Rel(root, path) - if err != nil { - return nil - } - bundleFiles[filepath.ToSlash(rel)] = struct{}{} - bundleFiles[filepath.Base(path)] = struct{}{} - return nil - }) - - texts := map[string][]byte{} - for _, name := range []string{"02-action-report.md", "03-executive-summary.md", "buyer-questions.md", "context-pack.md", "buyer-onepager.html"} { - if data, err := os.ReadFile(filepath.Join(root, name)); err == nil { - texts[name] = data - } +func checkReferences(rep *Report, root string, budget *readBudget) { + bundleFiles, walkFindings := walkBundleIndex(root) + for _, f := range walkFindings { + add(rep, f) } - seenURL := map[string]struct{}{} - seenPath := map[string]struct{}{} - seenClaim := map[string]struct{}{} + seen := map[string]struct{}{} // one finding per identity key + var dropped []string - for src, data := range texts { + for _, name := range triageSurfaces { + data, truncated, err := readCapped(root, name, budget) + if err != nil { + continue + } + if truncated { + add(rep, Finding{ + ID: "reference:size:" + name, Category: "reference", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: name + " exceeded size cap while extracting references", + }) + continue + } text := string(data) + for _, u := range reHTTPS.FindAllString(text, -1) { u = strings.TrimRight(u, ".,);\"'/") u = strings.TrimSuffix(u, " 200 { - continue - } - if strings.HasPrefix(cand, "http") { + + for _, m := range reClaimID.FindAllString(text, -1) { + if ClassifyReference(m) != RefClaim { + dropped = appendUnique(dropped, m) continue } - if _, ok := seenPath[cand]; ok { + key := "reference:claim:" + m + if _, ok := seen[key]; ok { continue } - seenPath[cand] = struct{}{} - st, detail := resolveBundleAnchor(root, cand, bundleFiles) + seen[key] = struct{}{} add(rep, Finding{ - ID: "reference:path:" + shortID(cand), Category: "reference", State: st, - Detail: referenceKindDetail("in-bundle-or-repo", detail+" (from "+src+")"), + ID: key, Category: "reference", State: StateConfirmed, + Detail: fmt.Sprintf("Pack claim id present in document: %s", m), }) } - for _, m := range reClaimID.FindAllString(text, -1) { - if _, ok := seenClaim[m]; ok { + + for _, m := range reBacktick.FindAllStringSubmatch(text, -1) { + cand := filepath.ToSlash(strings.TrimSpace(m[1])) + if cand == "" || strings.Contains(cand, " ") || len(cand) > 200 { + dropped = appendUnique(dropped, cand) continue } - seenClaim[m] = struct{}{} - // Claim ids in the pack are catalog citations — present in document = confirmed as cited; - // we do not treat them as product proof. - add(rep, Finding{ - ID: "reference:claim:" + m, Category: "reference", State: StateConfirmed, - Detail: fmt.Sprintf("Pack claim id present in document: %s", m), - }) + kind := ClassifyReference(cand) + switch kind { + case RefClaim: + // Claims never enter the path resolver (identity = claim id already handled). + continue + case RefURL: + continue + case RefDrop: + dropped = appendUnique(dropped, cand) + continue + case RefPath: + // identity = cleaned relative path as cited (basename is resolution-only) + identity := filepath.ToSlash(filepath.Clean(cand)) + if identity == "." { + dropped = appendUnique(dropped, cand) + continue + } + key := "reference:path:" + identity + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + st, detail, cause := resolveBundleAnchor(root, identity, bundleFiles) + add(rep, Finding{ + ID: key, Category: "reference", State: st, Cause: cause, + Detail: referenceKindDetail("in-bundle-or-repo", detail+" (from "+name+")"), + }) + } } } - if len(seenURL) == 0 && len(seenPath) == 0 && len(seenClaim) == 0 { + rep.Dropped = dropped + if len(seen) == 0 { add(rep, Finding{ - ID: "reference:none", Category: "reference", State: StateUnconfirmed, + ID: "reference:none", Category: "reference", State: StateUnconfirmed, Cause: CauseExtractor, Detail: "No path/claim/URL references extracted from triage surfaces", }) } } -func extractProvenance(root string) map[string]string { +func extractProvenance(root string, budget *readBudget) map[string]string { out := map[string]string{} - data, err := os.ReadFile(filepath.Join(root, "buyer-onepager.html")) - if err != nil { + data, truncated, err := readCapped(root, "buyer-onepager.html", budget) + if err != nil || truncated { return out } - // Unescape HTML entities in dd text for comparison. for _, m := range reProvDD.FindAllStringSubmatch(string(data), -1) { k := strings.TrimSpace(html.UnescapeString(m[1])) v := strings.TrimSpace(html.UnescapeString(m[2])) @@ -388,9 +568,7 @@ func extractProvenance(root string) map[string]string { continue } out[k] = v - // Also map digest keys lowercase. - lk := strings.ToLower(k) - out[lk] = v + out[strings.ToLower(k)] = v } return out } @@ -403,7 +581,6 @@ func digestPrefixMatch(full, claimed string) bool { if claimed == "" || full == "" { return false } - // Display truncates to 12 + ellipsis. n := len(claimed) if n > len(full) { n = len(full) @@ -414,27 +591,6 @@ func digestPrefixMatch(full, claimed string) bool { return strings.HasPrefix(full, claimed[:n]) } -func fileNonEmpty(path string) bool { - st, err := os.Stat(path) - return err == nil && st.Size() > 0 -} - -func fileSHA256(path string) string { - data, err := os.ReadFile(path) - if err != nil { - return "" - } - sum := sha256.Sum256(data) - return fmt.Sprintf("%x", sum) -} - -func looksLikeRepoPath(s string) bool { - if strings.Contains(s, "/") || strings.HasSuffix(s, ".md") || strings.HasSuffix(s, ".json") || strings.HasSuffix(s, ".yml") || strings.HasSuffix(s, ".yaml") { - return true - } - return strings.EqualFold(s, "SECURITY.md") || strings.EqualFold(s, "README.md") -} - func shortID(s string) string { sum := sha256.Sum256([]byte(s)) return fmt.Sprintf("%x", sum[:6]) @@ -444,6 +600,19 @@ func add(rep *Report, f Finding) { rep.Findings = append(rep.Findings, f) } +func appendUnique(xs []string, s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return xs + } + for _, x := range xs { + if x == s { + return xs + } + } + return append(xs, s) +} + func min(a, b int) int { if a < b { return a @@ -451,16 +620,208 @@ func min(a, b int) int { return b } +func fence(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return s + } + return "" + s + "" +} + +// Airlock placeholders — fixed tokens so PacketLooksAirlocked accepts triage output +// while preserving a contradicted finding that the bundle echoed unsafe material. +const ( + redactedHome = "" + redactedPEM = "" +) + +// Match exportx airlock shapes (homePathRE / pemBlobRE) for redact-then-emit. +var ( + reHomePath = regexp.MustCompile(`(?i)(/Users/[^/\s"'<>]+|/home/[^/\s"'<>]+|/mnt/[a-z]/Users/[^/\s"'<>]+|C:\\Users\\[^\\\s"'<>]+)`) + rePEMBlob = regexp.MustCompile(`-----BEGIN [A-Z0-9 ]+-----[\s\S]{20,}?-----END [A-Z0-9 ]+-----`) +) + +// redactReportAirlock mutates finding details/ids and dropped tokens. Returns true if any redaction occurred. +func redactReportAirlock(rep *Report) bool { + changed := false + for i := range rep.Findings { + if s, ok := redactAirlockString(rep.Findings[i].Detail); ok { + rep.Findings[i].Detail = s + changed = true + } + if s, ok := redactAirlockString(rep.Findings[i].ID); ok { + rep.Findings[i].ID = s + changed = true + } + } + for i := range rep.Dropped { + if s, ok := redactAirlockString(rep.Dropped[i]); ok { + rep.Dropped[i] = s + changed = true + } + } + return changed +} + +func redactAirlockString(s string) (string, bool) { + orig := s + s = rePEMBlob.ReplaceAllString(s, redactedPEM) + if home, err := os.UserHomeDir(); err == nil { + home = strings.TrimSpace(home) + if home != "" && home != "/" && home != `\` { + s = strings.ReplaceAll(s, home, redactedHome) + if slash := filepath.ToSlash(home); slash != home { + s = strings.ReplaceAll(s, slash, redactedHome) + } + } + } + s = reHomePath.ReplaceAllString(s, redactedHome) + return s, s != orig +} + +func jailJoin(root, rel string) (string, error) { + full, _, err := pathjail.Join(root, rel) + return full, err +} + +type readBudget struct { + remaining int64 +} + +func readCapped(root, rel string, budget *readBudget) (data []byte, truncated bool, err error) { + full, _, err := pathjail.Join(root, rel) + if err != nil { + return nil, false, err + } + st, err := os.Lstat(full) + if err != nil { + return nil, false, err + } + if st.Mode()&os.ModeSymlink != 0 { + return nil, false, fmt.Errorf("refusing symlink: %s", rel) + } + if !st.Mode().IsRegular() { + return nil, false, fmt.Errorf("not a regular file: %s", rel) + } + if budget.remaining <= 0 { + return nil, true, nil + } + perFile := int64(maxFileBytes) + if perFile > budget.remaining { + perFile = budget.remaining + } + f, err := os.Open(full) + if err != nil { + return nil, false, err + } + defer f.Close() + limited := io.LimitReader(f, perFile+1) + data, err = io.ReadAll(limited) + if err != nil { + return nil, false, err + } + if int64(len(data)) > perFile { + data = data[:perFile] + budget.remaining = 0 + return data, true, nil + } + budget.remaining -= int64(len(data)) + return data, false, nil +} + +func walkBundleIndex(root string) (map[string]struct{}, []Finding) { + files := map[string]struct{}{} + var findings []Finding + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + // Re-Lstat so we never follow symlinks (Walk may still visit link nodes). + st, lerr := os.Lstat(path) + if lerr != nil { + return nil + } + if st.Mode()&os.ModeSymlink != 0 { + rel, _ := filepath.Rel(root, path) + findings = append(findings, Finding{ + ID: "structure:symlink:" + shortID(filepath.ToSlash(rel)), + Category: "structure", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "Symlink skipped under bundle: " + fence(filepath.ToSlash(rel)), + }) + if st.IsDir() || (info != nil && info.IsDir()) { + return filepath.SkipDir + } + return nil + } + if st.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return nil + } + slash := filepath.ToSlash(rel) + if _, _, jerr := pathjail.Join(root, slash); jerr != nil { + findings = append(findings, Finding{ + ID: "structure:jail:" + shortID(slash), + Category: "structure", State: StateContradicted, Cause: CauseSelfDisagree, + Detail: "Path outside jail skipped: " + fence(slash), + }) + return nil + } + files[slash] = struct{}{} + files[filepath.Base(path)] = struct{}{} + return nil + }) + return files, findings +} + // TriageMarkdown returns a pasteable case-ticket note. -func TriageMarkdown(rep Report) string { +// Terse (default): summary line + top items. Full: complete dump + dropped list. +func TriageMarkdown(rep Report, full bool) string { var b strings.Builder + base := filepath.Base(rep.BundleRoot) b.WriteString("# Curbpack review — triage note\n\n") b.WriteString("> ") b.WriteString(rep.Disclaimer) b.WriteString("\n\n") + + if !full { + fmt.Fprintf(&b, "%s — %d genuine unresolved · %d contradicted", + base, rep.UnconfirmedGenuine, rep.ContradictedCount) + if rep.UnconfirmedExtractor > 0 { + fmt.Fprintf(&b, " · %d extractor", rep.UnconfirmedExtractor) + } + b.WriteByte('\n') + fmt.Fprintf(&b, "Confirmed %d · producer %d · external %d. (--full for all)\n\n", + rep.ConfirmedCount, rep.UnconfirmedProducer, rep.UnconfirmedExternal) + + top := 0 + for _, f := range rep.Findings { + if f.State != StateContradicted && !(f.State == StateUnconfirmed && f.Cause == CauseGenuine) { + continue + } + if top >= 5 { + break + } + fmt.Fprintf(&b, " · **[%s]** `%s` — %s\n", f.State, f.ID, f.Detail) + top++ + } + if top > 0 { + b.WriteByte('\n') + } + b.WriteString("---\n") + b.WriteString("Generated by `curbpack review` (offline). Exit 1 if any finding is contradicted.\n") + return b.String() + } + fmt.Fprintf(&b, "- **Bundle:** `%s`\n", rep.BundleRoot) - fmt.Fprintf(&b, "- **Confirmed:** %d · **Unconfirmed:** %d · **Contradicted:** %d\n\n", - rep.ConfirmedCount, rep.UnconfirmedCount, rep.ContradictedCount) + fmt.Fprintf(&b, "- **Classifier:** `%s`\n", rep.ClassifierVersion) + fmt.Fprintf(&b, "- **Confirmed:** %d · **Unconfirmed:** %d (producer %d · extractor %d · genuine %d · external %d) · **Contradicted:** %d (self_disagree %d)\n", + rep.ConfirmedCount, rep.UnconfirmedCount, + rep.UnconfirmedProducer, rep.UnconfirmedExtractor, rep.UnconfirmedGenuine, rep.UnconfirmedExternal, + rep.ContradictedCount, rep.ContradictedSelfDisagree) + fmt.Fprintf(&b, "- **Dropped tokens:** %d\n\n", rep.DroppedCount) writeSection := func(title string, state State) { var rows []Finding @@ -474,7 +835,11 @@ func TriageMarkdown(rep Report) string { } fmt.Fprintf(&b, "## %s\n\n", title) for _, f := range rows { - fmt.Fprintf(&b, "- **[%s]** `%s` — %s\n", f.State, f.ID, f.Detail) + cause := "" + if f.Cause != "" { + cause = " (" + string(f.Cause) + ")" + } + fmt.Fprintf(&b, "- **[%s]** `%s`%s — %s\n", f.State, f.ID, cause, f.Detail) } b.WriteByte('\n') } @@ -482,7 +847,15 @@ func TriageMarkdown(rep Report) string { writeSection("Unconfirmed", StateUnconfirmed) writeSection("Confirmed", StateConfirmed) + if len(rep.Dropped) > 0 { + b.WriteString("## Dropped (not references)\n\n") + for _, d := range rep.Dropped { + fmt.Fprintf(&b, "- `%s`\n", d) + } + b.WriteByte('\n') + } + b.WriteString("---\n") - b.WriteString("Generated by `curbpack review` (offline). Exit 1 if any finding is contradicted.\n") + b.WriteString("Generated by `curbpack review --full` (offline). Exit 1 if any finding is contradicted.\n") return b.String() } diff --git a/internal/review/review_test.go b/internal/review/review_test.go index 291636c..0463a12 100644 --- a/internal/review/review_test.go +++ b/internal/review/review_test.go @@ -45,20 +45,29 @@ func TestReviewMinimalPack(t *testing.T) { if err != nil { t.Fatal(err) } + if rep.Schema != "curbpack-review-report:2" { + t.Fatalf("schema: %s", rep.Schema) + } + if rep.ClassifierVersion != review.ClassifierVersion { + t.Fatalf("classifier: %s", rep.ClassifierVersion) + } if rep.ConfirmedCount == 0 { t.Fatalf("expected some confirmed findings, got %+v", rep) } if review.HasContradictions(rep) { t.Fatalf("expected no contradictions on minimal consistent pack: %+v", rep.Findings) } + if rep.UnconfirmedGenuine != 1 { + // SECURITY.md cited but not in bundle + t.Fatalf("expected 1 genuine unresolved for SECURITY.md, got %d findings=%+v", rep.UnconfirmedGenuine, rep.Findings) + } md := buf.String() if !strings.Contains(md, "Document triage only") { t.Fatalf("triage missing disclaimer: %s", md) } - if !strings.Contains(md, "Unconfirmed") { - t.Fatalf("expected unconfirmed section for external URL: %s", md) + if !strings.Contains(md, "genuine unresolved") { + t.Fatalf("terse output missing genuine line: %s", md) } - // No product verdict language banned := []string{"CRA-compliant", "CE marking", "notified-body approved", "we are certified"} for _, b := range banned { if strings.Contains(strings.ToLower(md), strings.ToLower(b)) { @@ -67,11 +76,32 @@ func TestReviewMinimalPack(t *testing.T) { } } +func TestReviewDeterminism(t *testing.T) { + dir := writeMinimalConsistent(t) + var a, b bytes.Buffer + ra, err := review.Run(review.Options{BundleRoot: dir, Writer: &a, Full: true}) + if err != nil { + t.Fatal(err) + } + rb, err := review.Run(review.Options{BundleRoot: dir, Writer: &b, Full: true}) + if err != nil { + t.Fatal(err) + } + if a.String() != b.String() { + t.Fatal("markdown not byte-identical across runs") + } + ja, _ := json.Marshal(ra) + jb, _ := json.Marshal(rb) + if !bytes.Equal(ja, jb) { + t.Fatal("JSON report not byte-identical across runs") + } +} + func TestReviewMissingRequiredContradicted(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "01-gate-failures.json"), []byte(`{"schema_version":"1","pack_id":"house-policy","readiness_score":0}`+"\n")) var buf bytes.Buffer - rep, err := review.Run(review.Options{BundleRoot: dir, Writer: &buf}) + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: &buf, Full: true}) if err != nil { t.Fatal(err) } @@ -96,17 +126,16 @@ func TestReviewDigestMismatch(t *testing.T) { } found := false for _, f := range rep.Findings { - if f.ID == "digest:result-digest-match" && f.State == review.StateContradicted { + if f.ID == "digest:result-digest-match" && f.State == review.StateContradicted && f.Cause == review.CauseSelfDisagree { found = true } } if !found { - t.Fatalf("expected result_digest contradiction, findings=%+v", rep.Findings) + t.Fatalf("expected result_digest contradiction with self_disagree, findings=%+v", rep.Findings) } } func TestReviewNoGitRequired(t *testing.T) { - // Bundle outside any git repo — temp dir is enough. dir := t.TempDir() mustWrite(t, filepath.Join(dir, "01-gate-failures.json"), []byte(`{"schema_version":"1","pack_id":"house-policy","readiness_score":100}`+"\n")) mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("x\n")) @@ -123,6 +152,322 @@ func TestReviewNoGitRequired(t *testing.T) { } } +func TestClassifyReferenceGolden(t *testing.T) { + cases := []struct { + in string + want review.RefKind + }{ + {"HOUSE-SECURITY-MD", review.RefClaim}, + {"CRA-VULN-HANDLING", review.RefClaim}, + {"MEDTECH-RISK-1", review.RefClaim}, + {"https://example.com/x", review.RefURL}, + {"docs/foo.md", review.RefPath}, + {"SECURITY.md", review.RefPath}, + {"README.md", review.RefPath}, + {"true", review.RefDrop}, + {"schema_version", review.RefDrop}, + {"v0.5.2", review.RefDrop}, + {"abcdef012345", review.RefDrop}, + {"", review.RefDrop}, + } + for _, tc := range cases { + if got := review.ClassifyReference(tc.in); got != tc.want { + t.Errorf("ClassifyReference(%q)=%q want %q", tc.in, got, tc.want) + } + } +} + +func TestOneStatePerPathIdentity(t *testing.T) { + dir := t.TempDir() + payload := ir.GateFailurePayload{SchemaVersion: "1", PackID: "house-policy", ReadinessScore: 50} + raw, _ := json.MarshalIndent(payload, "", " ") + mustWrite(t, filepath.Join(dir, "01-gate-failures.json"), append(raw, '\n')) + // Cite both docs/x.md and x.md — two identities even if same basename resolution. + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("see `docs/x.md` and `x.md` and `HOUSE-SECURITY-MD`\n")) + mustWrite(t, filepath.Join(dir, "03-executive-summary.md"), []byte("ok\n")) + mustWrite(t, filepath.Join(dir, "x.md"), []byte("content\n")) + digest := ir.ComputeResultDigest(payload) + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(` +
          Rule packs
          house-policy
          +
          result_digest
          `+digest[:12]+`…
          `)) + + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}, Full: true}) + if err != nil { + t.Fatal(err) + } + var pathFindings []review.Finding + for _, f := range rep.Findings { + if strings.HasPrefix(f.ID, "reference:path:") { + pathFindings = append(pathFindings, f) + } + } + if len(pathFindings) != 2 { + t.Fatalf("want 2 path identities, got %d: %+v", len(pathFindings), pathFindings) + } + // Claim must not also appear as a path key. + for _, f := range rep.Findings { + if strings.Contains(f.ID, "reference:path:") && strings.Contains(f.ID, "HOUSE-SECURITY-MD") { + t.Fatalf("claim id must not enter path resolver: %s", f.ID) + } + } +} + +func TestSensitivityMatrix(t *testing.T) { + t.Run("absent_path_genuine", func(t *testing.T) { + dir := writeMinimalConsistent(t) + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("missing `docs/absent.md`\n")) + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}}) + if err != nil { + t.Fatal(err) + } + ok := false + for _, f := range rep.Findings { + if f.ID == "reference:path:docs/absent.md" && f.State == review.StateUnconfirmed && f.Cause == review.CauseGenuine { + ok = true + } + } + if !ok || rep.UnconfirmedGenuine < 1 { + t.Fatalf("want unconfirmed+genuine for absent path, got %+v", rep.Findings) + } + }) + t.Run("altered_result_digest", func(t *testing.T) { + dir := t.TempDir() + payload := ir.GateFailurePayload{SchemaVersion: "1", PackID: "house-policy", ReadinessScore: 10} + raw, _ := json.MarshalIndent(payload, "", " ") + mustWrite(t, filepath.Join(dir, "01-gate-failures.json"), append(raw, '\n')) + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("ok\n")) + mustWrite(t, filepath.Join(dir, "03-executive-summary.md"), []byte("ok\n")) + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(` +
          result_digest
          111111111111…
          `)) + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}}) + if err != nil { + t.Fatal(err) + } + ok := false + for _, f := range rep.Findings { + if f.ID == "digest:result-digest-match" && f.State == review.StateContradicted && f.Cause == review.CauseSelfDisagree { + ok = true + } + } + if !ok { + t.Fatalf("want contradicted+self_disagree for altered digest: %+v", rep.Findings) + } + }) + t.Run("wrong_sbom_digest", func(t *testing.T) { + dir := writeMinimalConsistent(t) + mustWrite(t, filepath.Join(dir, "04-sbom.cdx.json"), []byte(`{"bomFormat":"CycloneDX"}`+"\n")) + // Rewrite onepager with wrong sbom_digest + payload := mustPayload(t, dir) + digest := ir.ComputeResultDigest(payload) + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(` +
          +
          Rule packs
          house-policy
          +
          result_digest
          `+digest[:12]+`…
          +
          sbom_digest
          ffffffffffff…
          +
          `)) + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}}) + if err != nil { + t.Fatal(err) + } + ok := false + for _, f := range rep.Findings { + if f.ID == "digest:sbom_digest" && f.State == review.StateContradicted && f.Cause == review.CauseSelfDisagree { + ok = true + } + } + if !ok { + t.Fatalf("want sbom digest contradiction: %+v", rep.Findings) + } + }) +} + +func TestSpecificityKnownGood(t *testing.T) { + dir := writeMinimalConsistent(t) + // Put SECURITY.md in bundle so the path cite confirms; no external URLs. + mustWrite(t, filepath.Join(dir, "SECURITY.md"), []byte("# security\n")) + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("see `SECURITY.md`\n")) + payload := mustPayload(t, dir) + digest := ir.ComputeResultDigest(payload) + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(` +
          Rule packs
          house-policy
          +
          result_digest
          `+digest[:12]+`…
          `)) + + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}, Full: true}) + if err != nil { + t.Fatal(err) + } + if rep.UnconfirmedGenuine != 0 { + t.Fatalf("specificity: UnconfirmedGenuine want 0 got %d findings=%+v", rep.UnconfirmedGenuine, rep.Findings) + } + if rep.ContradictedCount != 0 { + t.Fatalf("specificity: ContradictedCount want 0 got %d findings=%+v", rep.ContradictedCount, rep.Findings) + } +} + +func TestThreatSymlinkSkipped(t *testing.T) { + dir := writeMinimalConsistent(t) + target := filepath.Join(dir, "secret.txt") + mustWrite(t, target, []byte("should-not-read\n")) + link := filepath.Join(dir, "02-action-report.md") + _ = os.Remove(link) + if err := os.Symlink(target, link); err != nil { + t.Skip("symlink not supported:", err) + } + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}, Full: true}) + if err != nil { + t.Fatal(err) + } + // Required layer as symlink → contradicted structure + found := false + for _, f := range rep.Findings { + if f.ID == "structure:02-action-report.md" && f.State == review.StateContradicted { + found = true + } + } + if !found { + t.Fatalf("symlink required layer must contradict: %+v", rep.Findings) + } +} + +func TestThreatSizeCapFinding(t *testing.T) { + dir := t.TempDir() + // Oversized gate JSON → truncation finding (not silent). + big := bytes.Repeat([]byte("a"), 9<<20) + mustWrite(t, filepath.Join(dir, "01-gate-failures.json"), big) + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("x\n")) + mustWrite(t, filepath.Join(dir, "03-executive-summary.md"), []byte("x\n")) + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(`
          `)) + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}, Full: true}) + if err != nil { + t.Fatal(err) + } + found := false + for _, f := range rep.Findings { + if f.ID == "digest:gate-json-size" && f.State == review.StateContradicted { + found = true + } + } + if !found { + t.Fatalf("size cap must emit finding: %+v", rep.Findings) + } +} + +func TestExtractorVisibleInTerse(t *testing.T) { + dir := writeMinimalConsistent(t) + // No path/claim/url → extractor reference:none; also drop-shaped backticks + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("flag `true` and `schema_version`\n")) + payload := mustPayload(t, dir) + digest := ir.ComputeResultDigest(payload) + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(` +
          Rule packs
          house-policy
          +
          result_digest
          `+digest[:12]+`…
          `)) + var buf bytes.Buffer + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: &buf}) + if err != nil { + t.Fatal(err) + } + if rep.DroppedCount < 1 { + t.Fatalf("expected dropped tokens, got %d", rep.DroppedCount) + } + if rep.UnconfirmedExtractor < 1 { + t.Fatalf("expected extractor unconfirmed, got %d", rep.UnconfirmedExtractor) + } + if !strings.Contains(buf.String(), "extractor") { + t.Fatalf("terse line must show extractor when >0: %s", buf.String()) + } +} + +func TestContextPackNotTriageSurface(t *testing.T) { + dir := writeMinimalConsistent(t) + // Cache paths only in context-pack.md — assistant export, not assessor claim surface. + mustWrite(t, filepath.Join(dir, "context-pack.md"), []byte( + "# Context pack\n\nSee `.github/curbpack/cache/latest_failure.json` and `.github/curbpack/cache/latest_result.json`.\n", + )) + mustWrite(t, filepath.Join(dir, "context-pack.json"), []byte(`{"schema":"curbpack-context-pack:1"}`+"\n")) + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: ioDiscard{}, Full: true}) + if err != nil { + t.Fatal(err) + } + for _, f := range rep.Findings { + if strings.Contains(f.ID, "reference:path:") && strings.Contains(f.ID, ".github/curbpack/cache") { + t.Fatalf("cache paths in context-pack.md must not become reference findings: %s", f.ID) + } + if f.State == review.StateUnconfirmed && f.Cause == review.CauseGenuine && + strings.Contains(f.Detail, ".github/curbpack/cache") { + t.Fatalf("cache paths must not flood genuine: %+v", f) + } + } + // Structure check for context-pack.json remains. + foundJSON := false + for _, f := range rep.Findings { + if f.ID == "structure:context-pack.json" && f.State == review.StateConfirmed { + foundJSON = true + } + } + if !foundJSON { + t.Fatal("optional structure check for context-pack.json must remain") + } +} + +func TestAirlockRedactThenEmit(t *testing.T) { + dir := writeMinimalConsistent(t) + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte( + "suspicious cite `/Users/evil/.ssh/id_rsa` in action report\n", + )) + var buf bytes.Buffer + rep, err := review.Run(review.Options{BundleRoot: dir, Writer: &buf, Full: true}) + if err != nil { + t.Fatalf("home-path cite must not fail airlock after redact-then-emit: %v", err) + } + out := buf.String() + if strings.Contains(out, "/Users/evil") { + t.Fatalf("emitted triage must not echo home path: %s", out) + } + if !strings.Contains(out, "") { + t.Fatalf("expected home-path redaction placeholder in output: %s", out) + } + found := false + for _, f := range rep.Findings { + if f.ID == "structure:airlock-redacted" && f.State == review.StateContradicted && f.Cause == review.CauseSelfDisagree { + found = true + } + } + if !found { + t.Fatalf("expected structure:airlock-redacted contradicted finding, got %+v", rep.Findings) + } + if !review.HasContradictions(rep) { + t.Fatal("airlock redaction is a contradiction (bundle echoed unsafe material)") + } +} + +func writeMinimalConsistent(t *testing.T) string { + t.Helper() + dir := t.TempDir() + payload := ir.GateFailurePayload{SchemaVersion: "1", PackID: "house-policy", ReadinessScore: 80} + raw, _ := json.MarshalIndent(payload, "", " ") + mustWrite(t, filepath.Join(dir, "01-gate-failures.json"), append(raw, '\n')) + mustWrite(t, filepath.Join(dir, "02-action-report.md"), []byte("ok\n")) + mustWrite(t, filepath.Join(dir, "03-executive-summary.md"), []byte("ok\n")) + digest := ir.ComputeResultDigest(payload) + mustWrite(t, filepath.Join(dir, "buyer-onepager.html"), []byte(` +
          Rule packs
          house-policy
          +
          result_digest
          `+digest[:12]+`…
          `)) + return dir +} + +func mustPayload(t *testing.T, dir string) ir.GateFailurePayload { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, "01-gate-failures.json")) + if err != nil { + t.Fatal(err) + } + var p ir.GateFailurePayload + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatal(err) + } + return p +} + type ioDiscard struct{} func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/site/for-reviewers/index.html b/site/for-reviewers/index.html index 8994eba..8a2b4ec 100644 --- a/site/for-reviewers/index.html +++ b/site/for-reviewers/index.html @@ -39,8 +39,10 @@

          For reviewers

          Offline document triage

          When you receive a curbpack-native review-pack, unzip it on your machine and run:

          -
          curbpack review <received-pack-dir>
          +
          curbpack review <received-pack-dir>
          +# terse default · --full for all findings · --json for schema v2

          You get pasteable triage: confirmed, unconfirmed, or contradicted findings about the document (structure, digests, references) — not a product verdict. Teaching folder: /curbpack/samples/review-pack/. Longer path: Are you receiving submissions?

          +

          Locked laptop? Receive the release binary as a file (email/USB) with checksums.txt, verify sha256, then PATH — Ladder 3. No installer engineering required.

          Artifact trust table

          From fe010abf03d9c0323f3bbccef023b17fa9d02269 Mon Sep 17 00:00:00 2001 From: Aslak Felin Date: Tue, 25 Aug 2026 23:06:05 +0200 Subject: [PATCH 4/4] test(exportx): refresh buyer-onepager golden for provenance digests Co-authored-by: Cursor --- internal/exportx/testdata/goldens/buyer-onepager.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/exportx/testdata/goldens/buyer-onepager.html b/internal/exportx/testdata/goldens/buyer-onepager.html index f2bb9b8..ab79137 100644 --- a/internal/exportx/testdata/goldens/buyer-onepager.html +++ b/internal/exportx/testdata/goldens/buyer-onepager.html @@ -4,7 +4,7 @@ Curbpack — Buyer One-Pager - +