Skip to content

Harden the redaction transform; publish the playbook and seat wrapper from live - #2

Merged
OSideMedia merged 15 commits into
mainfrom
fix/audit-2026-09-26
Sep 27, 2026
Merged

OSideMedia merged 15 commits into
mainfrom
fix/audit-2026-09-26

Conversation

@OSideMedia

@OSideMedia OSideMedia commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Summary

Hardens the redaction transform that turns the live commands into this public copy, and ships the published files it now produces. Release target: v1.2.0 (the badge already says so; scripts/codex-seat.sh is a feature, not a patch).

The transform (tests/publicise.sh) can no longer fail open

  • Every rule asserts both of its anchors, and an awk block that opens but never closes is a refusal. A reworded terminator used to cut the published command to 5 of 14 lines with exit 0.
  • Output is staged and emitted only after every rule and the deny-list scan pass, so a refusal publishes nothing.
  • The deny-list loader strips CR and whitespace, skips comments, reads an unterminated last line, and refuses an empty list.
  • An unknown file is refused unless --no-rules is given, and the rule table is checked against tests/pairs.sh at startup.
  • Refusals carry a class (anchor, block, rules, unknown-file, deny-list, deny-hit) that the gate turns into the right advice.
  • New rules replace machine-specific lines in the playbook and the seat wrapper with portable wording.

The gate (tests/check-upstream-sync.sh) reports REFUSED separately from STALE, never advises a refresh after a refusal, and says UNKNOWN (exit 2) when any live source is missing.

One pairs table (tests/pairs.sh) is read by the transform, the gate, the fixer and the selftest.

Release hygiene: tests/check-release.sh checks that every tag's README badge equals the tag.

README: Requirements and Install now sit in the first sixth of the page, and the Install snippet copies codex-seat.sh, which /council calls.

CI (.github/workflows/selftests.yml): the redaction selftest, the seat selftest, the release check, shellcheck, and a step that requires the gate to say UNKNOWN on a runner with no live source.

Evidence

Check Result
sh tests/selftest-publicise.sh (sh and dash) 68 passed, 0 failed (was 19)
Same selftest against main's transform 45 failed
sh tests/check-upstream-sync.sh SYNC CLEAN, 4 files
sh tests/check-release.sh RELEASE CLEAN, 4 tags
bash scripts/codex-seat.sh --selftest every branch proven both ways
shellcheck -S warning clean
Private-token sweep of the tree, the branch diff and every commit message no hits

Review

The Codex seat was unavailable, so the branch went to a fresh Claude reader twice. It found an unterminated deny token that slipped past the loader, three guards no test could turn red, and a gate that gave the wrong advice for some refusals; all are fixed. At the refresh step a private owner name and a private hook name in a newly added live section reached the output while the gate read clean, because no rule and no deny token covered them yet. The output was discarded, the tokens were added to the machine-local deny list, and two rules now replace those lines.

After merge

  1. Tag v1.2.0; tests/check-release.sh then flips its HEAD advisory to ok.

🤖 Generated with Claude Code

OSideMedia and others added 15 commits September 26, 2026 09:44
…ftest

The fixer knew four pairs; the selftest's fixture tree built three and hashed
two. So the "fixer writes nothing on a refusal" test exited at the fixer's
missing-fourth-source check before it ever reached the write it exists to
catch — a fixer planted to bypass the transform entirely passed 19/19.

tests/pairs.sh is now the only list. refresh-from-live.sh and
check-upstream-sync.sh iterate it, make_live materialises every row (and fails
loudly on a row without a fixture), and the before/after digest covers every
published file. The same plant now fails two cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the deny-list, refuse unknown basenames

Fail-open holes, each with a red case in the selftest:

- Block rules required only their OPENING anchor. Rewording the closing line
  ("floor)." to "floor.)") left the awk skip running to end-of-file: exit 0,
  five of fourteen lines emitted, steps 2-4 of the command gone, nothing said,
  and the refresh would have published the gutted file. Every block now
  asserts both ends (grep -E and awk share one ERE, passed via the environment
  so backslashes survive), and the awk exits 1 if it reaches EOF still inside
  the block or never entered it; a non-zero awk is a refusal naming the rule.
- The deny-list loader checked only -r. Empty, comments-only and indented-
  comment-only lists passed everything; a CRLF-saved list gave every token a
  trailing CR and matched nothing; stray whitespace did the same. The list is
  now normalised (CR, surrounding whitespace, comments, blanks) and zero
  surviving tokens is a refusal.
- Rules dispatched on the exact basename, so a renamed live file got none.
  A basename outside tests/pairs.sh is refused; --no-rules is the explicit
  override, and is itself refused on a file the table knows.
- Output is staged through temp files and emitted only after every rule and
  the scan have passed.

All four published files still reproduce byte-for-byte from live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rapper

Five spots in the live files are facts about the author's machine and read as
claims about the reader's: a dated private audit cited as precedent, a plugin
"already wired into the harness", a Stop hook and a config file described as
if they were the reader's, a private billing incident, and the same hook named
in codex-seat.sh's header. Each becomes a block rule with both anchors
asserted and a portable replacement (what a stranger's machine can do).

Where a line carries the private name, the anchor stops short of it — as
judge-precedent already does — so this script never spells what it removes.
The selftest fixture uses invented names (FAKEHOOK, FAKEAPP, FAKELEDGER) that
its deny fixture catches, so a rule that stops firing fails twice: once on the
missing replacement, once on the token that reached the output. Every rule
has a red case for each anchor.

publicise-deny.example gains two placeholder rows for this class of token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`publicise | diff` under POSIX sh has no pipefail: a refusal's exit 3 was
lost and its stderr swallowed, so a REFUSING live file printed
"STALE ... regenerate with refresh-from-live.sh" — the one command a refusal
forbids, since the fixer would publish exactly what the transform refused.
The transform now runs to a file; a non-zero exit is REFUSED with the
transform's reason and the advice to fix the anchor or wording, never to
refresh. Exit 1.

A partial UNKNOWN (one to three live files absent) exited 0 and printed
SYNC CLEAN over the files it did check. Any missing source is now SYNC
UNKNOWN, exit 2, naming the absent files; REFUSED and STALE outrank it.

The selftest now drives the gate against its fixture tree through the copy it
already makes: CLEAN after a refresh over all 4 pairs, REFUSED without a
"regenerate" line and without a duplicate STALE row, STALE on a hand-edited
copy, UNKNOWN exit 2 with one source missing and with none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The badge is hand-edited and the tag is typed; nothing tied them together.
For each v* tag the README at that tag must carry the tag's version (red on a
mismatch or a missing badge); HEAD gets an advisory when its badge runs ahead
of the latest tag, which is the expected state on a release branch. Zero
visible tags is UNKNOWN (exit 2), not a pass. --selftest builds a throwaway
repo and proves all four verdicts.

The README badge moves to 1.2.0: codex-seat.sh shipped as a feature after
v1.1.2, so the next tag is a minor, and it follows the merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eat wrapper

Install sat at line 105 of 162 (65%); it now follows the opening pitch
(line 31 of 186). The snippet gains the two lines a stranger needed and did
not have: `mkdir -p ~/.claude/commands`, and copying scripts/codex-seat.sh
into place with chmod +x — council.md calls that wrapper unconditionally, so
the old snippet produced an empty Codex seat on every run. Requirements name
bash, python3, sh, awk, diff and shasum. "What's in the box" gains rows for
scripts/ and tests/. "Staying current" documents the four gate verdicts,
REFUSED in particular, that UNKNOWN exits 2 whether one or all live sources
are missing, the unknown-basename refusal, and check-release.sh. A workflow
status badge joins the badge row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… gate says UNKNOWN

ubuntu-latest: selftest-publicise.sh, codex-seat.sh --selftest,
check-release.sh --selftest and check-release.sh (fetch-depth 0 so the tags
are there to check), shellcheck -S warning over tests/ and scripts/, and a
final step that runs check-upstream-sync.sh and requires exit 2 — the runner
has no live ~/.claude, and a gate with nothing to check must say so rather
than pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… guards

Regression from the loader rewrite: the scan loop lost main's
`|| [ -n "$token" ]`, and sed preserves a missing final newline, so a list
whose only (or last) token had no trailing newline was COUNTED by the
no-tokens gate and then never read by the scan — exit 0, the file published.
Restored; two red cases (single token without newline, two tokens with the
last unterminated).

Three guards had no case that could go red — removing each left the selftest
at 54/0: the case-insensitive deny match, the block-never-entered refusal
(anchors present in the live file but swallowed by an earlier rule), and the
line rule's grep-found-awk-missed refusal. One case each, proven by removing
the guard: 58/1 every time, the right case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… checked against pairs.sh

The gate printed "Fix the anchor or the live wording" for every non-zero
publicise exit — including a missing or empty deny-list and an unknown
basename, where that advice is wrong and nothing about the live file has been
judged at all. publicise now tags the first stderr line of every refusal with
its class (`publicise: [anchor|block|rules|unknown-file|deny-list|deny-hit]
...`) and check-upstream-sync.sh chooses its advice from that tag. Red: the
selftest's gate case with no deny-list saw "Fix the anchor" against the
previous gate.

The `case` labels were a second list of basenames, untied to tests/pairs.sh:
rename a row's live file and its rules go dead while the file stays
pair_known and passes on the deny-list alone. The rules now live in one
rules() table with an explicit `norules` marker for files published verbatim,
and startup walks pairs.sh in dry mode refusing any row without an arm. Red:
with the startup walk removed, a renamed row exits 0.

Outputs are byte-identical to before the refactor for all four live files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Live prose inserted between a block rule's opening and terminating anchors is
replaced along with the block — no refusal, and it surfaces only as STALE at
the next gate run. Said so in "Staying current", with the refresh diff as the
place to catch it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
rules() is invoked as `rules "$name" || fail`, which suspends `set -e` for
its whole body, so advance()'s unguarded `mv -f "$nxt" "$cur"` could fail,
drop that rule's output, and carry on from the previous $cur as if the rule
had run. It now refuses with class [block] naming the rule.

The gate's anchor|block advice arm had no assertion pinning it: deleting the
arm left the selftest green because the generic fallback also says "do not
refresh". Section 6 now requires the anchor-specific sentence on an anchor
refusal; without the arm, 61/1 on exactly that case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`CDPATH= cd` becomes `CDPATH='' cd` in the five test scripts (SC1007). In
refresh-from-live.sh the write loop's `dst` is assigned by the `eval` on the
line above it, which shellcheck cannot follow; the SC2154 disable says so.
`shellcheck -S warning tests/*.sh scripts/*.sh` now exits 0, which the CI
step requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lines

A new section in the live playbook opened with a rule attributed to a named
person and closed by naming a Stop hook wired into the author's harness. No
rule covered either line and the deny-list had neither word, so the refresh
published both and the gate said CLEAN — the residual hole publicise.sh names
in its header (no anchor, no token). The deny-list now has the words, which
turned the leak into a [deny-hit] refusal; these two line rules make the file
publishable again.

playbook/fallback-owner replaces the attributed opening line with its portable
remainder; playbook/fallback-stop-hook replaces the hook line with what any
reader's hook can do. Each anchor runs from just after the private word to the
end of the line, so it never spells what it removes; the date is matched as a
shape (spelled out, no {4}, for mawk on the CI runner). The paragraph between
the two lines flows through untouched.

Selftest: fixture section with invented FAKEOWNER/FAKEHOOK (both in the deny
fixture), replacements and the untouched middle asserted, and each anchor
moved at its start side and its end side refuses naming the rule. Red before
the rules: 55 passed, 15 failed, led by the same [deny-hit] the real file hit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regenerated with tests/refresh-from-live.sh; check-upstream-sync.sh now
reads SYNC CLEAN for all four files.

The playbook gains an "Availability fallback" section (when a review seat
is down, the review step still runs with the strongest available Claude,
labelled as a fallback), and the agy recipe now matches /council's flags.
The seat wrapper judges an absent codex binary, a missing python3 and a
malformed models cache as UNKNOWN with the reason, reads both streams into
a variable instead of `cat | grep -q` (a SIGPIPE inverted the predicate on
large output), and cleans its selftest temp dir on exit.

Both files pass the redaction rules and the deny-list; the new section's
two machine-specific lines are replaced by portable wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ason

The selftest's "no cache" case now checks that --check says "no models
cache", not only that it exits 2: a missing file also fails the
malformed-cache parse, so the exit code alone passed with the no-cache
branch deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OSideMedia
OSideMedia merged commit 2919e4c into main Sep 27, 2026
2 checks passed
@OSideMedia
OSideMedia deleted the fix/audit-2026-09-26 branch September 27, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant