Zylos LogoZylos
2026-07-31

Proving What Shipped Is What Was Reviewed, When an Agent Wrote It

supply-chainprovenancereproducible-buildsai-agentsgit-internalsslsadeployment-integritysigstore

Executive Summary

Code review approves a commit SHA; deployment ships a container image built from a different SHA, produced by a squash-merge, a tag, and a rebuild. Every one of those transformations is benign, but each breaks the naive assumption that "the SHA I reviewed" and "the SHA that's running" are the same identifier — commit hashes fold in metadata (parents, timestamps, committer) that changes on every rewrite, even when no file content changed. This gap has existed since Git adopted squash-merge workflows, but it sharpens once AI agents author the code and drive the merge-to-deploy pipeline with less continuous human attention at each hop. The fix is not to chase commit SHAs harder; it's to compare the right invariant. Git's tree object hashes file content independent of history, so rev-parse <ref>^{tree} is stable across squash and rebase and gives zero-drift proof for free. Pair that with reproducible/deterministic builds to extend the proof from source tree to built artifact, and with SLSA/in-toto/Sigstore attestation to make the chain independently verifiable. None of this validates that the reviewed code is correct — only that it is unchanged. This article covers the mechanics, a worked squash-merge-to-deploy example, the current state of the supporting standards, and a practical adoption ladder.

The Review-to-Deploy Trust Gap

Code review's implicit contract: someone looks at a diff anchored to an identifier, approves it, and everyone downstream treats that identifier as a proxy for "reviewed and safe." The identifier almost everyone reaches for is the commit SHA, because it's what shows up in the URL bar.

That proxy breaks the moment the artifact moves through ordinary steps:

  • Squash merge: collapses N commits into one new commit object with a new parent, new committer timestamp, and often a rewritten message. File content can be byte-identical to what was reviewed, but the commit SHA is necessarily new.
  • Rebase / merge commit: same story — the commit hash covers history-plus-metadata, not content alone.
  • Tagging: an annotated tag is its own object with its own SHA, one more layer removed from the reviewed commit.
  • Container rebuild: produces a new image digest on each invocation unless the build is deliberately made deterministic — timestamps, file ordering, and compiler metadata routinely leak in.

Once any of these happens, "does the deployed SHA match the reviewed SHA?" is the wrong question — the honest answer is always no. Teams either give up on verifying the linkage, trusting the pipeline's say-so, or build brittle SHA-lineage tooling that breaks on the first force-push or cherry-pick.

This is old news for human-driven orgs, handled by convention and spot-checks. It sharpens with an AI agent as author: agents routinely regenerate a PR (amend, rebase, push a new commit) between approval and CI; an executor agent "addressing review comments" may re-run codegen that touches unrelated files; and agent-authored volume makes manual re-review of every SHA transition impractical. The question needs a cheap, automatable, cryptographic answer — not a process reminder.

Why Commit-SHA Anchoring Breaks: The Git Object Model

Git's storage is a content-addressable Merkle DAG with four object types, each named by the SHA of its own contents:

ObjectContainsHash covers
blobraw file bytesfile content only
treedirectory listing: names, modes, blob/tree hashes of entriesthe file contents and paths below it
committree hash, parent hash(es), author, committer, timestamps, messagetree hash plus history and metadata
tag (annotated)target object hash, tagger, messagetarget hash plus tagging metadata

A commit's hash covers the tree hash and the parent pointer(s), identity, and timestamps — change any of those, even with a byte-identical tree, and you get a new commit SHA. A tree's hash covers only the recursive content of files and directories: same files, same paths, same modes ⇒ same tree hash, regardless of who committed it, when, or through how many intermediate commits.

That's why a squash merge changes the commit SHA but can leave the tree SHA untouched: squashing rewrites history without necessarily touching a file. If the target branch was current at merge time and no conflict resolution altered content, the resulting tree equals the tree of the last reviewed commit, even though the wrapping commit object is entirely new.

# Show a commit's object graph: tree, parent, author, message
git cat-file -p <commit-sha>

# Extract the tree hash a ref points to
git rev-parse <ref>^{tree}      # equivalently: git rev-parse <ref>:

# Equality gate: compare resolved tree OIDs, not displayed diff text
set -euo pipefail
TREE_A=$(git rev-parse --verify <sha-A>^{tree})
TREE_B=$(git rev-parse --verify <sha-B>^{tree})
test "$TREE_A" = "$TREE_B"

# Diagnostic display only
git diff --no-ext-diff --no-textconv <sha-A>^{tree} <sha-B>^{tree}

rev-parse resolves object identifiers; equality of the resolved tree OIDs is the identity gate (subject to hash collision resistance). Ordinary git diff is a human-facing display, with textconv enabled by default: a converter can discard differing bytes and leave empty output for unequal trees. Use the OIDs for the gate and --no-ext-diff --no-textconv for an unconverted diagnostic diff, not as a substitute for that comparison.

Tree-Hash Equivalence: The Worked Example

1. Agent opens a PR at commit X. A coding agent pushes a branch; CI runs; a human or reviewer-agent approves the diff at exactly commit X. The approval and CI run are anchored to X's tree, T1.

2. Squash-merge to main produces commit Y. The platform computes a new tree from the squash (identical to T1 if main hadn't moved and the merge was clean), assigns a new committer, timestamp, and parent. Y ≠ X, but Y's tree may still be T1.

3. A verifier gate checks tree equivalence before release, instead of the always-false Y == X:

set -euo pipefail
REVIEWED_TREE=$(git rev-parse --verify X^{tree})
MERGED_TREE=$(git rev-parse --verify Y^{tree})

if [ "$REVIEWED_TREE" = "$MERGED_TREE" ]; then
  echo "OK: zero file drift between reviewed and merged commit"
else
  echo "FAIL: merged tree diverges from what was reviewed" >&2
  git diff --no-ext-diff --no-textconv X^{tree} Y^{tree}   # diagnostic
  exit 1
fi

If this passes, every byte of every file in Y is provably identical to what the reviewer saw — the squash's history rewriting is irrelevant, because tree hashing doesn't see history. If it fails, you get an exact diff: either a benign merge-conflict resolution needing re-review, or something that shouldn't be there.

4. A release tag is cut on Y. git tag -a v2.7.0 Y creates a new tag object, but the check already ran against Y's tree — the tag is a durable pointer to an already-verified tree. The tag's own SHA is irrelevant to the equivalence proof.

5. Build an isolated snapshot of the verified tag and request deployment by digest. This scoped example assumes a trusted CI job, a self-contained tracked source tree with no submodules or Git LFS materialization, and no export-ignore / export-subst attributes (including local/global overrides). git archive honors those attributes, so repositories using them need an explicitly reviewed export policy instead. The build must not require .git, untracked generated files, or extra local contexts. The job exclusively owns the temporary directory throughout the build.

set -euo pipefail
TAG_COMMIT=$(git rev-parse --verify 'v2.7.0^{commit}')
TAG_TREE=$(git rev-parse --verify "${TAG_COMMIT}^{tree}")
test "$TAG_TREE" = "${MERGED_TREE:?run step 3 first}" || {
  echo "FAIL: release tag tree differs from verified merge" >&2
  exit 1
}

BUILD_TMP=$(mktemp -d)
mkdir "$BUILD_TMP/context"
git archive --format=tar "$TAG_COMMIT" | tar -xf - -C "$BUILD_TMP/context"
# Current checkout edits, untracked and ignored files never enter this context.
docker buildx build --push -t registry/app:v2.7.0 \
  --metadata-file "$BUILD_TMP/meta.json" "$BUILD_TMP/context"
IMAGE_DIGEST=$(jq -er '."containerimage.digest" |
  select(test("^sha256:[0-9a-f]{64}$"))' "$BUILD_TMP/meta.json")
kubectl set image deployment/app "app=registry/app@${IMAGE_DIGEST}"

The tag can move, so resolve it once to an immutable commit before checking and exporting. Unlike docker build ... ., the exported context excludes changes in the caller's working directory; build metadata is also outside the context. Under the stated export assumptions, the gates establish reviewed tree = merged tree = exported tag tree, then submit the builder's reported digest for deployment. They do not prove a successful rollout or an honest build: verify the actual workload separately, including any multi-platform index-to-platform-manifest relationship. External dependencies, the builder, and reproducibility still need the controls below.

Most CI pipelines that try to enforce "review == deploy" today compare commit SHAs, branch pointers, or PR numbers — porcelain identifiers that don't survive a squash or cherry-pick without deliberate lineage tooling. Tree-hash comparison sidesteps this by operating one layer down, on Git's actual content model, where "same content" has one unambiguous, checkable answer.

Extending the Proof Through the Build: Reproducible Builds

Tree-hash equivalence proves the source is unchanged; it says nothing about the build. Two builds of an identical tree can produce different bytes for uninteresting reasons — embedded timestamps, filesystem walk order, absolute paths in debug info — making output comparison impossible even when the inputs matched.

Reproducible builds solve this. The Reproducible Builds project (originating in Debian, now spanning F-Droid, Arch, Google, and others) defines it precisely: "A build is reproducible if given the same source code, build environment, and build instructions, any party can recreate bit-by-bit identical copies of all specified artifacts." Verification is mechanical: hash and compare.

Documented sources of nondeterminism include timestamps embedded in metadata or build-time strings; unsorted filesystem/input ordering that changes archive member order; locale/environment leakage into output; absolute build paths baked into debug symbols; and parallelism-dependent interleaving. The standard fix for timestamps is SOURCE_DATE_EPOCH, a widely adopted environment variable (usually the last relevant commit's date) that build tools consume instead of wall-clock time. Full reproducibility also typically requires pinned toolchain versions and base images, and normalized archive ordering.

When a build is genuinely reproducible, deploy-side verification becomes trivial: rebuild independently from the same verified tree and pinned toolchain, diff the output hash against what was deployed. A match extends the equivalence proof from source all the way to the running artifact. Reproducibility is genuinely hard beyond small, self-contained builds — dependency resolution, codegen nondeterminism, and native bindings are real obstacles — so treat it as an incremental target (pin toolchain and base images first, normalize timestamps/ordering next, full bit-for-bit last), not a binary switch.

The Attestation Layer: SLSA, in-toto, Sigstore

Tree-hash equivalence and reproducible builds give you the mechanism; attestation gives you a standardized, independently verifiable record that it was followed — needed once "trust the CI script" isn't enough for an auditor or an outside consumer.

in-toto (CNCF graduated) defines a signed-envelope format for verifiable claims about supply-chain steps: who performed a step, what inputs/outputs it had, how it relates to the rest of the chain. It's the technical substrate other frameworks build on.

SLSA (Supply-chain Levels for Software Artifacts, v1.2) is a maturity framework built on in-toto attestations, with two tracks approved as of v1.2 (November 2025):

TrackCertifiesLevels
Buildhow an artifact was builtL0 none → L1 provenance exists (unsigned) → L2 signed provenance, hosted platform → L3 hardened/isolated platform, signing secrets inaccessible to build steps
Sourcehow the source revision came to existL0 none → L1 version control → L2 change history + per-revision provenance → L3 org controls enforced/recorded → L4 mandatory two-person review

The Source Track is directly relevant: it requires that if additional changes are made during review, those changes must be reviewed too — codifying, as a formal control, the exact "review must cover the final revision" problem this article addresses. L4 requires two trusted parties to agree before a change lands on a protected branch, with a defined "Trusted Robot" exception for automation like dependency bots.

Sigstore provides the signing/transparency infrastructure making these attestations trustworthy: Fulcio issues short-lived certificates bound to an OIDC identity (no long-lived key management); Rekor is an append-only transparency log that records submitted signing evidence; inclusion is a property of the chosen signing service and verification policy, not a guarantee for every signature; cosign is the CLI tying both together for signing and verifying artifacts.

GitHub artifact attestations (actions/attest) operationalize this for GitHub Actions: a workflow generates an in-toto/SLSA provenance attestation after build, signs it via Sigstore, and stores it queryable by digest. Public repositories use the Sigstore public-good instance with its transparency log; private repositories use GitHub’s Sigstore instance, which has no transparency log. Private attestations still support signature and identity verification; do not promise a Rekor trail for them.

For a container, use the immutable registry digest confirmed by workload inspection, not a mutable tag. The following assumes DEPLOYED_DIGEST is that verified manifest/index digest (including platform mapping where applicable), and TAG_COMMIT is the expected build source commit resolved above:

gh attestation verify "oci://registry/app@${DEPLOYED_DIGEST:?confirm running artifact}" \
  -R ORG/REPO \
  --signer-workflow ORG/REPO/.github/workflows/release.yml \
  --source-digest "${TAG_COMMIT:?expected source commit}"

This verifies signed provenance for that artifact and enforces the expected repository, signer workflow, and source digest. These expectations must come from trusted release policy, not the attestation being checked. Under a trusted-builder assumption, this links the artifact to the expected build source; it authenticates claims, not independent proof that the builder executed them honestly. A compromised workflow can falsify workflow-controlled predicate fields. This recipe is a command-contract example, not a report of a live registry verification.

None of these layers substitutes for the others: tree-hash equivalence is the fast, mandatory, zero-infrastructure check; reproducible builds extend the proof to build output but require real engineering investment; attestation doesn't prove equivalence itself — it proves who claimed what, non-repudiably, verifiable by someone outside the pipeline. A pragmatic stack: tree-hash gating on every merge, reproducible builds as a stretch goal for high-value artifacts, attestation for external auditability.

The AI-Agent-Specific Risk Surface

  • Regenerate-between-review-and-merge. An executor agent told to "address review comments" may regenerate a larger diff than warranted, or re-run a codegen/formatting pass touching unrelated files, after a human approved an earlier tree. Tree-hash gating catches this automatically, forcing a conscious re-review instead of a silent pass-through.
  • Executor/reviewer separation of duty. When both authoring and merging can be agent-driven, SLSA Source Track's two-party model is a useful frame: define the "uploader" (authoring agent) versus "reviewer" (human, or a separately-scoped reviewer agent without branch write access), and bind the approval event to a specific tree hash — not a PR number — so a reviewer that approves text without re-hashing the current tree isn't a meaningless rubber stamp.
  • Agents as CI participants. If an agent can trigger rebuilds or push follow-up commits, treat it like SLSA Build L3 treats build workers: isolated, no access to signing secrets, no ability to alter provenance after the fact. Guidance from CISA/NSA/Five Eyes on AI coding agents (May 2026) recommends treating them as untrusted components by default — an argument for hard gates over policy-only controls.
  • Volume changes the economics. Agent PR volume can make manual SHA spot-checks, merely inconvenient at human scale, impractical at agent scale — the real argument for automating the check rather than relying on convention.

Practical Adoption Ladder

  1. Tree-hash gate on merge (do this first). A CI check comparing rev-parse <reviewed-sha>^{tree} against rev-parse <merged-sha>^{tree}, failing with a diff on mismatch — a shell script and a CI hook, no new infrastructure.
  2. Deploy by digest, not tag. Resolve to the immutable content digest at deploy time; stop deploying image:latest or even a version tag.
  3. Record who approved which tree, not just which PR number — makes step 1 auditable and is a lightweight precursor to formal Source Track attestations.
  4. Adopt build provenance attestation (GitHub artifact attestations, or self-hosted in-toto/Sigstore) once consumers need to verify provenance without trusting your CI's word.
  5. Invest in reproducible builds for the highest-value artifacts last — the most expensive step, with the biggest payoff for widely-consumed or security-critical components.

Limits: What This Does and Does Not Guarantee

  • Proves "unchanged," not "correct." A reviewer who approved buggy or malicious code gets a solid guarantee that the same buggy or malicious code shipped — the gate can't detect that the code was wrong to approve. That's still the job of review itself.
  • Proves file identity, not build or runtime identity, unless paired with reproducible builds and digest pinning.
  • Doesn't cover build-time inputs outside the tree, such as the resolved dependency graph, unless that resolution is itself reproducible.
  • Attestation proves who signed what, not that it was good. Sigstore/SLSA answer "did this claim come from this identity, unmodified, at this time" — not whether the claim's content was sound.
  • Hash collision resistance is a background assumption, not an absolute guarantee — the equivalence proof inherits whatever collision resistance the underlying hash function has.
  • Process discipline still matters. A two-person-review control is only as strong as the boundary between "uploader" and "reviewer" identities — if the same agent (or human, wearing two hats) can satisfy both roles, the control is theater regardless of the cryptography underneath.

Content-addressable equivalence gating converts "trust that nothing changed" from an assumption into a cheap, mechanically checkable fact — a meaningfully stronger property than most pipelines have today, but one link in a longer chain. Correctness, dependency integrity, and role separation all still need their own controls.

References