custody
A receipt for every tool call an AI coding agent makes -- not just a diff at the end of the session.
receipt proves what one shell
command actually touched, once, when a human runs it with a declared scope.
An agentic coding session is a few hundred tool calls with nobody typing
--declare before each one. custody is a Claude Code hook: it watches
Edit, Write, and Bash calls live, and for each Edit/Write it
checks, from the file's real bytes, that what's on disk is exactly what
the tool says it wrote -- a separate code path from the tool's own
self-report.
$ cat .custody/receipts/toolu_01ABC123....json
{
"providence_version": 1,
"tool": "custody",
"payload": {
"tool_name": "Write",
"declared_scope": ["/repo/auth.py"],
"status": "fail",
"detail": "/repo/auth.py on disk is not what Write reported writing -- a partial write, or something else changed it in between",
"changed": "modified",
"tool_reported_success": true
},
"sha256": "9fab..."
}
A partial write, a silently-swallowed permission error, a formatter or another process racing the agent -- any of them can leave a tool call reporting success while the file holds something else. A transcript review catches this only if someone happens to look. A receipt catches it every time, for every call, automatically.
Install
pip install custody-evidence # the command it installs is `custody-hook`
(custody was already taken on PyPI -- same story as receipt-evidence
and providence-evidence in this portfolio.)
Wire it into Claude Code
Merge hooks/settings.snippet.json into
.claude/settings.json (project) or ~/.claude/settings.json (every
project):
{
"hooks": {
"PreToolUse": [{ "matcher": "Edit|Write|Bash",
"hooks": [{ "type": "command", "command": "custody-hook" }] }],
"PostToolUse": [{ "matcher": "Edit|Write|Bash",
"hooks": [{ "type": "command", "command": "custody-hook" }] }]
}
}
custody-hook has to be on the PATH Claude Code runs hooks with. A
project-local .venv never is, and a hook command that doesn't resolve
fails silently -- no receipts, no error. Either install it with a Python
whose scripts directory is already on PATH (pipx install custody-evidence is the cleanest), or put the absolute path in command,
e.g. "/path/to/venv/bin/custody-hook" (on Windows, quote it:
"\"C:/path/to/venv/Scripts/custody-hook.exe\"").
One binary, dispatched by the hook_event_name field Claude Code already
sends on stdin -- registering it under both events doesn't mean writing two
scripts. Receipts land in .custody/receipts/ under the session's own
cwd; add that directory to .gitignore unless you specifically want to
commit them.
What gets checked, per tool
Edit and Write declare their own scope: tool_input.file_path is
the one thing the call could possibly touch. So the question isn't
containment -- it's whether the file on disk matches what the tool reports
having written. Claude Code's PostToolUse response carries enough to
answer that exactly: Write reports the content it wrote, and Edit
reports the originalFile plus the oldString -> newString replacement.
custody rebuilds the text the tool claims to have left on disk and
compares it to the file's real bytes.
| what's on disk | status |
|---|---|
| exactly the text the tool reported writing | pass |
| anything else | fail -- the tool's claim and the file disagree |
| the file is gone after the call | unverified |
| not UTF-8, so it can't be compared as text | falls back to a before/after sha256: changed = pass, byte-identical = fail |
Two things are deliberately not a difference, because the tool preserves them on disk while reporting normalized text (both measured against real Claude Code, not assumed): a file's CRLF line endings, and a UTF-8 BOM.
PostToolUse only fires after a tool succeeded -- failures are a separate
PostToolUseFailure event -- and real Edit/Write responses have no
success field. So tool_reported_success is true on every Edit/Write
receipt: the event arriving is the success claim being checked.
Bash gets no declared scope at all. A shell command really could
touch anything, but this hook has no mechanism to collect a declaration
from an autonomous agent's command text the way receipt run --declare
collects one a human typed. So a Bash receipt stays at the same honest
floor receipt itself uses with no --declare: unverified, never a
guessed pass or fail -- what changed is still recorded (via receipt's own
snapshot()/diff(), reused directly, not reimplemented), just not
judged. tool_reported_success is null on Bash receipts: Bash's response
is {stdout, stderr, interrupted, isImage}, and whether a non-zero exit
counts as a tool failure hasn't been measured.
Why this depends on receipt
receipt.snapshot.snapshot() and .diff() power the Bash path and are
imported directly -- pip install custody-evidence pulls in
receipt-evidence for exactly this, not duplicated logic under a different
name.
Providence
Every receipt is written as a Providence
single-file bundle (providence_version, payload, sha256 over the
payload's canonical serialization), to SPEC.md's documented recipe.
providence check validates a .custody/receipts/*.json file with no
custody-specific code of its own.
What custody does not do
- Never blocks a tool call. No
permissionDecisionis ever emitted; the hook always exits 0. This is a witness, not a gate. - No declared scope for Bash (see above) -- a stated limit, not a bug.
- Doesn't catch a file changed by something other than the hooked tool
call. A
PostToolUsehook matchingEdit|Writedoesn't fire when aBashcommand or an external process rewrites the same file --custody's Bash hook is what records that case instead, observed but not judged. - Bash snapshots hash the whole working directory, before and after
every command (skipping
.git,node_modules,.venv). Fine for a normal repo; slow on a huge one. - An orphaned
.custody/pending/*.json(a crash betweenPreToolUseandPostToolUse) is harmless and never cleaned up automatically.
Tests
pip install -e .
python tests/test_core.py # pure decision logic
python tests/test_hook.py # real files, real temp dirs, real stdin/stdout,
# tool_response shapes captured from real
# Claude Code PostToolUse events
CI runs both on Linux and Windows.
Status: verified live
Checked end to end in a real Claude Code 2.1 session (claude -p, the
project's .claude/settings.json pointing at the installed hook), on
Windows: a real Write, two real Edits (one on a CRLF file), and a real
Bash call each produced a receipt -- pass, pass, pass and
unverified -- with no hand-built events involved.
Earlier attempts to see the hook fire got no receipts at all. The cause
was the one playhead later pinned
down: the hook command was custody-hook, installed only into a
project-local .venv, which isn't on the PATH Claude Code runs hooks
with -- and a hook command that doesn't resolve fails silently. That's why
"Wire it into Claude Code" above now says so first.
Dogfooding
.claude/settings.json in this repo wires custody onto itself. With
custody-hook on PATH (see above), editing a file in this checkout with
Claude Code writes a real receipt to .custody/receipts/ for that edit.
MIT licensed.
Release files for custody-evidence 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| custody_evidence-0.1.1.tar.gz | 17.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| custody_evidence-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 29.9 kB
Release files / custody_evidence-0.1.1.tar.gz
| Download URL | custody_evidence-0.1.1.tar.gz |
|---|---|
| Size | 17.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
da7938b955d62ee682b70902b9c6a68a3289455ed68a0fe20499088e9da01be0
|
|
BLAKE2b-256 checksum How to use checksums |
f8c9301af73f497dc6b29863681d4b70558efc017e5a84bc51dc6ec38c5c9da1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency logRelease files / custody_evidence-0.1.1-py3-none-any.whl
| Download URL | custody_evidence-0.1.1-py3-none-any.whl |
|---|---|
| Size | 11.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f25534ef1b466202126c741a3f6553e31a2987e0e2e518506e9f2678cf62332a
|
|
BLAKE2b-256 checksum How to use checksums |
07947fc889aa93b93ce3cc1b7e74f5370ef868bfdc1d3d8f364860c87812a786
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency log