Skip to main content

custody

ci

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 permissionDecision is 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 PostToolUse hook matching Edit|Write doesn't fire when a Bash command 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 between PreToolUse and PostToolUse) 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)

Source distribution for custody-evidence 0.1.1
File Size Uploaded
custody_evidence-0.1.1.tar.gz 17.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for custody-evidence 0.1.1
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

0.2.0

2 release files

This release

0.1.1 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page