# Safe-baseline metering — live wiring (router-gate v4 §3.1.2, item 1b)

**Date:** 2026-05-30
**Status:** Design v4 — ghost-risk pass folded in. Removed the user-approval override subsystem (G3 ghost-protection — almost never exercised); the escape is now solely "invoke any Skill/EnterPlanMode" (always available, C1). The runtime write-deny hook is retained but **decoupled** into a standalone git-approval-anchor hardening (independent value). Ready for writing-plans.
**Scope:** Turn the deferred no-op `main()` of `tools/enforce-safe-baseline-metering.mjs` into a live PreToolUse hook that meters safe-baseline tool usage per task and hard-blocks a mutating tool once a counter passes its hard threshold without a real skill match. The false-block escape is invoking any Skill/EnterPlanMode (never blocked by this layer). A separate, decoupled hook closes a pre-existing self-write hole on `~/.claude/runtime` side-channels (git-approval anchor).

This is router-gate v4 Direction 1 ("skill-substitution laundering": много Read/Grep/Glob/LS/TodoWrite/AskUserQuestion как канал анализа ВМЕСТО invoke recommended Skill, затем mutating-инструмент без единого skill-match). The pure metering + threshold logic already exists in `tools/safe-baseline-metering.mjs` (thresholds, counters, `processEvent`, `decide`); the live I/O composition, a corrected escape, and the side-channel write-protection are missing.

---

## Owner decisions (brainstorming, revised)

1. **Enforcement posture:** hard-block from day 1 (not observe-only).
2. **False-block escape (revised — override removed, G3):** invoke any real `Skill` or `EnterPlanMode` — these are never blocked by this layer (C1) and set the sticky task exemption. This escape is ALWAYS available, so a false block is a one-step recovery, not a lockout. The earlier user-approval override side-channel is **cut** as a ghost-protection (it defended a path that almost never occurs; its only durable value — runtime write-protection — is kept as a decoupled hook).
3. **`skillMatched` definition (revised — strict, discipline-first):** matched only by a **real `Skill` OR `EnterPlanMode` tool_use** in the task (an actual tool invocation visible in the transcript). A **text mention** of a plan path does NOT count (it was a self-writable hole — removed). Sticky within the task. Optional future strengthening — match the *recommended* node specifically — is deferred because it needs a node→skill registry mapping (see "Deferred strengthening").
4. **Priority:** discipline protection and correctness are paramount; cost and speed are secondary.

---

## Honest discipline-protection assessment

Read this before trusting the layer. Even fully fixed, safe-baseline is a **cheap deterministic tripwire**, not a strong scope-discipline guarantee:

- It fires only when a single task accumulates a high count of safe-baseline tools (Read hard = 60, Grep = 30, …) **without any real skill/plan invocation**, then reaches for a mutating tool. Realistically counters accumulate mostly *within one assistant turn* (terse user confirmations reset task boundaries), and 60 reads in one turn is uncommon — so the trigger surface is genuinely small.
- After the fixes it has **no self-bypass** (skill-match needs a real tool_use) and a **working escape** (skill/plan invocations are never blocked, always available). That makes it *sound* — it does what it claims without a trivial dodge.
- The **strong** scope-consistency check (is THIS tool call consistent with the declared task and recommended skill?) is **Layer 4** (`enforce-llm-judge-per-tool`), which is OFF until owner activation (item 2b). Safe-baseline is the cheap pre-filter beneath it.

Verdict: as a hard guarantee — **LOW–MODERATE**; as an honest, non-bypassable tripwire for blatant laundering — **sound**. The discipline lever that matters most is Layer 4.

---

## Architecture & data flow

`tools/enforce-safe-baseline-metering.mjs` gains a live `main()` (replacing the no-op). On each PreToolUse event:

1. Parse the event (`tool_name`, `session_id`, `transcript_path`).
2. Load the per-session ledger `~/.claude/runtime/safe-baseline-ledger-<sess>.json` = `{ state, lastKeywords }` (absent on first event → `null`).
3. From the transcript extract:
   - `promptText` — the last user prompt (`lastUserPromptText`).
   - `currentKeywords` — `extractKeywords(promptText)` (deterministic tokenization — see below; no classifier dependency).
   - `skillMatchedThisTurn` — `detectSkillMatch(lastTurnEntries(transcript))` **OR** `event.tool_name ∈ {Skill, EnterPlanMode}` (the in-flight escape call counts — see C1 fix).
4. Call the existing pure `processEvent({ event, priorLedger, currentKeywords, promptText, skillMatched, thresholds })` — task-boundary inference (`shouldInheritTaskId`: reset-marker / keyword-overlap ≥ 2 → continuation; else fresh task, counters from zero) then metering.
5. Sticky skill-match — **task-scoped, explicitly persisted** (the pure pipeline does NOT persist it; see "Skill-match stickiness contract"). Determine `inherit` (same predicate as `shouldInheritTaskId`), then `effectiveSkillMatched = (inherit ? priorLedger.state.skill_match_within_task : false) || skillMatchedThisTurn`; pass `effectiveSkillMatched` to `processEvent`/`decide` AND write it back into the persisted `state.skill_match_within_task`.
6. Persist the new ledger.
7. `hard_block` → `exitDecision({ block: true, message })` — the message MUST name the escape ("invoke the recommended Skill, or EnterPlanMode, to proceed"); `soft_flag` → append to the flags log and exit 0; `allow` → exit 0.

`soft_flag` never blocks (observability only). Only a mutating tool past a hard threshold without skill-match blocks.

### C1 fix — the escape must never be blocked

`Skill` and `Task` are in the pure module's MUTATING set (`safe-baseline-metering.mjs:31`), and `evaluateThresholds` hard-blocks any mutating tool past a hard threshold when `skillMatched` is false (`safe-baseline-metering.mjs:92-102`). Naively this blocks the very `Skill` call meant to escape (catch-22). The live head closes this by counting the **current event** in `skillMatchedThisTurn` when `event.tool_name ∈ {Skill, EnterPlanMode}` (step 3). Because `skillMatched` short-circuits `evaluateThresholds` to `allow` (`safe-baseline-metering.mjs:89`), a skill/plan invocation always passes — and then sets the sticky exemption for subsequent Edit/Write/Bash/Task. `Task` is intentionally NOT treated as an escape tool (subagent spawn can itself be a laundering channel) and remains blockable.

### Skill-match stickiness contract (V2-1 fix)

The pure pipeline neither persists nor task-scopes skill-match, so the wrapper MUST own it:

- `processEvent` returns `ledger.state = d.state` and never sets `skill_match_within_task` (`enforce-safe-baseline-metering.mjs:89-94`); `decide`/`incrementCounter` touch only `counts` (`safe-baseline-metering.mjs:42-46, 77-84`); `newCounterState` sets `skill_match_within_task: false` on a fresh task (`safe-baseline-metering.mjs:67`).
- **Two failure modes if the wrapper is naive:** (a) *lost stickiness* — a skill invoked early in a task is forgotten next event, counters climb, a later mutating op blocks despite the skill (false block); (b) *cross-task leak* — passing `priorLedger.state.skill_match_within_task` unconditionally applies a prior task's exemption to a freshly-started task.
- **Required wrapper logic:** compute `inherit` (replicate `shouldInheritTaskId`, or extend `processEvent` to return it); set `effectiveSkillMatched = (inherit ? priorLedger.state.skill_match_within_task : false) || skillMatchedThisTurn`; use it for the decision; and write `effectiveSkillMatched` back into the persisted `state.skill_match_within_task`. Unit tests must cover both failure modes explicitly (skill-then-60-reads stays allowed within a task; skill in task A does NOT exempt task B).

### Safety property of the boundary heuristic

The dangerous direction is *wrongly inheriting* counters across two genuinely different tasks (carrying 60 reads into an unrelated task → false block); this needs keyword-overlap ≥ 2 AND no reset marker, which is uncommon. The opposite error — treating a continuation as a fresh task — *resets* counters to zero, which only *reduces* blocking (safe direction). So the heuristic errs toward fewer false blocks.

---

## Task-boundary & skill-match detection

### `extractKeywords(promptText)` (pure) — H1 fix

Deterministic tokenization only: lowercase, strip RU/EN stopwords, keep tokens length ≥ 4, unique, sorted. **No classifier-keyword branch** — the classifier output schema (`router-classifier.mjs:420`) has no `keywords` field, and mixing extraction sources between events would break the overlap comparison. A single deterministic source guarantees `lastKeywords` (stored at event N) and `currentKeywords` (event N+1) are always comparable.

### `detectSkillMatch(turnEntries)` (pure) — C2 fix

Returns `true` only if the turn contains a real assistant **tool_use** block with `name ∈ {Skill, EnterPlanMode}`. The previous "text reference to `docs/superpowers/plans/`" path is **removed** — it was controller-authored text and therefore a self-writable bypass of the entire hard-block. A `Read` of a plan file no longer counts either (Read is itself a metered safe-baseline tool; counting it would let pure reading self-exempt). Implement like `hasPlanIndicator` in `enforce-tdd-gate.mjs` (scan assistant `tool_use` blocks); `lastTurnEntries` already skips harness-injected skill *bodies* (commit `ffd70d6f`, `isMeta` + `sourceToolUseID`), so the detector sees the genuine invocation, not the injected body. Stickiness is owned by the wrapper per the "Skill-match stickiness contract" (V2-1), combined with the in-flight escape rule in step 3 / C1.

### Deferred strengthening — recommended-node match

The strict ideal is "skill-match requires the *recommended* node, not just any skill," so the controller can't dodge by invoking an unrelated skill. This needs a node-ID (`#19`) → skill-name (`superpowers:writing-plans`) registry mapping (router-state stores `recommendedNode` per `router-classifier.mjs:163`, but not the skill name). **It is only a *partial* strengthening (V2-3):** the dominant recommendation for feature/planning/bugfix is node `#19` = the whole Superpowers plugin (`nodes.yaml:279-291`), which maps to "any `superpowers:*` skill" ≈ "any skill"; node-matching would constrain only specific skill-nodes (#62 billing-audit, #71, …), not the common case. Deferred to avoid a brittle mapping causing lockouts for marginal gain; the always-available Skill/EnterPlanMode escape and Layer 4 cover the gap meanwhile. Documented honestly so it is neither silently dropped nor oversold.

---

## Side-channel write-protection (decoupled — G3 cut)

The user-approval override is **removed** (G3 ghost-protection — it defended a near-never path with heavy machinery). The false-block escape is invoking any Skill/EnterPlanMode, which is always available and never blocked by this layer (C1). What remains worth building is a standalone hardening of the `~/.claude/runtime` side-channels — it has **independent value** (it closes a pre-existing self-write hole in the v4 **git-approval anchor**), so it ships as its own hook, not as a safe-baseline dependency.

### The write-deny hook

Today **no hook gates the Write/Edit tool to `~/.claude/runtime`**: `enforce-read-path-deny.mjs:22` is Read-only; `enforce-normative-content-rules.mjs:11-18` covers only normative docs; the `.claude/runtime` protection in `shell-content-rules.mjs:24` applies only to Bash/PowerShell, not the Write tool. So the existing git-approval anchor (`askuser-decisions-<sess>.jsonl`) — and any future runtime side-channel — is self-writable via the Write tool.

**Fix:** a new PreToolUse hook `enforce-runtime-write-deny.mjs` (matcher `Edit|Write|MultiEdit|NotebookEdit`) that blocks the Write/Edit tools from writing any path under `~/.claude/runtime/**`, fail-CLOSE. It **MUST normalize with the resolving `pathNormalize`** (`path-normalization.mjs:104,107` — `path.resolve` + `realpath` collapse `.`/`..`), **NOT** the lighter `defaultPathNormalize` (`shell-content-rules.mjs:13-19`), which leaves `.`/`..` segments intact (V2-2): `~/.claude/./runtime/x.jsonl` would evade the `\.claude/runtime` pattern while `fs` writes the real file. After resolving, match against the runtime pattern from `DEFAULT_PROTECTED_PATTERNS`. Legitimate hooks write there via Node `fs` (not the Claude Write tool), so they are unaffected. The same `.`-segment hardening should also be applied to `enforce-read-path-deny.mjs`.

**Owner verification:** the owner should check `.claude/settings.json` for any `permissions.deny` already covering Write to `~/.claude/**` (Claude cannot read settings.json — gate-blocked). The new hook is additive defense-in-depth regardless.

---

## Persistence, registration, testing, rollout

### Persistence

- Ledger: `~/.claude/runtime/safe-baseline-ledger-<sess>.json` = `{ state, lastKeywords }`; `state` also carries `task_id` and `skill_match_within_task`.
- Flags log: `~/.claude/runtime/safe-baseline-flags-<sess>.jsonl` (soft_flag observability).
- All file I/O is fail-quiet: any read/write error → treat as no-ledger and exit 0. The hook never crashes the session.

### Purity / testability

All logic lives in pure functions (`extractKeywords`, `detectSkillMatch`, plus the existing `processEvent`/`decide`). `main()` is only I/O composition. The new `enforce-runtime-write-deny.mjs` has a pure `decide({toolName, filePath})`. TDD: each new pure function RED→GREEN; an integration test drives `main()` via injected `runtimeDir` + a transcript fixture.

### Registration (owner-applied)

- `enforce-safe-baseline-metering` — PreToolUse, matcher scoped to the metered + mutating + escape tools (`Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode`), block mode.
- `enforce-runtime-write-deny` — PreToolUse `Edit|Write|MultiEdit|NotebookEdit`, block mode (standalone — protects the git-approval anchor; independent of safe-baseline).
- **Claude does not edit `settings.json`** (gate-blocked). The plan produces an exact JSON block for the owner to paste manually. Until registered, the hooks are inert (no behavior change).

### Rollout safety

Despite "hard-block from day 1", the plan includes a **mandatory smoke test before live registration**: run the live `main()` against 3 real transcript fixtures (single task / task switch / skill-invocation escape) and confirm boundary, skillMatched, and escape all fire correctly. Plus a smoke for `enforce-runtime-write-deny`: a Write to `~/.claude/runtime/x.jsonl` is blocked, a Write to `~/.claude/./runtime/x.jsonl` (V2-2 `.`-segment evasion) is ALSO blocked, and a Write to a normal project path passes. This does not change the posture; it catches gross detection bugs before the hooks start blocking.

### Scope

~7-9 TDD tasks (live `main()` + `extractKeywords` + `detectSkillMatch` + stickiness contract + escape fix; plus the standalone `enforce-runtime-write-deny` hook), estimate 5-7 h. Cost/speed are secondary per owner priority.

---

## Out of scope

- User-approval override side-channel (cut as a ghost-protection, G3 — escape via Skill/EnterPlanMode is always available).
- Layer 4 LLM-judge activation (separate owner step, item 2b) — the strong scope-discipline lever.
- Recommended-node skill matching (deferred strengthening — needs node→skill registry).
- CLAUDE.md / Pravila / PSR / Tooling normative sync (blocked by a parallel session, item 4).
- Layer 5 VM / biometric / YubiKey (item 6).
- Any weakening of the router-gate whitelist.
