Tool

Capture evidence trails for AI-generated code commits

Single static binary that stores a signed per-commit evidence record for agent-written code: who wrote each line, why, what was tried and what tested it.

Works with gitgithubclaudevitest

91
Spark score
out of 100
Updated last month
Source checked Oct 14, 2026
Version 0.0.7
Models
claudeclaude 3 opus

Add to Favorites

Why it matters

Create auditable evidence records for every commit written by coding agents, capturing what the agent tried, what tests passed or failed, and which lines have coverage-so code reviewers can focus attention on changes that lack verification evidence.

Outcomes

What it gets done

01

Record per-hunk evidence showing agent intent, superseded attempts, and test results

02

Generate risk-ordered code review summaries highlighting unverified changes

03

Verify commit integrity with cryptographic signatures and digest validation

04

Trace code lineage by explaining why specific lines exist via git blame

Source

Get it from source

Spark does not host a copy of it.

Open source

Reports

Agent outcome reports

No reports yet

Overview

Docket is a per-commit evidence record for agent-written code. The agent harness records its decisions and checks but throws them away at commit; Docket reads the session, matches it against the diff, and stores a signed evidence record for each commit, so for any hunk or line you can see who wrote it, why, what was tried before it and what verified it. It has no account and no network access. Use it when coding agents write much of your code and you still have to review, explain and trust it. It reports what it cannot explain as unknown instead of guessing. It is not orchestration, a live dashboard or a UI you live in.

What it does

Docket is a per-commit evidence record for agent-written code. The agent harness records the decisions and checks of a coding session but throws them away at commit. Docket reads the session, matches it against the diff and stores a signed evidence record for each commit, so for any hunk or line you can see who wrote it, why, what was tried before it and what tested it. There is no account and no network access. The records live in your repository on an orphan ref, refs/docket/records, and docket show HEAD displays the record for a commit with the riskiest hunks first; --all adds the hunks that have evidence behind them and docket show with a commit, --all and --json prints the whole record as JSON. A record is built once, at commit time, with whatever docket could do then.

For each hunk the record carries these fields:

  • origin: the agent and tool that wrote the lines.
  • task: the request the edit came from, in the human's words.
  • intent: what the agent said it was about to do, in the message immediately before the edit, which is the agent's reasoning in its own words.
  • attempt: code that was written into the region and then removed, with the check that failed in between if there was one. These abandoned approaches are the part that is normally lost within hours.
  • evidence: coverage and test runs. Test runs, type checks and static analysis are matched to the edits they followed, and a check that ran before the code was written is not evidence about it. Istanbul coverage-final.json and lcov reports are matched line by line against each hunk and ignored when the report is older than the code.
  • human contact: a terminal has no read receipts, so docket only claims what a harness recorded. edited means the harness saw the file change underneath it and these lines are among those that differed. approved means the edit went through a permission prompt (Claude Code's default mode, Codex's untrusted policy or a read-only sandbox), which is inferred from the setting in force and not observed. Everything else is none. Every record states what the claim rests on in contact_basis, and opencode records nothing about permissions per call, so its edits always land in the none case where the transcript does not say.

Each hunk gets an evidence density score between 0 and 1. Coverage of the hunk's lines is worth up to 0.5, a check that passed after the edit 0.3 (0.35 if this change turned it green), type and static checks up to 0.1 together, and recorded human contact up to 0.1. Caps then apply: 0.15 if nothing executed the code, 0.5 if nobody can say who wrote it, and a locally claimed record scores 0.9 of a CI-attested one.

How attribution works: docket parses the agent transcript into an ordered stream of events, replays every recorded edit per file and tracks an origin for each line through later edits. At commit time it lines up the committed file against the replay and resolves each hunk. A line is attributed to an edit only when its text is found at the aligned position and in that edit's recorded output; timestamps order events and never justify an attribution. If an edit's recorded before-image disagrees with the replay, because a shell command, an editor or a person changed the file in between, docket marks the affected lines unknown and restarts the replay from the recorded content. unknown is always an available answer and always comes with a reason. Lines that no agent edit touched, such as composer.lock, scaffolded models and generated code, are reported as unknown, with the commands that ran nearby listed as candidates, and are not counted as attributed. Docket stores short, redacted excerpts, not the conversation.

When to use - and when NOT to

Use it when coding agents write much of your code and you still have to explain it in review, fix it when it breaks and decide whether to trust it. docket explain src/auth.js:51 answers why a line exists, using git blame to find the commit, so you can start from the line in front of you. docket review --format md produces a pull request comment, and docket review --range reviews a range as one change with the least-verified hunks first. docket gate --commits 20 measures attribution against your own history by replaying real sessions against real commits and reporting what it could and could not explain; it needs the session transcripts, which stay on the machine that ran the agent, as does --rebuild.

Current limits: there is no reader for Gemini CLI or ACP-native agents, coverage is read only from Istanbul and lcov reports, GitLab is not supported, and there is no cross-repository aggregation, no policy gate on paths and no hosted team tier. What it is not: it is not orchestration, task assignment or agent spawning, not agent-to-agent handoff or provider routing, not a UI you live in (it annotates the review surface you already use), not a live dashboard, and not something that asks for an account. The trust tier is part of the schema and shown wherever a record is rendered. local_claimed means it was built on a developer machine and signed with a key that machine holds, and it is a claim. ci_attested means it was built by a CI runner and signed with a key the developer does not hold, set through DOCKET_SIGNING_KEY in CI.

Inputs and outputs

Input is the agent session plus the commit diff. Claude Code sessions are read from ~/.claude/projects/**/*.jsonl (or $CLAUDE_CONFIG_DIR), where docket gets Edit and Write calls with before and after images, shell commands, prompts and the agent's narration. Codex CLI sessions come from ~/.codex/sessions/**/rollout-*.jsonl, with apply_patch calls, shell commands with exit codes, prompts and narration. opencode sessions come from ~/.local/share/opencode/opencode.db and need sqlite3 installed. Codex and opencode send patches rather than whole files, so their edits have no before-image: docket seeds the replay from the base revision and applies each patch to the content it was written against, and when a patch does not fit it re-seeds and marks what it cannot explain. Any other agent can point its hooks at docket collect pre and docket collect post, and its edits are observed directly.

An agent that writes files through the shell (a heredoc, sed -i, a generator, a formatter) leaves nothing in the transcript to recover. The Claude Code PreToolUse and PostToolUse hooks that docket init installs cover this for Claude Code: one snapshots the working tree content, the other diffs it, and both images are stored as git blobs. These edits are marked observed rather than transcript, because docket saw them itself.

Output is the signed record, one extra trailer line on each commit, terminal views, JSON and a markdown pull request comment. Other commands: docket build --staged builds a record for the staged change without committing, docket verify HEAD checks the digest, signature and commit binding, docket push sends records to the remote, docket fetch gets them (git does not fetch refs/docket/records by default), and docket doctor reports what docket can and cannot see in the repository.

The trailer looks like Docket: sha256:... and sits alongside lines such as Signed-off-by and Reviewed-by. It is the only change to your history: subject lines and git log --oneline are untouched, and the trailer names a record in the repository rather than a URL, so it does not depend on any service staying up. Amending rebuilds the record and replaces the trailer. Merges get no trailer, and neither does a commit with nothing attributable in it. Rebase and cherry-pick do not run prepare-commit-msg, so the trailer is copied onto a diff it was not built from, and docket verify reports this rather than trusting the trailer.

Integrations

Docket is a single static binary for macOS, Linux and Windows on arm64 and x86-64. On macOS or Linux:

curl -fsSL https://raw.githubusercontent.com/Dillonsmart/docket/main/install.sh | sh

On Windows, or if you would rather not pipe to sh, download the archive for your platform from the releases page, unpack it and put docket on your PATH. Each release publishes SHA256SUMS, and the installer checks the download against it. To build from source, use go install github.com/Dillonsmart/docket/cmd/docket@latest.

docket init sets up a repository. It installs a prepare-commit-msg git hook that builds the record and writes its digest into the commit message as a trailer, a post-commit git hook that stores the record on refs/docket/records, a fetch refspec for refs/docket/* on origin so git fetch brings records down, a local signing key in .git/docket/, and Claude Code hooks in .claude/settings.local.json. That is the per-machine file, not the committed settings.json, because the hook names the path to the binary on this machine. If there is no remote yet, run docket init again once there is. The hooks never block a commit: if docket is missing or the record cannot be built, the commit goes through and the error is written to .git/docket/docket.log. To stop using docket, delete the two hooks from .git/hooks; existing trailers stay in the history as plain text.

To upgrade, re-run the installer and it replaces the binary in place. docket version prints what you have. To pin a version or roll back, set DOCKET_VERSION on the sh side of the pipe, for example DOCKET_VERSION=v0.0.7 sh. From source, run go install again. In CI, the GitHub Action's default version: latest picks up the newest release on each run; pin it to a tag to control when that happens. Nothing needs migrating: stored records carry the schema version they were written against and stay readable, and the only local state is the signing key, which upgrades do not touch. The hooks call the binary by absolute path and fall back to docket on PATH, so you only need to re-run docket init if you install to a new location and delete the old binary.

For pull requests, the GitHub Action reads the records that came with the commits and posts one comment that puts the hunks with no evidence first and collapses the well-covered ones underneath. It downloads the same binary, so nothing needs installing on the runner.

Privacy: everything that reaches a record goes through a redaction pass covering private key blocks, known credential formats (AWS, GitHub, GitLab, Slack, Stripe, Google, OpenAI, Anthropic, npm), JWTs, authorization headers, credentials in URLs, KEY=, SECRET= and PASSWORD= style assignments, and any long token whose characters look random. A record stores short redacted excerpts, never whole files or whole prompts. Nothing is sent anywhere: records live in your repository, docket push sends them to the git host you already use, and the signing key never leaves the machine. Records follow the Commit Evidence Record (CER) format. Docket is licensed Apache 2.0.

Source README
Docket

Why

More and more of the code in a repository is written by a coding agent, often running in auto mode. The agent reads the code, picks an approach, edits, runs the tests, fixes what failed and hands back a finished diff. Along the way it made decisions and ran checks that the developer never saw.

The developer still owns that code. They have to explain it in review, fix it when it breaks and decide whether to trust it. Without knowing what the agent tried, what it verified and which lines nobody ever looked at, that is hard to do.

The agent harness already records all of this. It is thrown away when you commit. Docket keeps it: it reads the session, matches it against the diff, and stores a signed evidence record for each commit. You can then see, for any hunk or any line, who wrote it, why, what was tried before it, and what tested it.

$ docket show HEAD

docket 7a9da95a2a748c2fb99706f48f2ddf64760ffab2
  record     sha256:046c5278e93cb516e3ba72400fd1a69648c85b93833d31d57648e9ed8e535ebf
  trust      local_claimed (signed on local)
  verified   digest and signature check out (ed25519:f889a26cbc34c95e)

  1 hunks in 1 files, 4 added lines
  100% of added lines attributed to a recorded edit
  mean evidence density 0.77

src/auth.js:3-6 (4 lines)  covered  density 0.77
  origin      claude-code/main via Edit (reported by the harness)
              claude-opus-5
  task        Fix session fixation on login
  intent      The raw UUID is reused across logins, so minting a prefixed id instead.
  attempt     Stamping a creation time on the session so expiry can be checked. [superseded]
              vitest failed after this change: npx vitest run
  evidence    coverage: 4 of 4 lines executed (coverage/coverage-final.json)
  evidence    test_execution: pass (npx vitest run --coverage) — failed before this change
  human       no recorded human contact with these lines — the edit was written without a prompt (claude-code acceptEdits)

There is no account and no network access. The records live in your repository on an orphan ref, refs/docket/records.


Install

Docket is a single static binary. On macOS or Linux:

curl -fsSL https://raw.githubusercontent.com/Dillonsmart/docket/main/install.sh | sh

On Windows, or if you would rather not pipe to sh, download the archive for your platform from the releases page (macOS, Linux and Windows, arm64 and x86-64), unpack it and put docket on your PATH. Each release publishes SHA256SUMS, and the installer checks the download against it.

To build from source:

go install github.com/Dillonsmart/docket/cmd/docket@latest
Try it first
scripts/demo.sh

This creates a throwaway repository in a temp directory with a recorded agent session in it. The agent fixes a session fixation bug, gets it wrong once, sees a test fail, fixes it properly, and also edits a second file through the shell, which the transcript does not record. It then prints the docket for the commit so you can see both kinds of hunk. Your own repositories are not touched and the directory is deleted on exit. It needs docket on your PATH, or DOCKET=/path/to/docket.

Or read this repository's own records

Docket records its own development. Clone it, fetch the records, and read the history of the tool from the tool:

git clone https://github.com/Dillonsmart/docket && cd docket
docket fetch                          # records live on refs/docket/records, which git does not fetch by default

docket show HEAD                      # the last commit, riskiest hunks first
docket show --all HEAD                # including the hunks with evidence behind them

# a commit the agent made through the shell: docket watched the files, and the tests ran in the same call
docket show --all $(git log --format=%h --grep='Join an observed edit' -1)

# why does this function exist? git blame finds the commit, the record explains the hunk
docket explain internal/build/build.go:$(grep -n 'func link(' internal/build/build.go | cut -d: -f1)

docket review --range v0.0.5..v0.0.6 --format md   # a release reviewed as one change, least-verified hunks first
docket verify HEAD                    # the record is intact, signed, and describes this commit
docket doctor                         # what docket can see on this machine

The records were made on the author's machine, so they read trust local_claimed. docket gate and --rebuild need the session transcripts, which stay on that machine; everything else works from the clone. A record is built once, at commit time, with whatever docket could do then - so older commits carry thinner records than newer ones, and that is the history, not a bug.

Set up a repository
docket init

This installs:

  • a prepare-commit-msg git hook, which builds the record and writes its digest into the commit message as a trailer,
  • a post-commit git hook, which stores the record on refs/docket/records,
  • a fetch refspec for refs/docket/* on origin, so git fetch brings records down (docket push sends them up; if there is no remote yet, run docket init again once there is),
  • a local signing key in .git/docket/,
  • Claude Code PreToolUse/PostToolUse hooks in .claude/settings.local.json, so docket can see edits the agent makes through the shell. That is the per-machine file, not the committed settings.json: the hook names the path to the binary on this machine.

The hooks never block a commit. If docket is missing or the record cannot be built, the commit goes through and the error is written to .git/docket/docket.log.

From then on each commit gets one extra trailer line, alongside Signed-off-by and Reviewed-by:

    Add the session helper

    The API needs a stable id per session, and the obvious place is here
    rather than in the middleware.

    Reviewed-by: Someone Else <someone@example.com>
    Docket: sha256:db77fdb4b0c1fc6cc7644d3fa2204267e25799d74a6f8f665aff3d32b12c39af

That is the only change to your history. Subject lines and git log --oneline are untouched, and the trailer names a record in the repository rather than a URL, so it does not depend on any service staying up.

Some cases to know about:

  • Amend rebuilds the record and replaces the trailer, so a commit never points at a record for content it no longer has.
  • Merges get no trailer, and neither does a commit with nothing attributable in it.
  • Rebase and cherry-pick do not run prepare-commit-msg, so the trailer is copied onto a diff it was not built from. docket verify reports this rather than trusting the trailer.

To stop using docket, delete the two hooks from .git/hooks. Existing trailers stay in the history as plain text.

Upgrading

Re-run the installer and it replaces the binary in place:

curl -fsSL https://raw.githubusercontent.com/Dillonsmart/docket/main/install.sh | sh

docket version prints what you have. To pin a version, or to roll back, set DOCKET_VERSION on the sh side of the pipe:

curl -fsSL https://raw.githubusercontent.com/Dillonsmart/docket/main/install.sh | DOCKET_VERSION=v0.0.7 sh

From source, go install github.com/Dillonsmart/docket/cmd/docket@latest again. In CI, the action's default version: latest picks up the newest release on each run; pin it to a tag if you want to control when that happens.

Nothing needs migrating. Stored records carry the schema version they were written against and stay readable, and the only local state is the signing key, which upgrades do not touch. The hooks call the binary by absolute path and fall back to docket on PATH, so you only need to re-run docket init if you install to a new location and delete the old binary.

Use it

docket show HEAD                      # the record for a commit, riskiest hunks first
docket build --staged                 # build a record for the staged change without committing
docket explain src/auth.js:51         # why does this line exist? (found via git blame)
docket review --format md             # the pull request comment
docket verify HEAD                    # check digest, signature and commit binding
docket push                           # send records to the remote
docket fetch                          # get records from the remote
docket doctor                         # what docket can and cannot see in this repo
docket gate --commits 20              # measure attribution against your own history

For pull requests, the GitHub Action reads the records that came with the commits and posts one comment. It puts the hunks with no evidence first and collapses the well-covered ones underneath. It downloads the same binary, so nothing needs installing on the runner.

Reading the reasoning back

Three fields answer "why does this code look like this":

  • task: the request this edit came from, in the human's words.
  • intent: what the agent said it was about to do, in the message immediately before the edit. This is the agent's reasoning in its own words.
  • attempt: code that was written into this region and then removed, with the check that failed in between if there was one. These abandoned approaches are the part that is normally lost within hours.
$ docket explain database/migrations/0001_01_01_000000_create_players_table.php:54

database/migrations/…:54 was last written by be66f35c5977

  origin      claude-code/main via Edit
  task        Look at the engine plan for this project, challenge any assumptions then start implementing
  intent      Postgres `jsonb` normalises key order, so the replayed response wasn't byte-identical
              to the original. For a stored response we only ever return verbatim, `json` is the
              right column type.
  attempt     Now the schema. Replacing the default `users` table with `players` as the
              authenticatable model. [superseded]

docket explain uses git blame to find the commit, so you can start from the line in front of you rather than needing to know which commit to look in. docket show <sha> --all --json prints the whole record as JSON.

Docket stores short, redacted excerpts, not the conversation. That is enough to reconstruct the decision, and whole transcripts contain secrets and grow without bound.


How it works

Attribution. Docket parses the agent transcript into an ordered stream of events, replays every recorded edit per file, and tracks an origin for each line through later edits. At commit time it lines up the committed file against that replay and resolves each hunk.

A line is only attributed to an edit when its text is found at the aligned position and in that edit's recorded output. Timestamps are used to order events, never to justify an attribution.

If an edit's recorded before-image disagrees with the replay, because a shell command, an editor or a person changed the file in between, docket marks the affected lines unknown and restarts the replay from the recorded content. It does not carry a guess forward. unknown is always an available answer and always comes with a reason, because a confidently wrong attribution is worse than none.

Agents. Docket reads sessions from Claude Code, Codex CLI and opencode. Everything after the reader is agent-agnostic, so adding an agent means adding a reader:

Agent Where its session lives What docket gets from it
Claude Code ~/.claude/projects/**/*.jsonl (or $CLAUDE_CONFIG_DIR) Edit/Write calls with before and after images, shell commands, prompts, the agent's narration
Codex CLI ~/.codex/sessions/**/rollout-*.jsonl apply_patch calls, shell commands with exit codes, prompts, narration
opencode ~/.local/share/opencode/opencode.db (needs sqlite3 installed) write/edit calls with diffs, shell commands with exit codes, prompts, narration
anything else - point its hooks at docket collect pre and docket collect post and the edits are observed directly

Codex and opencode send patches rather than whole files, so their edits have no before-image. Docket seeds the replay from the base revision and applies each patch to the content it was written against. When a patch does not fit, it re-seeds and marks what it cannot explain rather than guessing where the hunk goes.

Observation. Reading the transcript recovers Edit and Write calls. An agent that writes files through the shell (a heredoc, sed -i, a generator, a formatter) leaves nothing in the transcript to recover. So docket also watches the working tree: a PreToolUse hook snapshots the content, a PostToolUse hook diffs it, and both images are stored as git blobs. These edits are marked observed rather than transcript, because docket saw them itself.

Evidence. Test runs, type checks and static analysis are matched to the edits they followed. A check that ran before the code was written is not evidence about it. Coverage reports (istanbul coverage-final.json and lcov) are matched line by line against each hunk, and ignored when the report is older than the code.

Human contact. A terminal has no read receipts, so docket only claims what a harness recorded. edited means the harness saw the file change underneath it and these lines are among the ones that differed - line-accurate, from a recorded before-image. approved means the edit that wrote these lines went through a permission prompt: Claude Code's default mode, Codex's untrusted policy or a read-only sandbox. That is inferred from the setting in force, not observed, and a per-tool allow rule can silence a prompt the mode would otherwise show, so every record says what the claim rests on (contact_basis) and a reader can discount it. Everything else is none, with the basis saying whether the harness wrote without asking or the transcript simply does not say. opencode records nothing about permissions per call, so its edits are always the latter.

Density. Each hunk gets a score between 0 and 1. The formula is published in the spec: coverage of the hunk's lines is worth up to 0.5, a check that passed after the edit 0.3 (0.35 if this change turned it green), type and static checks up to 0.1 together, recorded human contact up to 0.1. Then the caps: if nothing executed the code the score is capped at 0.15, if nobody can say who wrote it at 0.5, and a locally-claimed record scores 0.9 of a CI-attested one.

The caps matter. This number will end up being used as a target, the same way coverage was, and a score you can raise without running anything would be worthless.


How well does attribution work

docket gate replays real sessions against real commits and reports what it could and could not explain. Measured so far:

Session Agent Hunks in files the session edited Added lines Content-verified
A Laravel engine, Edit/Write tools (4 commits, 98 edits) Claude Code 98.1% 99.8% 100%
A Python app, apply_patch (4 commits, 256 edits) Codex CLI 94.7% 95.7% 99.8%
A FastAPI project, 100 edits (uncommitted, measured against the working tree) opencode - 92.9% -
A PHP framework built largely through the shell (12 commits, 79 edits) Claude Code 59.2% 82.1% 100%

Two things to take from this. Every attribution was checked against the crediting edit's own recorded output, so docket never credited a line to an edit that did not write it. And the last row is why the shell-edit hooks exist: those sessions wrote files with heredocs and sed, which no transcript records, before docket could observe them. With docket init in place, those edits are captured.

Across whole diffs the rate is lower (40%, 94% and 37% for the three committed sessions), because real commits also contain composer.lock, scaffolded models and generated code that no agent edit touched. Docket reports those as unknown, listing the commands that ran nearby as candidates. It does not count them as attributed.

Run it on your own history:

docket gate --commits 20

What it is not

  • Not orchestration, task assignment or agent spawning.
  • Not agent-to-agent handoff or provider routing.
  • Not a UI you live in. It annotates the review surface you already use.
  • Not a live dashboard.
  • Not something that asks for an account.

Trust

Tier Meaning
local_claimed Built on a developer machine and signed with a key that machine holds. It is a claim.
ci_attested Built by a CI runner and signed with a key the developer does not hold. Set DOCKET_SIGNING_KEY in CI.

The tier is part of the schema and shown wherever a record is rendered.

The format

The record format is specified separately as the Commit Evidence Record, with a JSON Schema. Docket is the reference implementation. The spec is Apache 2.0 and written to be implemented by other tools.

Status

Working: attribution, the shell-edit hooks, redaction, signed records on an orphan ref, coverage and test correlation, the terminal viewer, explain, the pull request comment, the GitHub Action, and gate.

Not built yet: readers for Gemini CLI and anything ACP-native, coverage formats beyond istanbul and lcov, GitLab, cross-repository aggregation, policy gates on paths, and a hosted team tier.

scripts/demo.sh

#!/usr/bin/env bash
#
# Build a throwaway repository with a recorded agent session in it, then print
# the docket for the commit. Nothing here touches your own repositories.
#
#   scripts/demo.sh
#   DOCKET=./docket scripts/demo.sh    # use a specific binary
#
# What it stages: an agent fixes a session fixation bug, gets it wrong once,
# sees a test fail, fixes it properly, and runs the suite. It also edits a
# second file through the shell, which the transcript does not record — so the
# two hunks in the commit have very different evidence behind them.
set -euo pipefail

DOCKET="${DOCKET:-docket}"
command -v "$DOCKET" >/dev/null 2>&1 || { echo "docket not found; set DOCKET=/path/to/docket" >&2; exit 1; }

WORK=$(mktemp -d 2>/dev/null || mktemp -d -t docket-demo)
trap 'rm -rf "$WORK"' EXIT
export CLAUDE_CONFIG_DIR="$WORK/agent-home"

git init -q "$WORK/repo"
REPO=$(cd "$WORK/repo" && git rev-parse --show-toplevel)
cd "$REPO"
git config user.email demo@example.com
git config user.name "Demo"

printf 'coverage/\n.claude/\n' > .gitignore
mkdir -p src
cat > src/session.ts <<'TS'
import { randomUUID } from "node:crypto";

export function createSession(user: User): Session {
  return { id: randomUUID(), user, created: Date.now() };
}

export function isExpired(s: Session, now: number): boolean {
  return now - s.created > SESSION_TTL_MS;
}
TS
cat > src/audit.ts <<'TS'
export function recordLogin(userId: string, ip: string) {
  logger.info("login", { userId, ip });
}
TS
git add -A
git commit -q -m "chore: session scaffold"

"$DOCKET" init >/dev/null

# --- the shell edit, observed by docket's own hooks -------------------------
HOOK_JSON="{\"session_id\":\"demo-session\",\"cwd\":\"$REPO\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"sed -i '' 's/{ userId, ip }/{ userId, ip, at: Date.now() }/' src/audit.ts\"}}"
printf '%s' "$HOOK_JSON" | "$DOCKET" collect pre
cat > src/audit.ts <<'TS'
export function recordLogin(userId: string, ip: string) {
  logger.info("login", { userId, ip, at: Date.now() });
  metrics.increment("auth.login");
}
TS
printf '%s' "$HOOK_JSON" | "$DOCKET" collect post

# --- the recorded session ---------------------------------------------------
SLUG=$(printf '%s' "$REPO" | tr '/.' '--')
PROJECT="$CLAUDE_CONFIG_DIR/projects/$SLUG"
mkdir -p "$PROJECT"

# Three versions of the file: what it was, the approach that failed, and the
# one that shipped. The transcript records the images, which is what makes the
# attribution verifiable rather than inferred.
BEFORE=$(cat src/session.ts)

FIRST=$(cat <<'TS'
import { randomUUID } from "node:crypto";

export function createSession(user: User): Session {
  // regenerate on every login so a pre-auth id cannot be replayed
  return { id: randomUUID(), user, created: Date.now(), rotated: true };
}

export function isExpired(s: Session, now: number): boolean {
  return now - s.created > SESSION_TTL_MS;
}
TS
)

FINAL=$(cat <<'TS'
import { randomUUID } from "node:crypto";

export function createSession(user: User): Session {
  // A session id minted before authentication must never survive it: the
  // attacker planted it. Mint a fresh one, namespaced so a leaked pre-auth
  // id cannot be replayed against this table.
  return { id: mintSessionId(), user, created: Date.now() };
}

function mintSessionId(): string {
  return `sess_${randomUUID()}`;
}

export function isExpired(s: Session, now: number): boolean {
  return now - s.created > SESSION_TTL_MS;
}
TS
)
printf '%s\n' "$FINAL" > src/session.ts

json_string() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
now() { python3 -c "import datetime,sys; print((datetime.datetime.now(datetime.timezone.utc)-datetime.timedelta(seconds=int(sys.argv[1]))).isoformat().replace('+00:00','Z'))" "$1"; }

FILE=$(json_string "$REPO/src/session.ts")
# The images are quoted exactly as the harness records them, trailing newline
# included: an edit tool matches bytes, not lines.
B=$(json_string "$BEFORE
"); F=$(json_string "$FIRST
"); V=$(json_string "$FINAL
")

# acceptEdits, so the docket says the agent wrote without asking rather than
# implying someone read the diff.
cat > "$PROJECT/demo-session.jsonl" <<JSONL
{"type":"permission-mode","permissionMode":"acceptEdits","sessionId":"demo-session"}
{"type":"user","uuid":"u1","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 300)","message":{"role":"user","content":"Fix the session fixation issue on login — an id minted before auth is still valid afterwards"}}
{"type":"assistant","uuid":"a1","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 290)","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"Rotating the id at login and flagging it, so a pre-auth id is no longer accepted."},{"type":"tool_use","id":"e1","name":"Edit","input":{"file_path":$FILE}}]}}
{"type":"user","uuid":"u2","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 289)","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"e1"}]},"toolUseResult":{"filePath":$FILE,"oldString":$B,"newString":$F,"originalFile":$B,"structuredPatch":[],"userModified":false,"replaceAll":false}}
{"type":"assistant","uuid":"a2","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 280)","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"b1","name":"Bash","input":{"command":"npx vitest run","description":"run the suite"}}]}}
{"type":"user","uuid":"u3","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 260)","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"b1"}]},"toolUseResult":{"stdout":" FAIL  tests/session.spec.ts > rejects a pre-auth id\n Tests  1 failed | 7 passed","stderr":"","interrupted":false}}
{"type":"assistant","uuid":"a3","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 240)","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"The rotated flag advertises that a fixation attempt happened, and a raw uuid is still guessable across tables. Minting a namespaced id instead."},{"type":"tool_use","id":"e2","name":"Edit","input":{"file_path":$FILE}}]}}
{"type":"user","uuid":"u4","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 239)","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"e2"}]},"toolUseResult":{"filePath":$FILE,"oldString":$F,"newString":$V,"originalFile":$F,"structuredPatch":[],"userModified":false,"replaceAll":false}}
{"type":"assistant","uuid":"a4","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 220)","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"b2","name":"Bash","input":{"command":"npx vitest run --coverage","description":"suite with coverage"}}]}}
{"type":"user","uuid":"u5","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 200)","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"b2"}]},"toolUseResult":{"stdout":" Test Files  4 passed (4)\n      Tests  8 passed (8)","stderr":"","interrupted":false}}
{"type":"assistant","uuid":"a5","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 190)","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"b3","name":"Bash","input":{"command":"npx tsc --noEmit","description":"typecheck"}}]}}
{"type":"user","uuid":"u6","sessionId":"demo-session","cwd":"$REPO","timestamp":"$(now 180)","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"b3"}]},"toolUseResult":{"stdout":"Found 0 errors.","stderr":"","interrupted":false}}
JSONL

# Coverage for the tested file only, so the other hunk has nothing behind it.
mkdir -p coverage
python3 - "$REPO" <<'PY' > coverage/coverage-final.json
import json, sys
p = sys.argv[1] + "/src/session.ts"
print(json.dumps({p: {"path": p,
  "statementMap": {str(i): {"start": {"line": i + 1}, "end": {"line": i + 1}} for i in range(16)},
  "s": {str(i): 6 for i in range(16)}}}))
PY

git add -A
git commit -q -m "fix: mint a fresh session id at login

An id minted before authentication survived it, so an attacker who planted
one kept a valid session afterwards."

echo
"$DOCKET" show HEAD --all

FAQ

Common questions

Discussion

Questions & comments · 0

Sign In Sign in to leave a comment.