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.
0.0.7Add 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
Record per-hunk evidence showing agent intent, superseded attempts, and test results
Generate risk-ordered code review summaries highlighting unverified changes
Verify commit integrity with cryptographic signatures and digest validation
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 sourceReports
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.jsonand 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.
editedmeans the harness saw the file change underneath it and these lines are among those that differed.approvedmeans the edit went through a permission prompt (Claude Code'sdefaultmode, Codex'suntrustedpolicy or a read-only sandbox), which is inferred from the setting in force and not observed. Everything else isnone. Every record states what the claim rests on incontact_basis, and opencode records nothing about permissions per call, so its edits always land in thenonecase 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
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-msggit hook, which builds the record and writes its digest into the commit message as a trailer, - a
post-commitgit hook, which stores the record onrefs/docket/records, - a fetch refspec for
refs/docket/*onorigin, sogit fetchbrings records down (docket pushsends them up; if there is no remote yet, rundocket initagain once there is), - a local signing key in
.git/docket/, - Claude Code
PreToolUse/PostToolUsehooks in.claude/settings.local.json, so docket can see edits the agent makes through the shell. That is the per-machine file, not the committedsettings.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 verifyreports 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.