Skip to content

ADR-053 Implementation Spec — Delivery Plan, Disclosure, and Child Breakdown

Companion to: ADR-053 Status: Proposed 2026-07-10 Tracking issues: #543 (security), #540, #542, #544, #545 Advisory draft: docs/security/advisory-draft-verify-secret-transmission.md


The sequencing constraint

Everything else follows from one fact:

A GitHub Security Advisory published without a fixed version causes Dependabot to alert every downstream user with no safe version to upgrade to.

So the leak fix must merge and release before disclosure — and the leak fix is small, purely narrowing, and independent of ADR-053's design work. That splits delivery cleanly:

P0  leak fix ──────────► release ──────────► P1 disclosure
    (no design decisions)                     (GHSA + CVE + docs)
P2  verify trustworthiness (#544, #545) ────────────┤
P3  ADR-053 design work (content sniffing, receipt, clamp)

P0 is deliberately the smallest change that closes the entire leak surface. It takes no position on allowlist-vs-denylist, coverage receipts, or the clamp. Those are P3 and can take as long as they need.

Why the security fix front-runs the correctness fix

This ordering leaves #542 unfixed for longer: .zig, .tf, and .dart files stay silently dropped from review until P3. That is a deliberate trade — a leak is worse than a gap — but it is a judgment call, and it is the maintainer's to overturn.


P0 — Leak fix (one PR, patch release)

Goal: no credential file can be transmitted, on any code path. Nothing else.

Purely narrowing: it can only cause fewer files to be sent. It cannot turn a correct pass into a wrong one. Ships unflagged.

P0.1 — Chokepoint (ADR-053 Q0, closes #543)

  • Introduce select_blobs(snapshot_id, candidates) -> (selected, omitted), where candidates: Iterable[Candidate], Candidate = (path, origin).
  • Route every candidate-path producer through it: the blob branch, the tree branch, and — the bug — the git diff-tree branch in _fetch_files_for_verification_async_with_metadata().
  • Change _fetch_file_at_commit_async() to accept a SelectedBlob token rather than a str, so an unfiltered fetch is a type error, not a review miss.
  • Do not change the text/garbage predicates in this PR. TEXT_EXTENSIONS stays as-is; it simply now runs on the path where it never ran.

P0.2 — Secret denylist (ADR-053 Q3a, closes #540)

  • Compiled-in, case-insensitive, evaluated before any blob is fetched, and not overridable by any in-repo file.
  • Remove .env, .env.example, .env.sample, .npmrc, .yarnrc from TEXT_EXTENSIONS. Preserve *.example / *.sample / *.template by name pattern, not by a fake extension entry.
  • Full pattern list: ADR-053 § "Q3 — Permissibility".

P0.3 — Argv hygiene

  • Call validate_snapshot_id() at the run_verification() boundary (api.py:760), not only on the Pydantic/HTTP path.
  • (No -- separator work needed yet — P0 adds no pathspec-style git calls. This becomes load-bearing in P3.1.)

P0.4 — Tests

  • Red-team fixture: one commit touching .env, .npmrc, id_rsa, logo.png, yarn.lock, main.py. Assert the assembled prompt contains main.py and none of the others. Parametrised over target_paths=None, target_paths=["<dir>"], target_paths=["<explicit file>"]. The None case is the one that would have caught #543 and does not exist today.
  • Architecture test: no call to _fetch_file_at_commit_async() outside the selector module.
  • Fix the dead GARBAGE_FILENAMES directory entries (node_modules, __pycache__, .git) — match every path component, not just the basename.

P0 Definition of Done

  • [ ] All of P0.1–P0.4 merged
  • [ ] CHANGELOG.md ### Security entry (this convention already exists — 4 prior uses; do not invent a new one)
  • [ ] docs/guides/verify.md + SECURITY.md updated
  • [ ] Patch release tagged and on PyPI — this unblocks P1

Behaviour change to call out in the changelog

On the target_paths=None path, binaries and lockfiles stop appearing in prompts. Some verdicts will move. That is the fix, not a regression.


P1 — Disclosure (gated on P0's release)

The mechanism that actually protects users is the GHSA, not the CVE and not the README. A published GHSA lands in the GitHub Advisory Database in OSV format and auto-alerts every downstream repo via Dependabot. No README notice reaches those people.

Order:

  1. Draft the advisory from docs/security/advisory-draft-verify-secret-transmission.md.
  2. Verify the affected-version range. The draft proposes >= 0.22.0, traced via git log -S, but the #380 submodule split masks earlier history. Confirm v0.20/v0.21 contain no earlier variant.
  3. Maintainer scores CVSS. Do not inflate. Not remotely triggerable; requires secrets already committed to git. An inflated score costs credibility.
  4. Set the fixed version on the draft → request a CVE from GitHub (CNA; ~72h review) → publish.
  5. Ship the doc changes below.

P1 doc changes

Surface Change Durability
SECURITY.md Supported versions; PVR enabled; verify()-is-not-a-gate non-goal; what is sent to providers permanent
CHANGELOG.md ### Security entry under the fix release permanent
GitHub release notes Link the advisory + rotation guidance permanent
docs/guides/verify.md Security note + ADR-053 non-goal permanent
README.md Short dated notice linking the advisory temporary — remove after 2 material releases

Already done (2026-07-10)

  • Private vulnerability reporting enabled on the repository. It was disabled, so SECURITY.md's "click Report a vulnerability" instruction did not work — which is plausibly why #543 had no private channel to take.
  • SECURITY.md rewritten (stale 0.18/0.19 support table; non-goal; provider disclosure).

Process failure to record

SECURITY.md says "Do NOT open a public GitHub issue for security vulnerabilities." #543 was filed publicly anyway. Because the exposure is not remotely triggerable and is self-inflicted by the victim's own verify run, the practical harm is low — but the private-fix window is gone, so publish promptly once P0 ships.

README notice — draft copy

> **Security advisory (2026-07-xx).** Versions `< <FIX_VERSION>` could transmit
> credential files (`.env`, `.npmrc`, …) **that were committed to your git
> repository** to your configured LLM provider during `verify` / `gate` runs.
> Untracked and `.gitignore`d files were never affected. Upgrade to
> `<FIX_VERSION>` and, if you committed credentials and ran a verification over
> a commit touching them, **rotate those credentials**.
> See [GHSA-xxxx-xxxx-xxxx](https://github.com/amiable-dev/llm-council/security/advisories).

CHANGELOG entry — draft copy

### Security

- **verify(): credential files could be transmitted to LLM providers**
  ([GHSA-xxxx-xxxx-xxxx], #543, #540). With `target_paths` omitted — the default
  — no file filter ran at all, so any commit touching a `.env` sent its contents
  to the configured provider, along with binaries and lockfiles. Independently,
  `.env`, `.npmrc`, and `.yarnrc` were on the `TEXT_EXTENSIONS` allowlist.
  Only **committed** content was ever reachable (files are read via
  `git show <sha>:<path>`); untracked and gitignored files were never affected.
  All candidate paths now pass a single non-bypassable selector, and a
  compiled-in, non-overridable secret denylist excludes credential files before
  any blob is read. **If you committed credentials and ran a verification over a
  commit touching them, rotate those credentials.**

P2 — Make verify trustworthy (unblocks using the council on itself)

Do this before leaning on verify to review P3.

  • [#544] Binary-verdict parse failure silently degrades confidence. Cheap, high value. On a clean artifact this converts a pass into unclear(low_confidence) — indistinguishable from genuine uncertainty when the real cause is a parse failure that should route as infra_failure.
  • [#545] ADR-040 waterfall enforces per-model, not per-stage, deadlines. Needs the root-cause pass first (retries vs. serialization) — do not fix blind. Cost of not fixing: paid runs that return no verdict.

P3 — ADR-053 design work (no security urgency)

Ships behind LLM_COUNCIL_FILE_SELECTION = allowlist | shadow | content, default allowlist, byte-identical when off.

  • P3.1 — Q1 content sniffing. NUL-in-first-8000-bytes; .gitattributes -diff/binary via git --attr-source=<sha>; ls-tree size pre-filter. Must pass -- before any pathspec (P0.3's deferred half). Deletes the TEXT_EXTENSIONS treadmill and the extensionless-file special case.
  • P3.2 — Q2 reviewability. linguist-generated / linguist-vendored; .svg → noise-by-default.
  • P3.3 — Ignore-file family. .llmignore.aiexclude.aiignore.cursorignore.codeiumignore, read from the snapshot, matched with pathspec. No seeding — the built-in denylist is the floor, not a template. ignore --print-defaults / --init / --explain as ergonomics.
  • P3.4 — Coverage receipt. Additive, default-ON, no type break. Conservation invariant + TestVerifyResponseFieldDrift (ADR-051 C6 precedent). SHIPPED (#555, PR #572).
  • P3.5 — The clamp + coverage_ack. pass not representable over an unreviewed changed or explicitly-named file → unclear(incomplete_coverage), UNLESS acknowledged. llm-council gate hard-errors on LLM_COUNCIL_COVERAGE_POLICY=warn. coverage_ack shape resolved (ADR-053 Open Question 1); two sub-decisions await the maintainer — see below.
  • P3.6 — Shadow-mode telemetry, then flip content on in a later release.

P3.5 — the clamp, and its coverage_ack escape

The clamp is what turns the #555 receipt's explicit_omitted signal into a verdict. Without an acknowledgement mechanism it is unusable: under the uniform rule any commit touching a .png returns unclear, and a noisy gate gets switched off — worse than no gate. So P3.5 ships the clamp and coverage_ack together; the receipt (P3.4) already shipped the visibility without the verdict risk.

coverage_ack design (ADR-053 Open Question 1, resolved 2026-07-11). Caller-owned and auditable — never a pipeline auto-carve-out, which would reopen the threat-model hole rev 3 closed. Three layers, in build order:

  1. LLM_COUNCIL_COVERAGE_ACK_REASONS — a reason-set the operator pre-accepts (recommended default binary,generated,vendored,too_large,ignored,noise). Ship first: one setting removes the ~90% "binary" noise. The clamp then fires only on non-text (an unlisted language — the #542 bug), not_found, truncated, and denied_secret of a changed file.
  2. .council/coverage-ack — a committed gitignore-syntax glob baseline, read from the snapshot (reproducible, PR-reviewable, drift-visible). Add when the long tail needs it. Reuses the pathspec matcher from P3.3.
  3. coverage_ack=[...] on run_verification — a per-call library primitive.

Every ack is stamped on coverage.acked, so a would-have-clamped-but-acked run is still auditable.

Both sub-decisions settled 2026-07-11 — P3.5 is unblocked: - Default ack set = binary,generated,vendored,too_large,ignored,noise. non-text clamps (it is #542); so do not_found, truncated, and denied_secret of a changed file. - The clamp yields unclear(incomplete_coverage) (not a hard error); the fail policy stays available for callers who want the 422.

Rollout: opt-in. coverage._DEFAULT_POLICY = "warn" — byte-identical default; the clamp fires only when an operator sets clamp/fail. The flip to a clamp default (one-line change) is #557, gated on shadow telemetry.

Layer 1 (LLM_COUNCIL_COVERAGE_ACK_REASONS) ships with the clamp; layers 2/3 (.council/coverage-ack baseline, per-call list) follow when the tail needs them — the ADR's own build order.


Cross-cutting Definition of Done

Per CLAUDE.md: the published docs site is part of DoD, not an afterthought.

  • [ ] mkdocs.yml nav updated for ADR-053 + this spec
  • [ ] docs/guides/verify.md documents coverage fields by name (TestVerifyResponseFieldDrift will red otherwise)
  • [ ] CHANGELOG.md per phase
  • [ ] One release per epic, not per PR (git-tag driven) — exception: P0 gets its own patch release, because P1 cannot proceed without it

Tracking

Issue
Epic #546
P0 #547 selector · #548 denylist · #549 argv/garbage · #550 release
P1 #551 GHSA + CVE + rotation
P2 #544 verdict parse · #545 waterfall
P3 #552 Q1 · #553 Q2 · #554 ignore-family · #555 receipt · #556 clamp (blocked) · #557 shadow + flip
Closed by #543, #540 (P0) · #542 (P3)

Scheduled follow-up

A durable monthly cloud routine is registered to remove the temporary README security notice once two material (minor) releases have shipped past <FIX_VERSION>, and to confirm the advisory is published with a fixed version. Permanent surfaces (SECURITY.md, CHANGELOG.md, release notes, verify.md) stay.

It is designed to do nothing in most months: it stops early if the README notice is already gone, if the fix version cannot be determined, if fewer than two material releases have shipped, or if the advisory is still a draft (in which case it comments on #551 rather than removing anything).