Skip to content

claude: feat(attest): wrangle-attest verify — exec ampel, dual-check the verdict (#484 spike, v0.5.0)#780

Draft
TomHennen wants to merge 2 commits into
mainfrom
feat/484-engine-verify-exec-ampel
Draft

claude: feat(attest): wrangle-attest verify — exec ampel, dual-check the verdict (#484 spike, v0.5.0)#780
TomHennen wants to merge 2 commits into
mainfrom
feat/484-engine-verify-exec-ampel

Conversation

@TomHennen

Copy link
Copy Markdown
Owner

Spike of #484 with a deliberately different design than the issue proposes: exec the ampel CLI as an in-container sibling instead of importing ampel's Go packages. Stage 1 of 2 — engine only, no shell or image change.

DRAFT, targeting v0.5.0. #484 is deferred out of v0.4.0; nothing here needs to land now. This PR exists so v0.5.0 starts from evidence rather than from scratch. The verdict write-up is on #484.

Why exec, not import

#484 was deferred over three blockers, all caused by the library plan: ampel's HTML report has no public API, pkg/attest churned VSA semantics in a patch release, and pulling the CEL evaluator + plugins into the signing engine needed a security design pass. Exec-ing the CLI dodges all three: the CLI is ampel's stable contract, the HTML report is just ampel's stdout passed through, and the engine consumes the emitted VSA as opaque bytes (it parses only _type/predicateType/subject/predicate.verificationResult — the churny VSA internals never enter our code). ampel and wrangle-attest already ship in the same attest-toolbox image, so the exec needs no new dispatch.

The verdict protocol (the load-bearing part)

Verified by reading ampel v1.3.1 source directly (module cache), not from docs:

  • ampel verify exits 1 for BOTH a FAILED verdict and every tool error. cmd/ampel/main.go does os.Exit(1) on any returned error; internal/cmd/verify.go:542,579 does os.Exit(1) only when results.GetStatus() == papi.StatusFAIL && opts.SetExitCode. The exit code alone therefore cannot distinguish "policy failed" from "ampel broke".
  • The VSA is machine-readable and is the disambiguator. --attest-results --attest-format=vsa --results-path=F calls attester.AttestToFile(...) (verify.go:527) before the exit-code check, so F is written on PASS and on FAIL, but not on a tool error (which returns early). It is an in-toto VSA statement with predicate.verificationResult.
  • Exit code and VSA cannot disagree — both derive from the same in-memory results object.
  • --exit-code is a bool, default true (verify.go:174).

So the engine dual-checks: signing requires ampel exit 0 AND an emitted VSA that parses as in-toto, binds exactly the requested single-sha256 subject, and says verificationResult: "PASSED". There is no text-scraping anywhere — the verdict is read from a JSON field, not from the report.

One honest limitation (corrected mid-spike)

pkg/attest/vsa.go resultStringToSLSAResult maps both StatusPASS and StatusSOFTFAIL to "PASSED", and ampel exits nonzero only on StatusFAIL. So a SOFTFAIL is indistinguishable from a PASS by exit code and by VSA. This is not a hole this design opens — it is intended ampel + wrangle semantics: wrangle's own wrangle-release-pinned tenet is an enforce: OFF advisory group whose policy comment already says "a branch/SHA build SOFTFAILs here and still emits a PASSED VSA". Identical in today's shell. I initially claimed the dual-check caught SOFTFAIL; that was wrong, and the SPEC and the test case are corrected in this PR rather than left overstating the guarantee.

What the dual-check does catch (none of which the current shell catches): an absent VSA, an unparseable VSA, a wrong-subject VSA, a wrong-predicateType VSA, an empty verdict, and a FAILED verdict that somehow exited 0.

The engine also strips SIGSTORE_ID_TOKEN, ACTIONS_ID_TOKEN_REQUEST_*, and AMPEL_* from ampel's child env. The AMPEL_* scrub is load-bearing in principle — --context-env defaults true (verify.go:154) and pkg/context/env.go reads policy context from AMPEL_<KEY> vars, so a stray var could steer a verdict — though the container boundary already blocks host env today, making it defense-in-depth.

Upstream reuse (CLAUDE.md rule 4)

  • ampel CLI over pkg/verifier: the import path is exactly what Fold ampel policy verification + VSA emission into wrangle-attest (in-process) #484 deferred (unstable post-1.0 Go API, no report API); the CLI contract above is verified at v1.3.1, our pinned build.
  • carabiner-dev/signer signs the VSA bytes verbatim through the existing --statement path (shared signAndDeliver); it retries Fulcio/Rekor/TSA itself, so the engine adds no signing retry.
  • carabiner-dev/hasher self-digests file subjects via the existing subjects.go.
  • The one retry the engine does add wraps only the side-effect-free ampel exec (transient collector/locator I/O), with the results file removed between attempts.

Empirically verified

Ran the real engine + real ampel v1.3.1 against the checked-in consumer fixtures (test/consumer/fixtures/npm-vsa.intoto.jsonl + blob) and the shipped wrangle-vsa-consumer-v1.hjson policy: ampel exec'd as a sibling, the HTML report (🟢 PASS) passed through stdout, validateVSA accepted real ampel's VSA shape (subject digest + verdict), and signing failed closed at the OIDC step exactly as designed (no ambient token locally).

Tests

Hermetic Go tests with a scripted fake ampel (the exit-code × VSA verdict matrix must be deterministic offline; the real CLI contract is pinned by the stacked PR's real-binary bats test and the dispatch e2e) + the existing fake-signer seam: the fail-open guard (ampel exit 0 + FAILED VSA → exit 2, nothing signed), SOFTFAIL/empty-verdict/subject-mismatch/multi-digest/wrong-predicateType rejection, warn mode, retry semantics, env scrub, and the exact ampel argv.

Part of #484. Stacked flip: #. Relationship to #761: that PR flips only the VSA signing (bnd statement--statement); this ladder supersedes it if the exec-ampel design is accepted — sequencing is the owner's call.

🤖 Generated with Claude Code

review and others added 2 commits July 12, 2026 19:21
… sign the VSA

wrangle-attest verify evaluates the policy over one subject by exec-ing the
sibling ampel binary from the attest-toolbox image, then signs the emitted
VSA verbatim (the --statement signer path) and appends the signed line to
the per-artifact bundle.

The verdict protocol is deliberately stricter than an exit-code check:
ampel v1.3.1 overloads exit 1 (FAILED verdict and every tool error) and
exit 0 also covers SOFTFAIL and the empty-chain soft-pass, so the engine
requires exit 0 AND an emitted VSA that parses, binds exactly the requested
single-sha256 subject, and says verificationResult PASSED before anything
is signed. The ampel child env is scrubbed of SIGSTORE_ID_TOKEN,
ACTIONS_ID_TOKEN_REQUEST_*, and AMPEL_* (ampel resolves policy context from
AMPEL_<KEY> env vars).

Part of #484.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Source-verified (pkg/attest/vsa.go resultStringToSLSAResult): ampel maps both
StatusPASS and StatusSOFTFAIL to verificationResult PASSED, and exits nonzero
only on StatusFAIL. The dual-check therefore does NOT distinguish a SOFTFAIL
from a PASS — and must not: wrangle's own wrangle-release-pinned tenet is an
enforce: OFF advisory group that deliberately SOFTFAILs a branch build into a
PASSED VSA. The SPEC and the test case claimed otherwise; both corrected.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant