Skip to content

Releases: skymanbp/cc-enforcer

v0.42.0 — the Bash guard sees past command wrappers

Choose a tag to compare

@skymanbp skymanbp released this 02 Oct 20:40

Bash guard: a wrapper no longer hides the command

Every built-in Bash check read argv[0], so sudo chmod 777 x,
sudo rm -rf /, sudo git push -f, nohup git push -f,
GIT_TRACE=1 git push --force and sudo bash -c "git push -f" were all
allowed (only the any-position flags such as --no-verify were caught).
lib/shellcmd.effective_argv now resolves the argv a segment really runs —
past VAR=value prefixes and the sudo / doas / env / nice /
nohup / time / command / exec wrappers, with their value-taking
options — and every deny check and the nested-shell recursion read it. A
strictness increase: each case above is now denied, and each has an allow
twin in the matrix (sudo git push origin main, sudo rm -rf /tmp/x,
sudo echo git push -f, command -v git). The register-as-read hatch is
unchanged: it still demands the bare invocation.

Edict deny names the file the edict came from

The deny text said "a project-level edict defined in
.claude/cc-enforcer/edicts.toml" whatever file had loaded, including the
personal global one. It now names the resolved path. 773 → 779 tests.

Documentation: a full fact-check against the v0.41.0 code

Every reference surface (both READMEs, docs/, rules/ + zh/,
prompts/ + zh/, commands/, the skills, the test and demo indexes, the
CI and sync-gate comments) was re-checked claim by claim against the code.
The contract-level corrections:

  • The reply schema's own re-trigger example did not count as layer-(a)
    evidence.
    re-trigger: "$ <cmd> → …" puts the $ inside a quoted
    value, and layer (a) accepts a $ only at the start of a line. The
    template is now a block scalar (re-trigger: | then $ <cmd> on its own
    line), which does count; rule 06 and the per-turn reminder now name the
    real evidence shapes, including that a fence with a language tag is not
    one by itself.
  • Global edicts are a fallback, not a layer. EDICTS.md, the edict
    command and both READMEs said or implied that project and global edicts
    merge; the loader reads exactly one file, and a project edicts.toml
    shadows the global one entirely.
  • Layer (i) is not pre-empted by a sync marker. Several surfaces read as
    if a sync-check: line avoided the block; a first violation always
    blocks, and the marker settles only groups a previous block named.
    Rule 12 and the checklist also now say that the reply schema's yaml
    fence is where a marker does count.
  • The status table lists rows (a)→(i); only the verdicts follow
    evaluation order. ARCHITECTURE and both READMEs said the rows did.
  • The zh layer-(b) plain-words line named 应该 / 可能 as hedges, which
    the hook deliberately does not match; it now matches the English line.
  • The demo's Stop call now uses the production payload
    (last_assistant_message, no turn_count); the images are unchanged.
  • README §6 is re-measured on v0.41.0 with all six scenarios. The old
    tables predated the thin-shell split and labelled a non-edit Stop as "all
    nine layers"; the before/after table was release history and is left to
    v0.40.0.

Smaller drift fixed in the same pass: the rm -rf target set, the prose
exemption list, state-file quarantine, the maintenance passes running before
the emit, §8 rows naming entries for code that lives in the _impl.py
bodies, the message-catalog checks missing from I18N.md and the i18n
command, and history narration moved out of reference docs.
test_doc_sync now also derives the Chinese README's coverage-bar sample.


Full detail: CHANGELOG.md

v0.41.0 — the three recorded decisions, made

Choose a tag to compare

@skymanbp skymanbp released this 24 Sep 20:44

The three decisions v0.40.0 recorded, made. That entry closed with three
"decisions recorded, not made" — the Chinese evidence words, the entry
scripts that re-compile on every call, the duplicated [0.11.0] heading —
and the maintainer's ruling was to make all three rather than carry them.
One of them changes what a hook accepts; it is stated as a contract change
below, not as a bug fix. The CI runtime deprecation is closed in the same
release. One commit for the work, one for the release.

Stop layer (a): vocabulary is not evidence, in either language

EVIDENCE_PATTERNS held, besides the output shapes, four Chinese rule-06
check names (重触发 / 边界用例 / 反向用例 / 收敛) and three English verbs
(verified / re-ran / validated). Each half let a reply clear the "show
your work" layer by naming the work: a bare 收敛: schema key passed (a) in
Chinese while convergence: did not — the asymmetry v0.40.0 recorded — and,
in the direction nobody had recorded, Fixed, verified. passed in English
while 已修复,已验证。 did not. The v0.40.0 note proposed dropping the four
Chinese words; that would have flipped the asymmetry, not removed it. Both
halves are gone. What remains is output-shaped only — a shell-prompt line, a
test count, a runner's name, a fenced block — and it is the same set for
both languages. The check names still count as convergence markers for layer
(c), where naming the check is exactly what is asked.

This is a strictness increase for both languages: a reply that says it
verified and shows nothing is now blocked at (a) whatever language it is in.
Both recovery texts stop offering the words as a way through. The one test
that asserted the old allow (完成了。重触发原症状后异常消失。 with no output)
is now the block case, in both languages, with the twin that adds one output
line and passes — the words were removed from the evidence set only. A note
for the next person who writes such a fixture: rule 06 passed is output-
shaped, because 06 passed matches the test-count pattern; the English case
had to avoid a digit before the verb.

The four hook entries are thin shells over cached modules

CPython caches the bytecode of every module it imports under __pycache__
and re-uses it while the source is unchanged; the script it was asked to
run is compiled from source on every start and never cached. Every hook
entry was its whole body, so every invocation paid the compile — on the
maintainer's machine, p50 of fifty in-process compiles: inject_context.py
1.4 ms, read_guard.py 3.2 ms, bash_guard.py 2.2 ms, stop_guard.py
6.8 ms, for hooks whose own work is often less than that. The v0.40 sweep
had trimmed what the entries import and left this, the one cost on the path
that the entries could not avoid by importing less.

Each body now lives in <entry>_impl.py, unchanged apart from a two-line
docstring note and the removal of its __main__ block (one entry per hook,
not two); each entry is a 43-line shell that puts its own directory on
sys.path, imports the body and calls its main(). The entries keep their
names, so hooks.json, the tests, the demo, the benchmark and the docs
address the same files as before. The shells compile in 0.09 ms. Measured
under identical load, interleaved, eleven runs each, the median self-time of
the body's import (-X importtime) from source versus from bytecode:
inject_context_impl 3.5 → 1.6 ms, read_guard_impl 7.6 → 3.5 ms,
bash_guard_impl 4.2 → 1.5 ms, stop_guard_impl 10.9 → 2.2 ms. The
wall-clock table in README §6 is not re-quoted: the machine was under an
unrelated build during this release and the absolute figures would not have
been comparable.

Two design points, each pinned. Imported rather than run, a shell hands the
caller the body module itself (a sys.modules swap) and also carries the
body's names: inject_context.PLUGIN_ROOT = … in test_inject_context.py
must reach the code that runs, and a copied namespace alone would have let
that patch land on a copy while the test stayed green. And the shell
bootstraps sys.path itself: the first draft did not, and test_doc_sync,
which loads bash_guard.py by file path from a process that never put
hooks/scripts on its path, was the gate that failed. tests/ test_startup_cost.py now pins all of it: an entry defines nothing and
imports its body; after one run of the entry the body's .pyc exists and
the entry's never does, while the body run as a script is not cached either
(the mechanism the split exists for, with its twin); importing an entry
yields the body; loading an entry by path from a bare -I interpreter still
finds it. The guards-readme sync-gate group's glob widens to
hooks/scripts/*_guard*.py, since the behaviour it guards now lives in the
bodies.

CHANGELOG: the roadmap block that duplicated the 0.11.0 heading

Two ## [0.11.0] — 2026-05-19 headings: the first introduced a roadmap block
("future-version candidate", "currently held back") written at that release,
the second the release itself. test_version_sync._changelog_entry("0.11.0")
returned the roadmap. The first is retitled ## Roadmap as recorded at 0.11.0 — 2026-05-19; its body is byte-identical (a dated record is not
rewritten — the v0.33.0 ruling). The [Unreleased] compare link at the foot
of this file had pointed at v0.11.0...HEAD since that release; it now
points at the current tag.

CI: actions on the Node 24 runtime

actions/checkout@v4 → @v7 and actions/setup-python@v5 → @v7. Every
run had carried GitHub's Node 20 deprecation warning for the two actions;
the v7 lines run on Node 24. No change to what the workflow does.

768 → 773 tests.

v0.40.0 — the injection, the start-up path, the guard messages, the docs and the rules pack, slimmed

Choose a tag to compare

@skymanbp skymanbp released this 24 Sep 19:30

The injection, the start-up path, the guard messages, the documentation and
the rules pack, slimmed and put in order.
Five concerns after v0.39.2, one
commit each, from a cloud session; a sixth, found the moment the branch ran on
the maintainer's own Windows machine, is recorded below (b07430d). No hook's
enforcement semantics change: what is denied, blocked, forgiven or recorded is
byte-identical, and every rewrite below is pinned by a test that says so.

Prompt injection: a contract and a reminder, not two contracts

prompts/user-prompt.md was 7,075 characters — larger than the SessionStart
contract it was meant to remind of, two thirds of it restating that contract
verbatim — and it was re-sent on every prompt, so a fifty-turn session carried
fifty copies of it until compaction. The premise that justified the copy
("re-inject every turn so the rules survive compaction") had been overtaken:
SessionStart fires again after every compaction (source: compact) and
hooks.json registers it without a matcher, so the contract already came back.

The per-turn prompt is now a 2.6k-character reminder (1.5k in Chinese) that
names every hard gate, every Stop layer, the reply-schema field names, the tldr
cap and the sync-check semantics, and nothing else. What left it was
duplication, eleven version tags, and six "consequence" rows describing blocks
the hooks never issue. tests/test_inject_context.py now derives the tokens the
reminder must carry from the guards themselves (STATIC_PATTERNS slugs,
PATCH_MARKERS labels, _LAYER_IDS, TLDR_MAX_ITEM_COLUMNS, the rolling
threshold, the schema fields, every advertised hedge against _HEDGE_INNER) and
caps its size, so it can neither lose a gate nor grow back into the contract.

session-start.md is the single authority: its §4 no longer links a
CLAUDE.md this repository stopped tracking in v0.38.2 (and the test waivers
that covered the link are gone), the "9-layer" row names the layers (a)–(f) it
lists, the operator-only CC_ENFORCER_DISABLE_LAYER_G switch is no longer shown
to the agent, %USERPROFILE% joins the rule-11 row in English, and the
sync-gate row says what actually settles a group. The per-turn edict table
drops the intro and footer sentences the contract already carried
(render_injection(chrome=False)), and the realistic-install budget test now
covers all four prompts, not two.

Runtime: every hook loads only what its common path uses

Measured on Linux / Python 3.11 before the change, a bare interpreter cost
13 ms and every hook 63–81 ms. The difference was modules imported at start-up
for work the common path never does, plus stop_guard compiling ~115 regexes
up front — 31 of the 33 ms its import took, on a path that mostly returns after
the done-claim check.

scenario own share before after
UserPromptSubmit (every prompt) ≈ +50 ms +19 ms
PreToolUse(Read) +53 ms +27 ms
PreToolUse(Bash) +50 ms +21 ms
Stop (all nine layers) +68 ms +33 ms
  • inject_context: argparse (and the shutil / bz2 / lzma behind it)
    replaced by a five-line --event parser with the same exit-2 contract;
    lib.state, lib.envfile and gc_state imported inside the SessionStart
    maintenance branch; the payload parsed only when auto-GC can use the session
    id; the from . import gc_state branch that could never succeed removed.
  • lib/tomlio imports tomllib on first parse; edicts and sync_gate drop
    their own sentinels and ask tomlio.available() only once a config file
    exists, so a project without one never loads the parser.
  • Edict / EdictHit and Group / Violation are __slots__ classes
    instead of frozen dataclasses (dataclasses → inspect / ast / dis,
    ~10 ms per hook); mdctx.LineCtx is a collections.namedtuple.
  • traceback is imported inside the failing-open handlers; bash_guard
    imports hashlib and lib.state on the registration path only;
    stop_guard imports lib.sync_gate when layer (i) runs.
  • stop_guard compiles each pattern the first time a layer uses it
    (_LazyPattern); a Stop with no done-claim compiles the done set and nothing
    else, and the lazy object answers exactly what re.compile would.
  • stop_guard reads last_assistant_message — the field Claude Code's Stop
    payload documents — before the legacy assistant_message and the transcript
    fallback. Production had been taking the fallback on every Stop because the
    documented field was never read; the fallback's first window is now 256 KiB
    (×16 growth) instead of 4 MiB.
  • bash_guard tokenises a command once; state.record_edit_turn saves only
    when the flag or turn changes; lib/lang.py is the one definition of the
    active language (three copies before); projroot owns PLUGIN_NAME and the
    .claude/cc-enforcer/<file> layout; state owns AUTO_GC_MARKER.
  • bench_hooks.py gains the two injection scenarios, measures an allowed
    systematic Edit and a Stop on an edit turn (all nine layers live — the old
    Edit row measured the deny path and the old Stop row a non-edit turn), and
    aborts on a non-zero exit or unexpected output.

tests/test_startup_cost.py pins the property rather than the milliseconds.

Guard messages say what the hooks do

Three deny / block texts told the agent something the hooks do not do: the
block footer promised a one-shot grace ("the next Stop is allowed even if this
layer still fails") that v0.29 had replaced with per-layer grace; recovery (a)
and (c) offered re-trigger / boundary case / negative case as passing
markers, none of which is in the English evidence or convergence sets; and the
rolling-patch recovery advised padding an edit to ten lines to clear the
small-edit bar — the accretion the gate exists to catch. Each now names what
passes. The six longest messages are cut to headline, evidence and recovery;
version tags inside them are gone; the force-push deny text joins the catalog
so it follows CC_ENFORCER_LANG. Chinese catalog mirrored key for key; the demo
image re-rendered.

Design conflicts closed

i18n_check, edicts and inject_context no longer cite the untracked
CLAUDE.md; sync-gate.toml and docs/EDICTS.md point at sections that
exist; the Python floor is stated once as 3.11 (tomllib) with CI on 3.13;
both manifest descriptions are a 923-character product description instead of a
5.4k-character rolling changelog; the three _PLUGIN_NAME / tomllib sentinel
/ _resolved_lang copies are one definition each.

Documentation reorganised

docs/ARCHITECTURE.md (80k characters, 158 version references, every
subsection nested under "The injection budget", the Stop guard ordered by
release) is rewritten by component at ~41k: one subsection per hook, the Stop
decision table in evaluation order with the markers grouped by layer, per-layer
grace described for the first time, the shared modules, session state and the
configuration files each in one place, the data flow including Stop, and a
sorted, deduplicated connected-files map. README.md drops its release
narrative and its duplicated principles; Contributing and the release checklist
move to docs/CONTRIBUTING.md; docs/RULES.md is a catalog again;
docs/README.md no longer claims the documents never repeat each other;
tests/README.md is an inventory with live counts. History lives in this file.

The rules pack states the discipline; one line per rule states the hook

rules/ is presented as a portable, LLM-agnostic discipline pack, and a fifth
of it was hook implementation: rule 09 alone carried a 10k-character "Physical
interception" section — the rolling-patch classifier table, the patch-marker
regex catalogue, the detector-fix history from v0.25 to v0.26 — and rules 03,
08, 10, 11 and 12 carried their own hook tables and 55 version notes between
them. Every rule now opens with one Enforced by line under its title, in
both languages, that says which hook intercepts it and on what (rule 09's names
the seven Bash tokens; the doc gate now pins rule 03's line to the same
inventory), and the hook sections and version notes are gone. Facts corrected
on the way: rule 08 no longer counts global among layer (e)'s keywords or
describes the trigger as a turn-count comparison; rule 09's "1–3 lines" is the
hook's ≤ 10-line bound; rule 10's "10+ chars" is the detector's 8; rule 11
names CLAUDE_PLUGIN_DATA (not CC_PLUGIN_DATA) and no longer quotes a
maintainer's home path; rule 12 describes per-layer grace instead of the
retired one-shot guard; "minimum effective change" is defined once, in rule 07
check 4, where six sites already pointed. The English pack goes from 87.9k to
78.5k characters (rule 09: 21.5k → 13.8k) and the Chinese mirror from 42.3k to
37.1k, with identical heading sequences and the same enforcement tokens.
commands/checklist.md (9.4k → 6.7k characters) keeps its eight sections, the closed
patch-marker set, the output format and the items with no rule-text
counterpart, and stops restating the rules and the prompts; its description
now counts eight sections, and its example reads "layer (a)–(i)".

Two test premises that held everywhere except on the machine the plugin was written on

Running this branch on the maintainer's Windows machine gave five failures on
a suite that was green in the cloud and would have been green on CI. Both
causes are a test taking ambient machine state for an empty room — the
v0.38.3 class (a test that encoded the maintainer's directory layout),
pointing the other way. Neither is a hook defect; both are fixed in the
test, by changing the gate's mechanism rather than adding the offending name
to a list.

  • tests/test_doc_sync.py enumerated "the repository's documents" by walking
    the working tree, with ...
Read more

v0.39.2 — the Stop hook reads the transcript's tail

Choose a tag to compare

@skymanbp skymanbp released this 15 Sep 20:43

The Stop hook reads the transcript's tail, not the whole file. One commit
(83958ea) after v0.39.1, for a hook whose whole job is to run unnoticed
and had started costing the machine 12.7 GB per turn.

The Stop hook reads the transcript's tail, not the whole file

stop_guard._last_assistant_message_from_transcript used read_text() +
splitlines() on the session JSONL to find the last text-bearing assistant
entry. On 2026-09-15 a 2.5 GB transcript turned every Stop into a 12.7 GB
process for six seconds (available RAM 19.9 GB → 1.7 GB) and Windows paged
out the user's other services — a 90 s backend stall per turn. The reader now
walks the file backwards in growing byte windows (4 MiB, ×4, capped at
64 MiB) cut to whole lines and returns the first text-bearing assistant entry
it meets: the same answer as before at a peak cost of a few windows —
measured on the 2.5 GB file itself, 8.3 MB traced peak in 0.02 s. Three
tests pin it (a 60 MB transcript costs under four windows, a line longer than
the window grows it, 256-byte windows agree with a whole-file scan).
tests/README.md and docs/ARCHITECTURE.md describe the fallback as a tail
read. 746 → 749 tests.

v0.39.1 — the version gate reads every released heading

Choose a tag to compare

@skymanbp skymanbp released this 03 Sep 06:56

A documentation release with one gate behind it, and the gate's first
finding closed.
Five commits landed on main after v0.39.0. The one that
matters (80fbc0d) extends the version gate from the newest CHANGELOG
heading to every released heading, and the first thing it named was this
repository's own 0.38.2 — an entry and a release: commit with no tag
underneath them.

The version gate reads every released heading, not just the newest

Pinning released[0] against plugin.json answers the bump-vs-entry question
and only that one: every released heading under it is unchecked, so a release
that gets its CHANGELOG entry and its release: commit while the tag is never
cut sits in the file with every gate green.

tests/test_version_sync.py compares the whole released set against git tag.
It named 0.38.2: a heading, a release: commit (b5eb47e) and a paragraph
of the plugin description, while git tag -l v0.38.2 was empty and
gh release view v0.38.2 answered "release not found". That is the v0.22.1
family the release checklist exists for, one step earlier — the entry claims a
version shipped and there is nothing at that point in history to install.
Closing it was a release-time action rather than a code change: v0.38.2 is
now an annotated tag on b5eb47e carrying a GitHub release built from its own
entry, so v0.38.1 → v0.38.2 → v0.38.3 is a complete sequence and the gate is
green on the fact rather than on a waiver.

Two versions are excused, and only because their own entries say so. 0.8.0
was rolled into the v0.9.0 commit. 0.25.1 was released inside the v0.26.0
commit 3486c2c — the same commit wrote its heading, and plugin.json went
0.25.0 → 0.26.0 without ever carrying 0.25.1. Each entry states in public
that no separate tag or release exists, UNTAGGED_BY_RECORD registers them,
and a companion test re-derives that declaration from the entry and fails the
day the tag appears — the registry cannot excuse a version the CHANGELOG has
not admitted where readers see it.

CI checks out with fetch-depth: 0, because the default depth-1 checkout
brings no tags and the gate would have nothing to read. The suite reads that
line out of .github/workflows/*.yml: _git_tags answers None on a shallow
clone and the tag check skips itself, which is right for a downloaded tarball
and would otherwise let a depth-1 CI run stay green over a gate that inspected
nothing. The coupling is registered as the ci-workflow sync-gate group and in
docs/ARCHITECTURE.md §8. 742 -> 746 tests.

Documentation aligned with the code

Both plugin descriptions — the one Claude Code shows at install time
(.claude-plugin/plugin.json) and the marketplace listing
(.claude-plugin/marketplace.json) — read "twelve rules, ten backed by a
hook", which is what the README's Enforcement column has said since rules 02
and 05 became text-level discipline graded by the Stop gate.

Both READMEs' "the repo is held to its own rules" section counts four CI
drift gates and lists the demo gate next to the version, doc and i18n ones,
matching the tests tree, the coverage sentence and tests/README.md.

Also in this release

  • 0701dc6 — full-repo documentation sweep after the v0.38.x / v0.39.0 chain
    (CHANGELOG.md, docs/ARCHITECTURE.md, tests/README.md).
  • 5e4cdb2 — .gitignore covers .ce/ and .ccm/: the cc-enforcer index and
    cc-memory state are machine-local, never content.
  • 643acd5 — README, commands/checklist.md, docs/ARCHITECTURE.md and
    docs/RULES.md aligned with the code after the 2026-09-02 audit.
  • 0d6fce3 — CI's fetch-depth: 0 pinned by the suite, which reads the line
    back out of .github/workflows/*.yml so a depth-1 checkout cannot leave the
    tag check skipping itself while the run stays green (ci-workflow sync-gate
    group, docs/ARCHITECTURE.md §8); and 0.25.1's entry now states that no
    separate tag exists, which is what UNTAGGED_BY_RECORD rests on.

Tests — 742 → 746

v0.38.2 — CLAUDE.md leaves the repository

Choose a tag to compare

@skymanbp skymanbp released this 03 Sep 06:17

CLAUDE.md leaves the repository. It is the project instruction file
Claude Code loads — written for an agent, not for a reader — and its metadata
section carries the maintainer's mail address and machine paths. Claude Code
reads it from the working tree regardless of git, so untracking costs a local
session nothing; a clone simply does not get one.

Removing a file is the small half. It was load-bearing in fourteen places.

What it was holding up

  • Four registered claims in test_doc_sync (rule count, command count,
    test count ×2), plus the module-inventory tree, the Bash-deny surface list,
    and the current-release narrative scan.
  • Six published links that would have 404'd on the front page — two in
    docs/ARCHITECTURE.md, one in docs/README.md, one in tests/README.md,
    and one in each README's Contributing section.
  • Five links in CHANGELOG.md, which is a dated record.
  • The release checklist, which both READMEs told contributors to go read
    there and which existed nowhere else.

One rule, not fourteen conditionals

The first attempt made each registry tolerate the file's absence. That produced
a suite behaving one way locally and another on CI — the trap test_hookio
names from the other side ("a gate that can only fail on one laptop is not a
gate"). Replaced with a single rule: the doc gates scan the tracked
repository.
UNTRACKED_LOCAL_DOCS is consulted by every scanner, by the
claims loop, by the surface lists, and by link resolution — so a link to
CLAUDE.md fails here exactly as it would in a clone, even though the file is
sitting on this disk. Verified by running the gate in both states: identical.

The cost is stated where the constant is defined rather than left implied:
CLAUDE.md's own numbers are no longer pinned by anything. It is a working note
now, and everything it asserted is asserted by README.md and README.zh.md
too, which are still gated on every push.

What moved rather than vanished

The release checklist is now in both READMEs' Contributing sections, in
their own language — a contributor already there needs it, and it was the only
part of CLAUDE.md written for anyone but the agent.

CHANGELOG.md's five references were de-linked, not de-worded: the text is
byte-identical, only the link target is gone. The v0.33.0 rename entry set the
precedent that rewriting what a past entry says is falsification; a link is
navigation, and navigation to a file that is no longer in the repository is
simply broken.

prompts/session-start.md and its zh mirror keep their CLAUDE.md link and are
registered as example links with the reason: an injected prompt is read inside
whatever repository cc-enforcer is installed into, so that link resolves against
the consuming project's instruction file. It never referred to this one.

The gate

test_untracked_local_docs_are_actually_untracked asserts both halves hold:
out of the index and ignored. git rm --cached alone leaves the file
untracked but not ignored, so the next git add -A re-commits it — mail address
included — while every gate above keeps skipping a file that is in the
repository. Verified to bite by re-adding the file and watching it fail.

Tests — 737 → 738

v0.39.0 — the injected contract, thinned and gated

Choose a tag to compare

@skymanbp skymanbp released this 26 Aug 04:31

The contract was eating the feature it was supposed to leave room for.
v0.38.3 fixed the symptom — edicts dropped silently — by making the drop
loud and by stopping the header from spending the install root twice. This is
the cause: the SessionStart contract had grown to 9,262 characters against a
10,000-character cap, and nothing gated it.

Measured across releases, this had been eroding for nine of them:

release contract headroom
v0.29.0 9,009 B 991
v0.32.0 9,086 B 914
v0.35.0 9,448 B 552
v0.39.0 6,627 chars 3,373

At v0.38 that headroom no longer covered a real install path plus two real
edicts. A headline feature was degrading to nothing in the field, and every
release had made it slightly worse with nothing to say so.

What came out

Not text, but duplication:

  • Section 2's Recovery column — every word of it is printed at deny time
    in the block reason's [Recovery — …] section. Sending it in advance, every
    session, to be read once and forgotten, was the largest single block in the
    file. Three tokens lived only there and were folded into the trigger cell
    rather than dropped: CC_ENFORCER_DISABLE_LAYER_G=1, the 因为 half of the
    bilingual why-comment hatch, and the sync marker (already in §1's row 12).
  • Section 1's paragraph rows. The heading calls it a "one-line index";
    rows 03, 06, 08, 09 and 12 had grown into paragraphs, and what they had
    grown into was §2's job. Every concept was checked against
    prompts/user-prompt.md — re-injected every turn with 2,925 characters of
    headroom — before being cut. The upstream ladder, Check 2b, the bulk-edit
    survey and the unified fix are all still injected, every turn.
  • The intro blockquote, §3's two explanatory blockquotes and §4's link list,
    compressed. The schema block itself is untouched: its field names are the
    Stop hook's detection markers.

What went back in

The first pass cut three things that lived nowhere else at cold start, and
putting them back is the point rather than an embarrassment: the four
self-quiz questions verbatim, and the layer (e) / layer (f) consequence
for rules 08 and 09. §2 names the Stop gate as a whole, not which layer a
given rule trips. Caught by the tests that assert them.

The zh mirror got the same treatment (6,011 → 3,998 characters), because a
mirror that stops mirroring is how the deny set came to differ between
languages in v0.26.

The gate

test_a_realistic_install_keeps_a_realistic_edict_set: a 120-character
install root
— what …/.claude/plugins/marketplaces/<market>/<plugin>
actually costs — plus three edicts rendered by the real renderer must
survive in both languages, with no elision. The parameters are ordinary on
purpose and named in the test, so a failure reads "the contract grew again and
the edicts are what will be dropped" rather than "a number moved". Verified
RED against the pre-thinning contract.

Two elision tests were also rebuilt on synthetic bodies sized to the cap
rather than on the live prompt. Pinning them to the real contract's current
size meant they silently stopped reaching the branch they existed to test —
which is exactly what this release's thinning did to them.

Tests — 741 → 742

v0.38.3 — the fresh-clone check earned its keep

Choose a tag to compare

@skymanbp skymanbp released this 26 Aug 04:01

Two defects found by verifying v0.38.2 in a fresh clone, not by reading the
code.
The history rewrite was checked the way it should be — clone the remote
into a new directory and run the suite there. Five tests failed. CI was green on
the same commit, on two platforms, so the obvious conclusion was "verification
artefact". Both turned out to be real.

The clone sat at a 110-character path. This repository lives at
D:\Projects\cc-enforcer — 23.

The injection dropped every edict without saying so

build_context protects the contract and elides edicts when the 10,000-character
hook cap gets tight. When the contract alone fills the budget it took a different
branch and returned silently:

if room <= 0:
    return header + body       # every edict gone, nothing said

A session on a deeply-nested install was therefore governed by rules it had never
been shown and had no way to learn about. That is the v0.34.1 defect — all edicts
elided while the notice reported 0 — living in the sibling branch, three releases
after the class was supposedly closed.

Reproduced first-party before the fix, across install-root lengths:

root  23 chars → header 177 | room  386 | edicts kept=True  | notice=False
root 120 chars → header 371 | room  192 | edicts kept=True  | notice=False
root 300 chars → header 731 | room -168 | edicts kept=False | notice=False   ← silent

The branch now reports the count, using the same counter _clip_edicts
locates cut points with — counting twice, two ways, is how v0.34.1's notice came
to disagree with its own clipper.

The header spent the install root twice

The second copy came out of the budget the header exists to protect. Naming it
once — the "Read prompts/… under that root" line sits directly beneath the
root, so it stays just as actionable — takes the 300-character case from
room -168 to room +125, i.e. from losing every edict to keeping them.

And a test that encoded the maintainer's directory layout

test_self_locating_header_leads_the_injection sliced the first 200
characters
and asserted the filename was inside. True at a 23-character root,
false at 110 — so it failed on code that was working correctly. It measures the
header now. A test that passes because of where the repository happens to live
is not testing the property it names.

Tests — 738 → 741

test_a_total_elision_still_reports_the_count (with its twin, so the fix cannot
degenerate into "always drop everything and report it") and
test_the_header_names_the_root_once. Both verified RED against the pre-fix
code.

v0.38.1 — one document, one language

Choose a tag to compare

@skymanbp skymanbp released this 26 Aug 02:06

The same class, in the documents v0.38.0 did not look at. v0.38.0 fixed
the language mixing at its source and cleaned the two READMEs, because the
READMEs were what the request named. Asked afterwards whether the project was
closed, the honest answer was no: a repo-wide scan found the same defect —
a Chinese phrase sitting beside its own English translation — in 6 more
English documents
, rules/ among them.

rules/ is the English skeleton and the LLM-agnostic product, so a gloss
there ships to every consumer that never reads this repo's docs.

The rule, applied uniformly

In an English document, CJK is allowed only inside a backtick code span —
where it is a literal token the reader must type or that a detector literally
matches. Everything else is English. That splits the 65 surviving occurrences
three ways:

  • DATA (14) — a detector token or trigger phrase that simply was not
    marked up as one: the rule-02 keyword table, the rule-09 triplet table, the
    rationale tokens in rule 09, 全库更新 in rule 12, the hedge list in
    README.md. Backticked, not removed: deleting them would make the document
    false, and test_doc_sync's own hedge gate would fail on the next run.
  • GLOSS (43 + 8 in fenced blocks) — removed or translated. 圣旨 beside
    "Imperial Edicts", 宁可漏报不误报 beside "prefer false negatives",
    代码门禁, 语言版本控制, (陈旧) / (过时) / (冗余) beside their
    English names, 中文 translation beside "Chinese translation", and
    docs/EDICTS.md's Chinese sample edicts — which read, in an English doc,
    as if the feature only accepts Chinese.
  • ALLOWED (1) — the language-switcher link in README.md. An English
    reader needs the Chinese document labelled in Chinese to recognise it.

The second half of the sweep went inside the fenced blocks, which the
first pass skipped. A fence in an English doc is a file tree (its comments are
prose) or a config sample (its values are what a user would type).

The gate

Correcting the words would decay on the next release, so
TestEnglishDocsAreEnglish now enforces it (4 cases):

  • English docs carry no CJK outside a code span;
  • every markdown file is classified — English, Chinese-by-project-decision
    (CLAUDE.md section 5: commands / skills / this document), or deliberately
    unscanned with a stated reason. Without this, adding an English doc and
    forgetting to register it means the gate silently does not cover it, which
    is how the READMEs kept their glosses through v0.37;
  • the allowlist must still be needed — an exemption list that only grows
    eventually covers a real defect;
  • and a document registered as Chinese must actually be Chinese, so the
    registry cannot be used to dodge the first check.

Two files are unscanned, each with its reason recorded in the source:
CHANGELOG.md, a dated record whose entries quote the wording of the release
they describe — rewriting them to read better today is the falsification the
v0.33.0 rename entry refused to commit; and prompts/*.md, injected payloads
budgeted against Claude Code's 10,000-character cap, where backticks for
tidiness have a real cost.

Verified RED against the pre-sweep tree before being committed.

Tests — 733 → 737

v0.38.0 — the READMEs mixed languages because the product did

Choose a tag to compare

@skymanbp skymanbp released this 26 Aug 01:29

The READMEs mixed languages because the product did. The request was to
separate them — Chinese in the Chinese README, English in the English one. The
survey said the prose was already clean: zero English prose in README.zh.md
outside code fences, and three Chinese fragments in README.md, one of which
was a decorative gloss.

The mixing was inside the fenced samples, and those samples are real hook
output. Rewriting them into one language would have fabricated output — the
exact defect v0.35.1 was about. So the fix had to happen where the mixing
actually was.

What the guards were printing

Every user-facing string a guard emitted was bilingual by construction: an
English body, a Chinese 大白话: line appended to every block reason, Chinese
phrasings offered inline ("尚未确认 / not yet verified"), and marker lists
that named the Chinese spellings alongside the English ones. CC_ENFORCER_LANG
did not reach any of it — it switched the injected prompts and nothing else.

The catalog

Every string now lives in a catalog, on the contract rules/ and prompts/
already use (docs/I18N.md):

A dict rather than markdown beside prompts/, for two measured reasons: these
strings are read on every hook invocation, which sits in the critical path
§6 publishes the cost of; and a dict can be gated on exact key sets and
per-key placeholder sets, where markdown can only be gated on heading shape.

The English half was generated mechanically from the constants the guards
carried through v0.37 — verified byte-identical before anything else changed —
so the move could not alter behaviour. 733 tests then passed unchanged, which
is the evidence that it didn't.

Then the English was made English

A separate, deliberate contract change. An English message now names the
English spellings of the markers it describes. Each replacement was checked
against the live pattern sets (CONVERGENCE_MARKERS, FIDELITY_MARKERS,
RULE_08_MARKERS, RULE_09_MARKERS, TLDR_MARKERS, SYNC_MARKERS,
_HEDGE_INNER), all of which accept both languages — so naming only one stays
true. 大白话: became In plain words:; the nine per-layer one-liners, which
were Chinese-only, were written in English for the skeleton and kept in Chinese
for the translation.

Measured, not asserted: default output contains zero CJK, and
CC_ENFORCER_LANG=zh produces a fully Chinese deny across all seven deny
and block paths.

What the first pass missed

Found by running each path in zh and reading the output, not by grep: the
coverage-bar renderers in read_guard, the Hedge matched label, and
bash_guard's entire register-deny path were built by functions and f-strings
rather than held as constants, so they came through in English inside an
otherwise-Chinese message. Ten more keys.

Gates

i18n_check gained check_message_catalogs(), wired into CI through
tests/test_i18n_sync.py:

  • key-set parity, both directions;
  • per-key str.format field parity — the one that bites at runtime, since
    a dropped field silently says less and an invented one raises inside the
    hook while the user is already being denied;
  • every key a guard asks for exists — including the two computed families
    (per-layer, per-bash-pattern), because a renamed key would otherwise appear
    as <<missing message: …>> inside a deny.

The last check caught its own docstring on first run (messages.text("a.b")
read as a real request), and then caught itself again: the filter that was
supposed to skip non-consumers spelled out the import phrase it searched for,
which made this module look like a consumer. Split across concatenation, the
way read_guard and bash_guard already handle their own literals.

Also

  • The Stop status table pads by display columns, not code points. It never
    mattered while every value was ASCII; a translated status word rendered at
    half its declared width and the table came out ragged. Same _display_width
    the tldr cap has used since v0.35.
  • README.zh.md's two samples were re-captured under CC_ENFORCER_LANG=zh;
    README.md's layer-(b) sample was re-captured too — it still carried the
    pre-v0.38 bilingual text, so it was both mixed and stale.
  • Three decorative Chinese glosses removed from README.md. The Chinese that
    stays is data: the language-switcher link, and the hedge tokens the detector
    literally matches (test_doc_sync derives that list from _HEDGE_INNER, so
    removing them would make the doc false and fail the gate).
  • The demo images were re-rendered — the rule-09 deny wording changed, and
    tests/test_demo.py said so before anything else did.

Tests — 712 → 733

New tests/test_messages.py (17): the English
catalog asserted CJK-free on every value (the user-visible requirement, stated
as an assertion rather than a review pass), the Chinese catalog asserted
actually translated rather than copied, loader resolution and both fallbacks,
and TestCatalogChineseOutput — the language switch proven end to end through
real hook subprocesses, each case with its English twin, because "the output is
Chinese" means nothing without "and the default is not". Plus 4 in
test_i18n_sync.py for the new parity gate, including the boundary case that
proves it can return clean.