[bench] security: stop cross-project ledger leak, retire contaminated chain, add disclosure policy#13
Merged
Conversation
Adds SECURITY.md at the repo root so GitHub surfaces a disclosure route instead of pointing reporters at public issues. The policy is scoped to what a Bench vulnerability actually is: governance bypass, fail-open regressions, ledger forgery that still passes `python -m cli verify`, prompt injection through governed content, constitution snapshot violations, and credential exposure. Model judgment quality is explicitly out of scope; a disagreeable Oracle verdict is a constitution issue, not a CVE. Every runtime claim in the document was verified against code rather than docs: MAX_DIFF_LINES=300 (utils/diff.py:29), the BENCH_SUBPROCESS reentrancy guard as the sole fail-open path (hooks/pre-tool-use.py:290), and pipeline_error VETO on judge failure (pipeline/runner.py). CI hardening in the same pass: - workflow-level `permissions: contents: read` (least privilege; the job only reads the repo) - actions/checkout v4 -> v7, actions/setup-python v5 -> v7 (both major aliases confirmed to exist before pinning) - dependabot.yml covering pip and github-actions Dependabot PRs must not be set to auto-merge: that would route dependency changes around the governance pipeline, which C-007 forbids. The ledger is deliberately NOT included in this commit. It has accrued entries from unrelated projects on this machine and needs a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QFU7kbe53G7BPUZHprb9G
Bench's PreToolUse hook is registered globally in ~/.claude/settings.json, so it governs every project on this machine. But ledger/chain.py pinned the ledger path to the Bench install directory, so every governed edit anywhere appended its full diff to Bench's own ledger. Since that ledger is committed to a public repository, unrelated projects' source was being published: 264 foreign entries already public, 1715 more staged in the working tree. resolve_ledger_path() now routes by the project being governed: BENCH_LEDGER_PATH -> that path (explicit central ledger) cwd inside Bench -> ledger/bench-ledger.json (self-governance, unchanged) anything else -> <project>/.bench/bench-ledger.json Readers move with the writer. load_ledger, verify_chain, and the HTML viewer all default to the same resolver, so the auditor can never inspect a different file than the writer appends to. verify.py keeps its independence where it matters: it still recomputes every hash itself and never calls load_ledger. Agreeing on which file to audit is a precondition for auditing it, not a coupling. ledger-meta.json is written alongside whichever ledger is selected, so each chain carries its own anchor and verifies standalone. cli verify now prints the ledger it read, via _display_path, which collapses to a CWD-relative form so operators do not paste their home directory into bug reports. Tests cover all four resolution branches, Bench-subdirectory resolution, and reader/writer parity. The parity test asserts the resolved target is inside its fixture before calling append_entry: if routing regressed, that append would land in the real append-only chain and could not be removed under C-008. Verified: 2410 entries before and after the run, zero synthetic. Bench governance on this change: every edit was challenged, defended, and ruled on. The Oracle caught a missing Path import in cli/commands.py, an unverified resolve_ledger_path import in utils/viewer.py, the irreversible C-008 hazard in the parity test, and required that the verifier move with the writer rather than landing the write path alone. One edit was fail-closed VETOed when the Defender stage errored, and was retried. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QFU7kbe53G7BPUZHprb9G
…tirement Two changes, both driven by the cross-project contamination fix. 1. Out-of-project diff bodies are stripped before hashing. A globally registered hook governs files belonging to other projects. Those changes are still adjudicated in full, but the recorded evidence now keeps only path, tool and verdict: content, old_string, new_string, edits and formatted_diff are dropped, and the entry is marked redacted. A published ledger therefore cannot become a mirror of another project's source. Classification and ledger routing now share one anchor, _project_root(). They previously disagreed: routing treated any cwd under the Bench repo as self-governance, while classification compared against the raw cwd. Editing utils/api.py while sitting in tests/ would have been judged external and stripped evidence out of Bench's own record. The Oracle caught this. Residual, stated plainly: challenger/defender/oracle prose may still quote a few lines of the change it reasons about. This removes the systematic copy of file bodies, not every possible quotation. 2. C-008 gains a bounded chain-retirement clause (constitution v2). Bench vetoed its own chain reset under C-008, correctly: removing a ledger is deletion, and the rule admitted no exceptions. But the alternatives it proposed (keep the contaminated chain in-repo, or append to it) both leave 1,715 foreign diffs in a public repository. C-008 and the privacy requirement genuinely conflicted, so the law was amended rather than circumvented. Retirement is now permitted, with a single trigger (the chain contains content that must not be published) and four mandatory elements: a human decision that is never agent-initiated, an archive that passes verify_chain BEFORE the original moves, an anchor entry recording the retired chain's tip hash, genesis hash, entry count, timestamps, verbatim archive path and reason, and indefinite retention. Editing, reordering or removing an individual entry remains a violation with no exception. A first draft of this amendment was itself vetoed under C-007 for being open-ended and unenforceable. The version here names the authority, defines the verification step, and gives auditors a concrete check. ANCHOR verdicts are plumbed through stats and the CLI so retirement markers are counted separately from adjudicated changes and cannot skew the pass rate. Dry-run confirmed verify, stats and the HTML viewer all handle the new entry shape without erroring. Tests: 126 pass across chain, stats, commands, verify, viewer, constitution. Includes coverage for every resolution branch, external classification from a subdirectory, redaction of bodies, and anchor accounting. Note: tests/test_hook.py:371 has a pre-existing F841 (unused original_cwd), untouched here since it is unrelated to this change. Ruff is not in CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QFU7kbe53G7BPUZHprb9G
The chain that ran from 2026-04-22 to 2026-07-24 is retired. It accumulated governance receipts from unrelated projects on this machine, because a globally registered hook wrote every project's diffs into Bench's own ledger. Publishing it would disclose third-party source. Retirement was performed under C-008 as amended in constitution version 2, and was initiated by the repository owner at an interactive prompt. The script refused two non-interactive invocations before that, which is C-008(a) working as written. retired entries : 2471 (2026-04-22 to 2026-07-24) tip hash : 2176516fec72f66da131096438cd318d8453409f87dec30107a312beef7a4c8d archive : C:\Users\mstar\.bench-archive\bench-ledger-2026-07-24.json anchor entry : 7a06ce4f-aa12-4129-ada3-d200ece60cdb No entry was edited, reordered, or removed. The chain was archived intact, the archive was verified with verify_chain BEFORE the original was moved, and the successor's anchor entry records the predecessor's tip hash, genesis hash, entry count, timestamps, verbatim archive path, and reason. The auditor check C-008 prescribes was run against the result: the archive verifies valid at 2471 entries, and its tip hash, genesis hash, and entry count all match the values recorded in the anchor. Retirement is auditable, not asserted. tests/test_ledger_hygiene.py is the regression guard. It asserts no ledger entry carries a diff body under a non-relative path. Verified non-vacuous: run against the retired chain it reports 349 violations, so it would have caught this contamination in April. The guard is deliberately stricter than the runtime redactor. It flags any absolute path, including one pointing inside the repo, because that means either foreign source or a path-normalization regression. The retired chain contained both kinds. Absoluteness is matched by regex rather than os.path.isabs, since CI runs on Linux where isabs of a Windows path is False and a Windows-shaped path is exactly what the guard exists to catch. This commit resumes the practice of shipping the ledger as source. The successor chain is 7 entries: the anchor plus the governed writes that produced this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QFU7kbe53G7BPUZHprb9G
dburks-svg
added a commit
that referenced
this pull request
Jul 24, 2026
Ten receipts generated after the final commit on the PR branch: scratchpad and memory writes during session wrap-up. All ten are out-of-project files, so every one is recorded with path and verdict but a redacted body. This is the redaction landed in PR #13 working in production, not a special case. Chain valid at 19 entries from the successor genesis 4e98fb41. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QFU7kbe53G7BPUZHprb9G
This was referenced Jul 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Started as "add a SECURITY.md." Surfaced a live leak: this public repo was
publishing source code from unrelated private projects. Four commits: stop the
leak, minimize what the ledger records, retire the contaminated chain under a
bounded constitutional amendment, and add a regression guard.
1. The leak
Bench's hook is registered globally in
~/.claude/settings.json, so it governsevery project on the machine. But
ledger/chain.pypinned the ledger to theBench install directory, so every governed edit anywhere appended its full
diff into this public repo's chain.
Exposed: another project's
src/, an Electron app, a Next.js billing app,.claude/plans/, project memory notes, the globalsettings.json. Scanned for12 credential classes; every hit was fixture data, so nothing needs rotating.
Fix:
resolve_ledger_path()routes by the project being governed.BENCH_LEDGER_PATHwins; else a cwd inside the Bench repo uses Bench's ownledger; else
<project>/.bench/bench-ledger.json.load_ledger,verify_chain, and the viewer all default to the same resolver, so the auditorcan never inspect a different file than the writer appends to.
2. Redaction of out-of-project bodies
Routing fixes other projects' sessions. A file edited from a Bench session
still lands here, so out-of-project diff bodies are now stripped before hashing:
content,old_string,new_string,edits,formatted_diffare dropped andthe entry is marked
redacted. Path, tool, and verdict survive, so the audittrail still shows the file was governed and how it was ruled on.
Classification and routing share one
_project_root()anchor. They previouslydisagreed: editing
utils/api.pywhile sitting intests/would have beenjudged external and stripped evidence from Bench's own record.
Residual, stated plainly: judge prose may still quote a few lines of the change
it reasons about. This removes the systematic copy of file bodies, not every
possible quotation.
3. C-008 amended, chain retired (constitution v2)
Bench vetoed its own chain reset under C-008, correctly: removing a ledger
is deletion. Its proposed alternatives (keep the contaminated chain in-repo, or
append to it) both leave 1,715 foreign diffs in a public repo. C-008 and the
privacy requirement genuinely conflicted, so the law was amended rather than
circumvented.
Retirement is now permitted with one trigger (the chain contains content that
must not be published) and four mandatory elements: a human decision that is
never agent-initiated, an archive passing
verify_chainbefore the originalmoves, an anchor recording tip hash, genesis hash, entry count, timestamps,
verbatim archive path and reason, and indefinite retention. Editing, reordering,
or removing an individual entry remains forbidden with no exception.
A first draft of the amendment was itself vetoed under C-007 for being
open-ended and unenforceable. The landed version names the authority, defines
the verification step, and gives auditors a concrete check.
The retirement then ran:
The auditor check C-008 prescribes was run against the result: the archive
verifies valid at 2471 entries, and its tip hash, genesis hash, and entry count
all match the anchor. Auditable, not asserted.
ANCHORverdicts are plumbed through stats and the CLI so retirement markersnever skew the pass rate. Dry-run confirmed verify, stats, and the HTML viewer
handle the new entry shape without erroring.
4. Regression guard
tests/test_ledger_hygiene.pyasserts no ledger entry carries a diff body undera non-relative path. Verified non-vacuous: run against the retired chain it
reports 349 violations, so it would have caught this in April.
Deliberately stricter than the runtime redactor: it flags any absolute path,
including one pointing inside the repo, since that means either foreign source
or a path-normalization regression. Absoluteness is matched by regex, not
os.path.isabs, because CI runs on Linux whereisabs(r"C:\Users\...")isFalse and a Windows-shaped path is exactly what the guard exists to catch.
5. SECURITY.md and CI hardening
Policy written against Bench's real threat model. In scope: governance
bypass, fail-open regressions, ledger forgery that still passes
cli verify,prompt injection through governed content, credential exposure. Out of scope:
model judgment quality (a disagreeable Oracle verdict is a constitution issue,
not a CVE). States plainly that the ledger is tamper-evident, not tamper-proof.
permissions: contents: readat workflow levelactions/checkoutv4 to v7,actions/setup-pythonv5 to v7 (aliases verifiedvia the refs API; CI green on 3.11, 3.12, 3.13)
dependabot.ymlfor pip and github-actions; alerts and security fixes enabledDo not enable auto-merge on Dependabot PRs. That routes dependency changes
around the pipeline, which C-007 forbids.
Governance record
Bench governed every change here and rejected four of them:
Anyannotation. Complied.Rewritten with a named authority and an auditor-executable check.
a narrative string authored by an agent. The gate is now structural, and it
refused two non-interactive invocations from me before the owner ran it.
Plus one fail-closed VETO when a Defender stage errored, and Oracle catches
on a missing
Pathimport, an unverifiedresolve_ledger_pathimport, and anirreversible C-008 hazard in a test that would have written synthetic entries
into the real chain.
Testing also caught what review did not: the first
isatty()human gate silentlyfailed under Git Bash, where msys reports a TTY even with stdin redirected. The
EOFErrorpath is what actually holds.Not fixed
tests/test_hook.py:371has a pre-existing F841 (unusedoriginal_cwd), leftalone as unrelated. Ruff is not in CI.
Already-public history is unchanged by deliberate decision: 56 unique cloners
pulled this repo in the preceding 14 days, so a rewrite cannot un-distribute the
264 foreign entries, and severing the chain would cost more than it recovers.