Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • ๐Ÿ”ฎ Predictions
  • ๐Ÿ“ฐ Breaking News
  • ๐ŸŽจ AI Art
  • ๐Ÿ“– Short Stories
  • View All โ†’
  • Products โ†’

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

ยฉ 2021-2026 Crashbytesยฎ by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Build a Verifiable Agent-Commit Provenance Trail in TypeScript
TutorialJune 29, 202626 min readโ€ข By Michael Eakins

Build a Verifiable Agent-Commit Provenance Trail in TypeScript

A hands-on TypeScript tutorial for proving which agent, model, prompt, and supervisor produced a code changeset โ€” and detecting any later tampering. 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.

Quick Takeaways

What you'll learn in this article

26 min read
Intermediate
  • 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

Step 1

Canonical hashing

Serialize a changeset deterministically and reduce it to one order-independent SHA-256 digest โ€” the thing the signature commits to.

Step 2

ed25519 keys

Generate a signing key pair, plus a deterministic from-seed helper so demos and tests are perfectly reproducible.

Step 3

Sign and verify

Build an attestation binding the changeset hash to the agent context, sign it, and verify it with three independent checks.

Step 4

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.

Advertisement

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.

Advertisement

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

Key custodyTutorial: a deterministic seed in code. Production: a signing key that never leaves an HSM or KMS, with the agent runner requesting signatures rather than holding the key.
Ledger anchoringTutorial: an in-memory chain. Production: the chain head anchored somewhere external โ€” a signed git tag, a transparency log, or a notary โ€” so the history itself cannot be silently rewritten and re-signed.
Changeset bindingTutorial: a hand-built Changeset object. Production: changesets derived from real VCS object ids (git tree and blob hashes) so the attestation binds to what the repository actually stores.

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.

Signed by Michael Eakins

PGP key fingerprint ends in 08E8 8F19 ยท signed 2026-06-29

Verify โ†’.sig
Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

TutorialTypeScriptCryptographyAI AgentsSoftware Supply Chain
Back to Articles
โ† PreviousThe Custody of Code: Why Source Control Became the Most Contested Layer in AINext โ†’Instrument an MCP Tool-Use Agent with OpenTelemetry Tracing in TypeScript

From across the CrashBytes network

More than the blog โ€” predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to Tutorial and expand your knowledge.

๐Ÿ“„Tutorial

Instrument an MCP Tool-Use Agent with OpenTelemetry Tracing in TypeScript

A hands-on TypeScript tutorial for making an autonomous, tool-using AI agent observable. You build a small, dependency-light agent loop and wrap it in OpenTelemetry traces โ€” a root span per invocation, child spans for every model call and every MCP tool call, using the gen_ai.* and MCP semantic conventions โ€” then prove the span tree with deterministic, in-memory tests. Runs offline with zero API keys.

24 min readRead more
๐Ÿ“„Tutorial

Building Production Agentic AI Systems - Complete Tool Calling Implementation Tutorial

Step-by-step guide to building a production-ready agentic AI system with tool calling, error handling, rate limiting, and observability. Includes complete working code and GitHub repository with TypeScript implementation using Claude API.

22 min readRead more
๐Ÿ“„Tutorial

Implementing RAG in a React Native App: On-Device and Cloud Retrieval

Build production Retrieval-Augmented Generation for React Native with one engine that runs both on-device and behind a Claude Opus 4.8 cloud service.

50 min readRead more
๐Ÿ“„Tutorial

Build a Pre-Deployment LLM Evaluation Pipeline in TypeScript

A hands-on TypeScript tutorial for a CI-integrated eval harness that gates LLM releases on capability, safety, and regression checks โ€” the discipline CAISI now requires from labs.

29 min readRead more