Quick Takeaways
What you'll learn in this article
- 1
A hands-on TypeScript tutorial for proving which agent, model, prompt, and supervisor produced a code changeset โ and detecting any later tampering
- 2
You build canonical changeset hashing, ed25519-signed attestations, and an append-only chained ledger you can verify offline, with zero runtime dependencies and zero API keys
Keep reading for detailed implementation, code examples, and real-world results
When an AI agent writes a function, the diff looks exactly like a diff a human wrote. That is the entire problem. As agents move from suggesting code to authoring the majority of changes on a team, the reviewer who opens a pull request can no longer assume a person stood behind it, thought about it, and would answer for it. The diff is just bytes, and bytes do not remember where they came from.
This is the practical edge of a shift I wrote about in the custody of code: as the repository becomes the contested layer of AI development, "who and what produced this change" stops being metadata you can shrug at and becomes something you need to be able to prove. The agent-native hosts now being built make a specific promise โ per-change provenance tying each edit to an agent, a prompt, and a model version. In this tutorial you build the cryptographic core of that promise yourself, in about 250 lines of dependency-free TypeScript.
By the end you will have a small library that hashes a changeset, signs an attestation binding it to its provenance with ed25519, and chains attestations into an append-only, tamper-evident ledger that anyone can verify offline with just a public key. It runs with zero API keys and zero runtime dependencies โ everything is built on Node's crypto module โ and it is fully deterministic, so the tests and the demo produce the same bytes every time.
The companion code lives at CrashBytes/ByteSizedExamples/agent-commit-provenance-typescript. Clone it and follow along, or build it file by file as we go.
What You'll Build
The library answers one question in a way that cannot be faked without the private key: did agent X, running model Y under supervisor Z, produce exactly this changeset, and has anything changed since? Four small pieces get us there.
The provenance pipeline you will build
Canonical hashing
Serialize a changeset deterministically and reduce it to one order-independent SHA-256 digest โ the thing the signature commits to.
ed25519 keys
Generate a signing key pair, plus a deterministic from-seed helper so demos and tests are perfectly reproducible.
Sign and verify
Build an attestation binding the changeset hash to the agent context, sign it, and verify it with three independent checks.
Chained ledger
Link attestations into an append-only hash chain so editing any past change breaks every link after it.
The design keeps the cryptography boring on purpose. Canonical JSON, SHA-256, ed25519, and a hash chain are all well-understood primitives; the interesting engineering is in wiring them together so the result is reproducible, offline, and easy to test. Boring cryptography is the only kind worth shipping.
Prerequisites
You need Node 20 or newer and a passing familiarity with TypeScript. There is no account to create and no service to run โ node:crypto provides ed25519 and SHA-256 natively, so the whole project installs only dev tooling.
Step 0: Project Setup
Create the project and install the four dev dependencies. There are no runtime dependencies at all.
mkdir agent-commit-provenance-typescript && cd $_ npm init -y npm install -D typescript tsx vitest @types/node mkdir src test examples
The package.json scripts give us a build, a watch-mode demo, and the test and type-check commands:
{
"name": "agent-commit-provenance-typescript",
"type": "module",
"engines": { "node": ">=20" },
"scripts": {
"build": "tsc",
"dev": "tsx watch examples/demo.ts",
"demo": "tsx examples/demo.ts",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit"
}
}
The tsconfig.json runs in strict, modern mode. Two flags matter for this project: verbatimModuleSyntax forces you to be explicit about type-only imports, and noUncheckedIndexedAccess makes array indexing return T | undefined so the compiler reminds you to handle empty ledgers and missing entries.
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["es2022"],
"strict": true,
"declaration": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src", "test", "examples"],
"exclude": ["node_modules", "dist"]
}
With that in place, we can write the data model.
Step 1: The Data Model
Everything in the library moves three shapes around: a Changeset (the what), an AgentContext (the who and how), and an Attestation (the signed record binding them). Putting them in one file first makes the rest of the code read cleanly. Create src/types.ts:
/** A single file touched by a changeset. */
export interface FileChange {
path: string
op: 'add' | 'modify' | 'delete'
/** The full new contents after the change. Empty string for a delete. */
contents: string
}
/** A tool the agent invoked while producing a change. */
export interface ToolCall {
name: string
/** A short, stable digest of the arguments โ not the raw args. */
argsDigest: string
}
/** The provenance context: who and what produced a changeset. */
export interface AgentContext {
agentId: string
model: string
/** SHA-256 (hex) of the prompt that produced the change. */
promptHash: string
toolCalls: ToolCall[]
/** Identifier of the human who supervised or approved the run. */
supervisor: string
}
/** A set of file changes the agent proposes as one unit. */
export interface Changeset {
id: string
/** The files this changeset touches. Order does not matter. */
files: FileChange[]
}
/** The signed body of an attestation. Everything here is covered by the signature. */
export interface AttestationPayload {
v: 1
changesetId: string
changesetHash: string
agent: AgentContext
issuedAt: string
/** Hash of the previous attestation in the chain, or null for the first. */
prev: string | null
}
/** A verifiable record binding a changeset to its provenance. */
export interface Attestation {
payload: AttestationPayload
/** base64 ed25519 signature over the canonical payload. */
signature: string
/** Canonical hash of the payload โ the link the next attestation points to. */
hash: string
}
export interface VerifyResult {
valid: boolean
/** Empty when valid; one human-readable reason per failed check otherwise. */
reasons: string[]
}
Notice what the AgentContext records: not the raw prompt but its hash, not the raw tool arguments but a digest. Provenance does not require keeping every secret the agent saw; it requires keeping a commitment to them, so that a later claim ("this was produced by that prompt") can be checked without storing the prompt in the clear. Notice too that promptHash is just a hex string โ we will compute it with the same sha256 helper we build next.
Step 2: Canonical Hashing
A signature is only meaningful if the bytes being signed are reproducible. If two processes serialize the same logical object into different byte strings, a signature made by one will not verify for the other. Plain JSON.stringify is a trap here, because object key order is not guaranteed to be stable across code paths. The fix is canonicalization: sort every object's keys, recursively, before stringifying. Create src/canonical.ts:
import { createHash } from 'node:crypto'
import type { Changeset, FileChange } from './types.js'
/** Serialize a JSON value with object keys sorted recursively, deterministically. */
export function canonicalize(value: unknown): string {
return JSON.stringify(sortValue(value))
}
function sortValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortValue)
if (value !== null && typeof value === 'object') {
const source = value as Record<string, unknown>
const out: Record<string, unknown> = {}
for (const key of Object.keys(source).sort()) {
out[key] = sortValue(source[key])
}
return out
}
return value
}
/** SHA-256 of a UTF-8 string, hex-encoded. */
export function sha256(input: string): string {
return createHash('sha256').update(input, 'utf8').digest('hex')
}
Arrays keep their order โ order is meaningful in a list โ but objects are sorted, so { b: 1, a: 2 } and { a: 2, b: 1 } canonicalize to the identical string. That single guarantee is what makes the signatures downstream portable.
Now the changeset hash. We want a digest that is the same for the same set of file changes regardless of the order the files arrive in, because agents frequently emit files concurrently and you do not want the provenance to depend on a race. The trick is to digest each file independently, then sort the digests before combining them:
function fileDigest(file: FileChange): string {
return sha256(
canonicalize({
op: file.op,
path: file.path,
contents: sha256(file.contents),
})
)
}
/** Order-independent content hash of a changeset. */
export function hashChangeset(changeset: Changeset): string {
const fileDigests = changeset.files.map(fileDigest).sort()
return sha256(canonicalize({ id: changeset.id, files: fileDigests }))
}
We hash each file's contents first, so the outer digest stays small and constant-size no matter how large the files are, and we bind that content digest to the file's path and op so a rename or a change from add to delete produces a different digest. Sorting the per-file digests gives the order independence.
Step 3: ed25519 Keys
ed25519 is the right signature scheme for this job: small keys, small signatures, deterministic signing, and it is built into Node, so we keep our zero-dependency promise. For real use you generate a fresh key pair; for demos and tests you want a deterministic key pair so the output is reproducible. Create src/keys.ts:
import {
createPrivateKey,
createPublicKey,
generateKeyPairSync,
type KeyObject,
} from 'node:crypto'
export interface KeyPair {
privateKey: KeyObject
publicKey: KeyObject
}
/** Generate a fresh, random ed25519 key pair. */
export function generateKeyPair(): KeyPair {
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
return { privateKey, publicKey }
}
// A PKCS#8 ed25519 private key is a fixed 16-byte ASN.1 header followed by the
// raw 32-byte seed. Prepending the header turns any 32-byte seed into a key
// object Node accepts โ the standard trick for deterministic ed25519.
const PKCS8_ED25519_PREFIX = Buffer.from(
'302e020100300506032b657004220420',
'hex'
)
/** Build a deterministic ed25519 key pair from a 32-byte seed. */
export function keyPairFromSeed(seed: Buffer): KeyPair {
if (seed.length !== 32) {
throw new Error(`seed must be exactly 32 bytes, got ${seed.length}`)
}
const der = Buffer.concat([PKCS8_ED25519_PREFIX, seed])
const privateKey = createPrivateKey({
key: der,
format: 'der',
type: 'pkcs8',
})
const publicKey = createPublicKey(privateKey)
return { privateKey, publicKey }
}
/** Export a public key to base64 SPKI DER, suitable for storage or transport. */
export function exportPublicKey(publicKey: KeyObject): string {
return publicKey.export({ format: 'der', type: 'spki' }).toString('base64')
}
/** Re-import a public key previously produced by exportPublicKey. */
export function importPublicKey(base64Spki: string): KeyObject {
return createPublicKey({
key: Buffer.from(base64Spki, 'base64'),
format: 'der',
type: 'spki',
})
}
The keyPairFromSeed helper is worth dwelling on, because it is a genuinely useful trick. Node will not let you hand it 32 raw bytes and call them an ed25519 key โ it wants a PKCS#8 DER structure. But for ed25519 that structure is just a fixed 16-byte prefix followed by your 32-byte seed, so prepending the constant gives Node exactly what it expects. The same seed always yields the same keys, which is precisely what makes a reproducible demo possible. Never use a guessable seed for anything that protects real code; for teaching and testing it is ideal.
The export and import helpers exist because verification happens elsewhere โ possibly on another machine, by someone who only has the public key. Reducing the public key to a single base64 string makes it easy to publish next to the repository, the same way a project publishes a PGP key.
Step 4: Signing Attestations
Now we bind a changeset to its provenance. An attestation is the signed claim "this agent, model, prompt, and supervisor produced exactly this changeset, at this time, following this previous attestation." Create src/attest.ts:
import { sign } from 'node:crypto'
import type { KeyObject } from 'node:crypto'
import type {
AgentContext,
Attestation,
AttestationPayload,
Changeset,
} from './types.js'
import { canonicalize, hashChangeset, sha256 } from './canonical.js'
export interface SignOptions {
/** Hash of the previous attestation in a chain. Omit/null for the first. */
prev?: string | null
/** Override the issued-at timestamp; pass a fixed value for reproducibility. */
issuedAt?: string
}
export function signAttestation(
changeset: Changeset,
agent: AgentContext,
privateKey: KeyObject,
options: SignOptions = {}
): Attestation {
const payload: AttestationPayload = {
v: 1,
changesetId: changeset.id,
changesetHash: hashChangeset(changeset),
agent,
issuedAt: options.issuedAt ?? new Date().toISOString(),
prev: options.prev ?? null,
}
const canonical = canonicalize(payload)
// ed25519 takes a null algorithm โ the curve already fixes the hash.
const signature = sign(
null,
Buffer.from(canonical, 'utf8'),
privateKey
).toString('base64')
return { payload, signature, hash: sha256(canonical) }
}
Three details earn their place here. First, we sign the canonical payload, not a hand-built string, so the signer and any future verifier agree on the exact bytes. Second, the signature covers the changeset's hash, not its contents โ the attestation stays tiny even for a thousand-file change, and the actual code can be stored and moved separately. Third, issuedAt is overridable: production code lets it default to now, while tests pass a fixed timestamp so the signed bytes โ and therefore the signature โ are identical on every run.
That last point is what makes the whole library testable. A signing function that secretly depends on the wall clock is non-deterministic and miserable to test; threading the timestamp through as an option turns the determinism back on whenever you need it.
Step 5: Verifying
Verification is where the value is. A good verifier does not return a single boolean โ it runs each independent check and tells you exactly which one failed, because "the signature is wrong" and "the code was changed" are very different incidents. Create src/verify.ts:
import { verify } from 'node:crypto'
import type { KeyObject } from 'node:crypto'
import type { Attestation, Changeset, VerifyResult } from './types.js'
import { canonicalize, hashChangeset, sha256 } from './canonical.js'
export function verifyAttestation(
attestation: Attestation,
changeset: Changeset,
publicKey: KeyObject
): VerifyResult {
const reasons: string[] = []
const canonical = canonicalize(attestation.payload)
// 1. The recorded hash must match the payload (detects edits to the attestation).
if (sha256(canonical) !== attestation.hash) {
reasons.push('attestation hash does not match its payload')
}
// 2. The signature must verify against the canonical payload bytes.
let signatureOk = false
try {
signatureOk = verify(
null,
Buffer.from(canonical, 'utf8'),
publicKey,
Buffer.from(attestation.signature, 'base64')
)
} catch {
signatureOk = false
}
if (!signatureOk) {
reasons.push('signature does not verify against the payload')
}
// 3. The changeset must hash to what the payload attested (detects edits to the code).
if (hashChangeset(changeset) !== attestation.payload.changesetHash) {
reasons.push('changeset hash does not match the attested hash')
}
return { valid: reasons.length === 0, reasons }
}
The three checks are genuinely independent and catch different attacks. Check one catches someone editing the attestation's own fields โ swapping the supervisor, say โ and leaving the old hash in place. Check two catches a forged or wrong-key signature. Check three catches the most important case for our problem: the code itself being changed after it was attested, while the attestation sits there looking legitimate. Wrapping the verify call in a try/catch matters because Node throws on malformed signature bytes rather than returning false, and a malformed signature should be a clean "invalid," not a crash.
Step 6: The Tamper-Evident Ledger
A single attestation proves one change. A chain of them proves a history. By recording the hash of the previous attestation in each new one, we make the entries into a hash chain: change any past changeset and its attestation hash changes, which breaks the prev link of every entry after it. That is the same property that makes a git history or a blockchain tamper-evident, and it is only a few lines. Create src/ledger.ts:
import type { KeyObject } from 'node:crypto'
import type {
AgentContext,
Attestation,
Changeset,
VerifyResult,
} from './types.js'
import { signAttestation, type SignOptions } from './attest.js'
import { verifyAttestation } from './verify.js'
export interface LedgerEntry {
changeset: Changeset
attestation: Attestation
}
export class ProvenanceLedger {
private readonly entries: LedgerEntry[] = []
append(
changeset: Changeset,
agent: AgentContext,
privateKey: KeyObject,
options: Omit<SignOptions, 'prev'> = {}
): Attestation {
const prev = this.head()?.hash ?? null
const attestation = signAttestation(changeset, agent, privateKey, {
...options,
prev,
})
this.entries.push({ changeset, attestation })
return attestation
}
head(): Attestation | undefined {
return this.entries.at(-1)?.attestation
}
list(): readonly LedgerEntry[] {
return this.entries
}
verifyChain(publicKey: KeyObject): VerifyResult {
const reasons: string[] = []
let expectedPrev: string | null = null
for (let i = 0; i < this.entries.length; i++) {
const entry = this.entries[i]
if (!entry) continue
const result = verifyAttestation(
entry.attestation,
entry.changeset,
publicKey
)
if (!result.valid) {
reasons.push(
`entry ${i} (${entry.changeset.id}): ${result.reasons.join('; ')}`
)
}
if (entry.attestation.payload.prev !== expectedPrev) {
reasons.push(
`entry ${i} (${entry.changeset.id}): broken chain link โ expected prev ` +
`${expectedPrev ?? 'null'}, got ${entry.attestation.payload.prev ?? 'null'}`
)
}
expectedPrev = entry.attestation.hash
}
return { valid: reasons.length === 0, reasons }
}
}
verifyChain does two things per entry: it verifies the attestation against its changeset, and it checks that the prev pointer matches the hash of the actual previous entry. The second check is the chain integrity โ even if every individual attestation were valid in isolation, a reordered or spliced history would fail the linkage check. And like the single-attestation verifier, it collects every problem rather than bailing on the first, so an audit gives you the complete picture in one pass.
The if (!entry) continue line is there because noUncheckedIndexedAccess makes this.entries[i] typed as possibly-undefined. It is a small tax that buys real safety: the compiler will not let you forget that an index might miss.
Step 7: Re-Export and Run It
A single src/index.ts re-exports the public surface so consumers import from one place:
export type {
FileChange,
ToolCall,
AgentContext,
Changeset,
AttestationPayload,
Attestation,
VerifyResult,
} from './types.js'
export { canonicalize, sha256, hashChangeset } from './canonical.js'
export {
generateKeyPair,
keyPairFromSeed,
exportPublicKey,
importPublicKey,
type KeyPair,
} from './keys.js'
export { signAttestation, type SignOptions } from './attest.js'
export { verifyAttestation } from './verify.js'
export { ProvenanceLedger, type LedgerEntry } from './ledger.js'
Now examples/demo.ts ties it together: build a two-entry ledger, verify it, then tamper with already-attested code and watch verification reject it. Using a fixed seed and fixed timestamps makes the output identical on every run.
import {
ProvenanceLedger,
verifyAttestation,
keyPairFromSeed,
exportPublicKey,
sha256,
type AgentContext,
type Changeset,
} from '../src/index.js'
const { privateKey, publicKey } = keyPairFromSeed(Buffer.alloc(32, 7))
function agent(promptText: string): AgentContext {
return {
agentId: 'agent://refactor-bot/1',
model: 'claude-opus-4-8',
promptHash: sha256(promptText),
toolCalls: [{ name: 'read_file', argsDigest: sha256('src/retention.ts') }],
supervisor: 'renata.okafor@example.com',
}
}
const first: Changeset = {
id: 'CS-1001',
files: [
{
path: 'src/retention.ts',
op: 'modify',
contents: 'export const WINDOW_DAYS = 30;\n',
},
{
path: 'test/retention.test.ts',
op: 'add',
contents: "import './fixtures';\n",
},
],
}
const ledger = new ProvenanceLedger()
ledger.append(
first,
agent('fix the off-by-one in the retention window'),
privateKey,
{
issuedAt: '2026-06-29T08:00:00.000Z',
}
)
// ...a second changeset is appended the same way...
console.log(
`Chain verification: ${ledger.verifyChain(publicKey).valid ? 'VALID' : 'INVALID'}`
)
Running npm run demo produces a stable trace. The hashes below are real output from the seeded run โ you will see exactly these values:
Public key (share this to let anyone verify): MCowBQYDK2VwAyEA6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iw= Attestation for CS-1001 agent agent://refactor-bot/1 (claude-opus-4-8) prev (genesis) hash dd52f5b79cedc2adece752b9a361b0acfb5ebd34746340e9ac331a7b2ceec161 Attestation for CS-1002 agent agent://refactor-bot/1 (claude-opus-4-8) prev dd52f5b79cedc2adece752b9a361b0acfb5ebd34746340e9ac331a7b2ceec161 hash 6acc35d2fd597cc35676b1443d1b2dff0fe105b940b94a4ba7f02af7af34f3b4 Chain verification: VALID Re-verifying CS-1001 against tampered code: INVALID - changeset hash does not match the attested hash
The second attestation's prev is the first attestation's hash โ there is the chain, visible in the output. And when the demo edits an already-attested file and re-verifies, the third check fires with a precise reason. That is the whole point of the library, demonstrated in a dozen lines.
Step 8: Testing the Guarantees
A provenance library that is not itself thoroughly tested is a contradiction. The companion repo ships sixteen tests across three files; the interesting ones assert the security properties directly. Here is the heart of test/attest.test.ts:
it('verifies a freshly signed attestation', () => {
const att = signAttestation(changeset, agent, keys.privateKey, fixedTime)
expect(verifyAttestation(att, changeset, keys.publicKey).valid).toBe(true)
})
it('rejects a changeset whose code was tampered with', () => {
const att = signAttestation(changeset, agent, keys.privateKey, fixedTime)
const tampered: Changeset = {
id: 'CS-1',
files: [
{ path: 'src/x.ts', op: 'modify', contents: 'export const x = 666;\n' },
],
}
const result = verifyAttestation(att, tampered, keys.publicKey)
expect(result.valid).toBe(false)
expect(result.reasons).toContain(
'changeset hash does not match the attested hash'
)
})
it('rejects a signature from the wrong key', () => {
const att = signAttestation(changeset, agent, keys.privateKey, fixedTime)
const other = generateKeyPair()
const result = verifyAttestation(att, changeset, other.publicKey)
expect(result.valid).toBe(false)
expect(result.reasons).toContain(
'signature does not verify against the payload'
)
})
These tests assert behavior, not implementation. They say a valid attestation verifies, a tampered changeset is caught with the right reason, and a wrong key is rejected โ exactly the properties a reader needs to trust. The ledger tests go one step further and prove that mutating a committed entry breaks the chain:
it('detects tampering with an already-committed changeset', () => {
const ledger = new ProvenanceLedger()
ledger.append(changeset('CS-1', 1), agent('one'), keys.privateKey, t(1))
ledger.append(changeset('CS-2', 2), agent('two'), keys.privateKey, t(2))
// Mutate the contents of the first entry after the fact.
ledger.list()[0]!.changeset.files[0]!.contents = 'export const x = 9999;\n'
const result = ledger.verifyChain(keys.publicKey)
expect(result.valid).toBe(false)
expect(result.reasons.some(r => r.includes('CS-1'))).toBe(true)
})
Run the suite and the type-checker:
npm test # 16 passed npm run type-check
Because every test uses a seeded key and a fixed timestamp, there is no flakiness and nothing to mock โ the determinism we built in at each step pays off as a test suite that is fast, total, and trivially reproducible.
A Note on Algorithm Choices
The two cryptographic primitives here were chosen for boring, defensible reasons, and it is worth knowing why so you can defend them in a review of your own.
ed25519 is preferred over RSA and ECDSA for this kind of attestation work on several counts. Its keys and signatures are tiny โ a 32-byte public key and a 64-byte signature โ which keeps attestations small even when you store millions of them. Signing is deterministic by construction, so the same message and key always produce the same signature without depending on a quality source of randomness at signing time, which is exactly the property that lets our tests assert byte-for-byte output. And it sidesteps whole categories of ECDSA implementation pitfalls around nonce reuse. Node exposes it natively, so we pay no dependency cost to use it.
SHA-256 is the conservative default for the content hashing. It is collision- resistant at a margin no one expects to fall for the lifetime of any code you are attesting, it is fast, and it is universally available, so a verifier in another language or another runtime will compute the same digest from the same canonical bytes. The one rule we are careful to follow is to always hash the canonical serialization, never a value straight out of JSON.stringify, because a hash is only as reproducible as its input. If you ever swap in a different hash โ BLAKE3, say, for speed โ change it in exactly one place, the sha256 helper, and every digest in the system moves with it. Centralizing the primitive is what makes that kind of swap a one-line change rather than an audit.
The broader lesson is that none of these choices are clever, and that is the point. Provenance is infrastructure you want to still trust in five years, so every primitive in it should be the unremarkable, widely-reviewed option, wired together carefully. Save the cleverness for the integration, not the crypto.
Wiring It Into an Agent Loop
The library is deliberately agnostic about where changesets come from, which makes it easy to drop into an existing agent. The integration is two touch points: the agent runner signs an attestation the moment it finalizes a change, and a gate โ a CI job, a pre-merge hook, a review surface โ verifies before the change is allowed to land. The sketch below is illustrative rather than part of the repo, but it shows the shape:
// When the agent finishes producing a change, capture provenance at the source.
async function recordAgentRun(
run: AgentRun,
ledger: ProvenanceLedger,
key: KeyObject
) {
const changeset: Changeset = {
id: run.taskId,
files: run.editedFiles.map(f => ({
path: f.path,
op: f.op,
contents: f.newContents,
})),
}
const agent: AgentContext = {
agentId: run.agentId,
model: run.model,
promptHash: sha256(run.systemPrompt + '\n' + run.userPrompt),
toolCalls: run.toolCalls.map(t => ({
name: t.name,
argsDigest: sha256(t.argsJson),
})),
supervisor: run.approvedBy,
}
return ledger.append(changeset, agent, key)
}
// At the gate, refuse to merge unless the whole chain still verifies.
function gateOnProvenance(
ledger: ProvenanceLedger,
publicKey: KeyObject
): void {
const result = ledger.verifyChain(publicKey)
if (!result.valid) {
throw new Error(
`provenance check failed:\n ${result.reasons.join('\n ')}`
)
}
}
The important property is where the attestation is created. Sign at the moment of authorship, inside the agent runner that has the prompt, the model id, and the tool calls in hand โ not later, in a separate job that has to reconstruct them and can only guess. Provenance captured at the source is evidence; provenance reconstructed after the fact is a story. The whole reason to thread promptHash and toolCalls through the AgentContext is so the record is made by the only component that actually knows them.
The gate side is just as simple, and it is where the guarantee becomes operational. A CI job that calls verifyChain and fails the build on a bad result turns the cryptography into a policy: code without intact, verifiable provenance does not merge. That is the same place a team already runs tests and linters, so the provenance check costs nothing new in workflow โ it is one more gate that either passes cleanly or stops the line with a specific reason.
The Threat Model
It is worth being precise about what this design defends against and what it does not, because a provenance system that is trusted beyond its actual guarantees is worse than none. Be clear-eyed about the boundary.
What it does defend against is tampering after attestation. Once a changeset is signed, any later edit to the code, to the recorded agent context, or to the ordering of the history is detectable by anyone with the public key. An attacker who rewrites a committed change cannot produce a matching attestation without the private key, and an attacker who swaps in a different attestation breaks the chain linkage. The signature also pins authorship: a verifier knows the attestation came from the holder of a specific key, not from an impostor.
What it does not do is vouch for the honesty of the input. If the signing key is compromised, the attacker can mint perfectly valid attestations for malicious code โ the system proves provenance, not virtue. If the agent runner lies about which model it used or whose approval it had, the attestation faithfully records the lie; garbage in, signed garbage out. And nothing here prevents a bad change from being authored in the first place; it only makes the authorship undeniable after the fact. Provenance is accountability infrastructure, not a correctness check โ it tells you who to ask when something goes wrong, and proves the code in front of you is the code that was reviewed, but it does not make the review good.
That boundary is exactly why key custody and ledger anchoring, covered next, matter so much. The signature is only as trustworthy as the secrecy of the key, and the chain is only as trustworthy as the difficulty of rewriting it wholesale. A teaching implementation with a seed in the source has neither property; a production deployment earns both back deliberately.
From Toy to Production
What you have built is the real cryptographic core of agent-commit provenance, but a teaching implementation is not a key-management product. Three changes take it the rest of the way.
What changes between this tutorial and a production system
The shape stays the same in all three cases: canonical hashing, an ed25519 attestation, and a hash chain. That is the reassuring part โ the primitives you just wired together are the same ones the agent-native hosts and supply-chain frameworks use; production hardens the edges rather than replacing the middle.
This also connects to the broader trajectory. I argued in the custody of code that source control is becoming the contested layer of AI development, and I put a dated stake in the ground in my prediction that agent-native source control goes mainstream. Provenance is one of the features those hosts will compete on, and the news analysis of the SpaceXโCursor deal and Origin shows the consolidation already underway. Provenance is also a sibling discipline to observability: where instrumenting an agent with OpenTelemetry tracing tells you what an agent did at runtime, attestation tells you what it produced and lets you prove it later. A mature agent platform wants both.
Conclusion
When agents author most of your code, "trust me, a person reviewed it" stops being an answer. What replaces it is not more trust but less need for it: a verifiable record that says precisely which agent, model, prompt, and supervisor stand behind a change, signed in a way that anyone can check and no one can forge without the key. You built that record in this tutorial โ canonical hashing so the bytes are reproducible, ed25519 attestations so they are authentic, and a hash chain so the history is tamper-evident โ in a few hundred lines with no dependencies and no services.
The cryptography was deliberately ordinary. The engineering that matters is the determinism: a library that produces the same bytes every time is one you can test exhaustively, reason about completely, and trust in an audit. Clone the companion repository, run the demo, break a changeset on purpose, and watch the verifier catch you. Once you have seen a tampered diff get rejected by a check you wrote yourself, the custody of code stops being an abstraction and becomes something you can hold.
