Skip to content

fix(builder): close the recipe-contract verification gaps - #334

Merged
antoinetoussaint-byte merged 2 commits into
mainfrom
fix/recipe-contract-verification-gaps
Aug 21, 2026
Merged

fix(builder): close the recipe-contract verification gaps#334
antoinetoussaint-byte merged 2 commits into
mainfrom
fix/recipe-contract-verification-gaps

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

Follow-up to #332. That PR merged the Docker build-recipe contract, but the verification-gap fixes reviewed on the branch did not make it into the merge — main currently has the contract with the gaps still open. This lands them.

The contract validated only the recipe files and left the recipes — the half that decides what gets built and where it is pushed — outside its integrity boundary.

Gaps closed

  • Verify ≠ buildable. Recipe dockerfile/context/dockerignore were never checked to exist in the emitted tree, so a green VerifyDockerBuildPlan was followed by a confusing buildx "Dockerfile not found". validateRecipes now requires every recipe path to resolve to a real tree entry (Dockerfile/dockerignore files, context a directory), enforced in both Build and Verify.
  • Digest covered only files. A plan whose image reference, build args, target, or paths were altered still passed verification when the files were byte-identical. aggregateRecipeDigest now covers the recipes too, so digest pinning actually detects recipe tampering.
  • Unconstrained recipe paths. A recipe could point buildx at a context or Dockerfile outside the caller-owned output directory (context: "/", dockerfile: "../.."). recipeRelPath now rejects absolute and escaping paths at the verification gate.
  • Recipe name not enforced.name is documented as the unique logical identity within a service; an empty or duplicated name was accepted and would silently build two recipes under one identity. Now rejected.

Also: stream file hashing (io.Copy instead of os.ReadFile) so inventorying a large context no longer buffers each file whole, and length-prefix every string in the digest (netstring form) so a path containing a separator or newline cannot be confused with a field boundary.

Tests

Added for each: missing-file rejection, path-escape rejection, duplicate-name rejection, and tampered-image detection through verification. Full agents/services suite green.

Related follow-up: #333 (enforce output_directory absoluteness at the CLI boundary).

🤖 Generated with Claude Code

The Docker build-recipe contract validated only the recipe *files* and
left the recipes — the half that decides what gets built and where it is
pushed — outside its integrity boundary. Gaps closed:
- A passing VerifyDockerBuildPlan did not mean the plan was buildable:
recipe Dockerfile/context/dockerignore were never checked to exist in
the emitted tree, so a green verify was followed by a confusing buildx
"Dockerfile not found". validateRecipes now requires every recipe path
to resolve to a real tree entry (Dockerfile/dockerignore files, context
a directory), enforced in both Build and Verify.
- The aggregate digest covered only the file inventory, so a plan whose
image reference, build args, target, or paths were altered still passed
verification when the files were byte-identical — the digest advertised
as the plan's fingerprint protected half the plan. aggregateRecipeDigest
now covers the recipes too, so digest pinning actually detects recipe
tampering.
- Recipe path fields were unconstrained, letting a recipe point buildx at
a context or Dockerfile outside the caller-owned output directory
(context: "/", dockerfile: "../.."). recipeRelPath now rejects absolute
and escaping paths at the verification gate.
- Recipe names, documented as the unique logical identity within a
service, were not enforced: an empty or duplicated name was accepted and
would silently build two recipes under one identity. validateRecipes now
rejects both.
Also stream file hashing (io.Copy instead of os.ReadFile) so inventorying
a large context no longer buffers each file whole, and length-prefix every
string in the digest (netstring form) so a path containing a separator or
newline cannot be confused with a field boundary.
Tests added for each: missing-file rejection, path-escape rejection,
duplicate-name rejection, and tampered-image detection through verification.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbdJpccJnztweMo1G7W7Sx
The recipe-contract verification still had three holes where a plan could
pass VerifyDockerBuildPlan yet not be the artifact the agent emitted:
- Digest covered content but not file mode. Flipping a recipe file's
executable bit (content byte-identical) — a change buildx carries into
the image — passed verification. Add RecipeFile.mode to the inventory
and cover it in aggregateRecipeDigest so a mode change is detected.
- Path-escape prevention was lexical only, and a symlink defeated it: a
Dockerfile symlinked to an out-of-tree target was accepted (fileDigest
followed it), and the higher-value context-directory escape was blocked
only by accident, via an unrelated "is a directory" io.Copy crash.
Reject symlinks in inventoryRecipeFiles so the tree is contained by
construction, not by accident — this also replaces that cryptic error
with a clear "symlinks are not permitted" message.
- The aggregate-digest algorithm changed but the contract version stayed
v1, so a stale-algorithm digest surfaced as a digest mismatch
(indistinguishable from tampering). Bump to v2 — since this commit again
changes the algorithm (mode coverage) — so an older digest is reported
as a contract mismatch, a clear and actionable error.
Tests added for each: mode-tamper detection, symlink rejection, and
stale-contract-version rejection. Full agents/services suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCpejtWC2kQurJMREuK1e
@antoinetoussaint-byte
antoinetoussaint-byteforce-pushed the fix/recipe-contract-verification-gaps branch from cf06580 to 94ad1d6CompareAugust 21, 2026 18:30
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 24d6840 into mainAug 21, 2026
1 check passed
Sign up for freeto 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

@antoinetoussaint-byte