Skip to content

release: v2.66.0 — tool discipline is a knob you can turn - #60

Merged
drknowhow merged 1 commit into
mainfrom
release/v2.66.0
Jul 31, 2026
Merged

release: v2.66.0 — tool discipline is a knob you can turn#60
drknowhow merged 1 commit into
mainfrom
release/v2.66.0

Conversation

@drknowhow

Copy link
Copy Markdown
Owner

The problem

C3 had four independent gates and only three were adjustable:

Layer Governs Adjustable before this PR?
A — permission tier which tools the IDE will call yes (c3 permissions)
B — Access Guard which paths the agent may touch yes (c3 access)
C — tool discipline native Edit/Write vs c3_edit no knob at all
D — agent locks file leases yes (locks config)

cli/hook_pretool_enforce.py hardcoded _BLOCKED_TOOLS, a 10-minute signal
TTL and the lookback window, read no config and no env var, and install-mcp
registered it regardless of tier. So a user selecting permissive
documented as "all tools and shell commands pre-approved" — still had every
native Edit hard-denied. The knob that looked like it should help didn't
reach the layer doing the blocking.

Two things made it worse, neither of them policy:

  • No denial telemetry. docs/access-guard.md §3 specified "coalesced per
    (rule, tool, session) with a hit counter". Never implemented, so friction was
    unmeasurable.
  • A self-worsening state bug (see Fixed).

The design

Layer C is a workflow preference; Layer B is a security boundary. They were
conflated. Splitting them is what makes it safe to loosen the first.

c3 enforce [strict|advisory|off], plus an enforcement section resolved
project → global → strict.

At every mode including off, these still enforce: Access Guard path
rules, the credential-vault write guard, agent locks. The vault guard keys off
a fixed tool set rather than the configurable blocked_tools, so neither a
mode nor a config override can open a native write path to the vault.
Asserted per-mode in TestSecurityBoundariesSurviveEveryMode.

The edit ledger is captured PostToolUse and is unaffected by the mode. What
strict buys over advisory is c3_edit's pre-edit snapshot — that's the
whole trade-off, and both UIs say so before you switch.

Also added

  • Discipline tab in both UIs — per-project (c3 ui) and cross-project
    (Hub). Mode picker, provenance, what stays enforced, and the ranked denial
    table with the fix per row. In the Hub, unreadable projects are listed under
    "Not reporting" rather than shown as strict — "we don't know" and "running
    strict" are different claims.
  • c3 access stats — the missing §3 telemetry, covering both layers and
    naming the right lever per row (c3 enforce advisory vs c3 access remove
    vs c3 access builtin disable).
  • Tier derivationstandardadvisory, permissiveoff,
    c3-strict/read-onlystrict. An explicit c3 enforce records
    set_by: user and a later tier change defers to it.
  • c3 init — Step 5/5 prompt, and --enforcement for scripted installs.

Fixed

_atomic_write_json wrote enforcement state without fsync and abandoned its
temp file when os.replace raised. Observed on Windows: a truncated
enforcement_state.json, 10 orphaned .c3/*.tmp<pid> files, 58 hook errors
over two days. The failure is quietly self-worsening — corrupt state loads
empty, dropping every sticky unlock, making enforcement more aggressive,
which reads as "the guard got worse". Now fsyncs before publishing, retries
os.replace on Windows sharing violations, and always removes the temp file.
c3 init sweeps orphans whose owning PID is gone, never one belonging to a
live process.

Upgrade safety

No enforcement section resolves to strict, and nothing is derived at read
time — upgrading cannot change how an existing project behaves. Verified
against the live registry: all 47 registered projects report
mode=strict, scope=default.

Everything fails closed: unknown mode, malformed section, unparseable JSON, or
a blocked_tools entry naming an ungoverned tool all resolve to strict with
a visible [c3:enforcement-config] warning.

Verification

  • 1816 tests pass (61 new); full suite green.
  • JSX is transformed in-browser, so a syntax error would silently break the
    whole bundle with green Python tests. Both concatenated bundles (30-file Hub,
    20-file project UI) were compiled through @babel/standalone and checked for
    duplicate top-level declarations.
  • twine check passes on sdist + wheel; confirmed the new JS and service files
    land in the wheel (ui/components/*.js, hub_ui/components/*.js globs).

Docs: docs/enforcement.md. docs/access-guard.md gets an implementation note
under §3 — the frozen spec itself is unchanged.

https://claude.ai/code/session_01NLRxC5agGV92NFfaEQFz6F

C3 had four independent gates, and only three were adjustable. The fourth —
the PreToolUse hook that hard-denies native Edit/Write unless a c3_* call ran
first — was hardcoded, read no config, and was registered regardless of the
permission tier. So selecting `permissive`, documented as "all tools and shell
commands pre-approved", still had every native Edit refused by the hook. The
one knob that looked like it should help did not reach the layer doing the
blocking.

Added
- `c3 enforce [strict|advisory|off]` plus an `enforcement` config section.
  Deliberately separate from `c3 access`: path policy is a security boundary,
  tool discipline is a workflow preference, and that split is what makes it
  safe to loosen the second without touching the first. At every mode —
  including `off` — Access Guard rules, the credential-vault write guard and
  agent locks still enforce. The vault guard keys off a fixed tool set so
  neither a mode nor a `blocked_tools` override can reach it.
- Discipline tab in both UIs: per-project in `c3 ui`, cross-project in the Hub.
- `c3 access stats` — the denial logging docs/access-guard.md §3 specified but
  never shipped. Ranks denials by layer and names the command that clears each.
- Permission tiers now derive a discipline mode (standard->advisory,
  permissive->off, c3-strict/read-only->strict). An explicit `c3 enforce`
  choice records set_by=user and a later tier change defers to it.
- `c3 init`: Step 5/5 prompt, plus `--enforcement` for scripted installs.

Fixed
- `_atomic_write_json` wrote enforcement state without fsync and abandoned its
  temp file when os.replace raised. Observed here: a truncated
  enforcement_state.json, 10 orphaned .c3/*.tmp<pid> files, 58 hook errors over
  two days. The failure is self-worsening — corrupt state loads empty, dropping
  every sticky unlock, which makes enforcement feel more aggressive. Now
  fsyncs before publishing, retries os.replace on Windows sharing violations,
  and always cleans up the temp file.

Upgrade safety: no `enforcement` section resolves to `strict`, and nothing is
derived at read time, so upgrading cannot change how an existing project
behaves. Everything fails closed — unknown mode, malformed section or
unparseable JSON all resolve to strict with a visible warning.

61 new tests; 1816 passing.

Claude-Session: https://claude.ai/code/session_01NLRxC5agGV92NFfaEQFz6F
@drknowhow
drknowhow merged commit f504aa7 into main Jul 31, 2026
11 checks passed
@drknowhow
drknowhow deleted the release/v2.66.0 branch July 31, 2026 16:53
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