diff --git a/docs/CATALOG.md b/docs/CATALOG.md index 1801ea3d96..4f62c63bb4 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -75,7 +75,7 @@ plugin manifests and kept in sync by CI — never hand-edit it; the category voc - [`claude-memory`](../plugins/claude-memory) — Keeps a repo's Claude Code memory layer healthy and under your control, against criteria derived from official Claude Code documentation. The audit skill checks the instruction/memory layer (CLAUDE.md, CLAUDE.local.md, .claude/rules/, auto-memory) with a deterministic script-backed spine plus judgment-tier checks. The stateless skill inspects, disables, and (confirm-gated) purges Claude-written auto memory across all settings scopes. - [`claude-ops`](../plugins/claude-ops) — Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort and a repo-pull + marketplace-refresh launch step), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. - [`rate-limit-guard`](../plugins/rate-limit-guard) — Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume. -- [`context-guard`](../plugins/context-guard) — Per-session context-window observability: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (zones.json SSOT with shipped defaults), and a reader contract fixes how consuming sessions interpret the snapshots. +- [`context-guard`](../plugins/context-guard) — Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks inject continuation guidance once per transition into a worse zone (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker. - [`plugin-quality`](../plugins/plugin-quality) — Post-use behavioral audit of Claude Code plugin components: a six-step audit workflow (evidence capture, grounded mapping in a fresh subagent, blindspot pass, interactive contract lock, presence-gated review seams, work-item emit with draft+confirm) over any skill, agent, hook, command, or config you have actually used — zone-informed by context-guard snapshots when present, conservative when not. - [`skill-quality`](../plugins/skill-quality) — Skill-authoring QA tooling: a static contract checker that runs twenty-two deterministic checks over a Claude Code skill (frontmatter, per-skill listing-entry cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity, injection shell-declaration, fresh-eyes declaration conformance), a shared skill-listing budget reporter across a set of skills, and a bundled evals.json schema for validation. Runs against any repo's skills directory via the convention-resolution ladder — no baked layout. diff --git a/docs/conventions/hook-telemetry/README.md b/docs/conventions/hook-telemetry/README.md index 5fbbd03bfe..10ab6b9242 100644 --- a/docs/conventions/hook-telemetry/README.md +++ b/docs/conventions/hook-telemetry/README.md @@ -180,3 +180,6 @@ producers without coordinating with them or each other. | `claude-ops` plugin | `skill-usage-audit` (two producers: PostToolUse/Skill and UserPromptExpansion — see schema) | `data/skill-usage-audit.schema.json` | | `claude-ops` plugin | `tool-failure-audit` | `data/tool-failure-audit.schema.json` | | `autonomy` plugin | `lane-stop-gate` | `data/lane-stop-gate.schema.json` | +| `context-guard` plugin | `zone-crossing-inject` (two producers: PostToolBatch and UserPromptSubmit — see schema) | `data/zone-crossing-inject.schema.json` | +| `context-guard` plugin | `zone-gate` | `data/zone-gate.schema.json` | +| `context-guard` plugin | `post-compact-mark` | `data/post-compact-mark.schema.json` | diff --git a/docs/conventions/hook-telemetry/data/post-compact-mark.schema.json b/docs/conventions/hook-telemetry/data/post-compact-mark.schema.json new file mode 100644 index 0000000000..8f67e11992 --- /dev/null +++ b/docs/conventions/hook-telemetry/data/post-compact-mark.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/post-compact-mark.schema.json", + "title": "post-compact-mark telemetry data", + "description": "Per-hook `data` payload for context-guard's PostCompact evidence-degraded marker hook. Discovered from the envelope `hook` value \"post-compact-mark\". Evolves additive-only.", + "type": "object", + "required": ["trigger"], + "additionalProperties": true, + "properties": { + "trigger": { + "type": "string", + "description": "Compaction trigger from the hook payload: \"manual\" (/compact) | \"auto\" | \"unknown\" (absent or unrecognized in the payload)." + } + } +} diff --git a/docs/conventions/hook-telemetry/data/zone-crossing-inject.schema.json b/docs/conventions/hook-telemetry/data/zone-crossing-inject.schema.json new file mode 100644 index 0000000000..d5e818fbbb --- /dev/null +++ b/docs/conventions/hook-telemetry/data/zone-crossing-inject.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/zone-crossing-inject.schema.json", + "title": "zone-crossing-inject telemetry data", + "description": "Per-hook `data` payload for context-guard's zone-crossing injection hook (PostToolBatch + UserPromptSubmit — the envelope `hook_event` distinguishes them). Discovered from the envelope `hook` value \"zone-crossing-inject\". Emitted only on a zone transition (either direction); unchanged-zone and unknown runs emit nothing. Evolves additive-only.", + "type": "object", + "required": ["zone", "injected"], + "additionalProperties": true, + "properties": { + "zone": { + "type": "string", + "description": "The newly resolved zone word: \"smart\" | \"acceptable\" | \"dumb\" (unknown never emits)." + }, + "previous": { + "type": "string", + "description": "The last recorded zone word, empty string when this is the session's first recorded observation." + }, + "injected": { + "type": "boolean", + "description": "true when guidance was injected (transition into a worse zone); false for a silently recorded improvement." + } + } +} diff --git a/docs/conventions/hook-telemetry/data/zone-gate.schema.json b/docs/conventions/hook-telemetry/data/zone-gate.schema.json new file mode 100644 index 0000000000..1384cb8e42 --- /dev/null +++ b/docs/conventions/hook-telemetry/data/zone-gate.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/zone-gate.schema.json", + "title": "zone-gate telemetry data", + "description": "Per-hook `data` payload for context-guard's blocking-mode PreToolUse gate. Discovered from the envelope `hook` value \"zone-gate\". Emitted only when the gate denies (envelope status \"blocked\"); every allowed or fail-open path emits nothing. Evolves additive-only.", + "type": "object", + "required": ["zone", "grace", "calls_seen"], + "additionalProperties": true, + "properties": { + "zone": { + "type": "string", + "description": "Always \"dumb\" — the only zone the gate can deny in." + }, + "grace": { + "type": "integer", + "description": "The grace budget in effect (zone_gate_grace_calls, in-script default 20)." + }, + "calls_seen": { + "type": "integer", + "description": "Matched tool calls observed for this session since it entered the dumb zone, including the denied one." + } + } +} diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index ef0cd6b147..c8d7aad28b 100644 --- a/plugins/context-guard/.claude-plugin/plugin.json +++ b/plugins/context-guard/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "context-guard", - "version": "0.3.0", - "description": "Per-session context-window observability: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (zones.json SSOT with shipped defaults), and a reader contract fixes how consuming sessions interpret the snapshots.", + "version": "0.4.0", + "description": "Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks inject continuation guidance once per transition into a worse zone (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" @@ -14,6 +14,28 @@ "tee", "zones", "context-degradation", - "session" - ] + "session", + "hooks" + ], + "hooks": "./hooks/hooks.json", + "userConfig": { + "context_guard_hooks_enabled": { + "type": "boolean", + "title": "context-guard hooks kill switch", + "description": "Master switch for the zone-crossing injection, blocking gate, and PostCompact marker hooks", + "default": true + }, + "zone_hook_mode": { + "type": "string", + "title": "Zone hook posture", + "description": "advisory (default) injects guidance only; blocking additionally denies new Write/Edit/NotebookEdit/Agent/Workflow calls on a fresh dumb-zone snapshot past the grace budget (fail-open on unknown; handoff-path writes, reads, Bash, and Skill stay allowed)", + "default": "advisory" + }, + "zone_gate_grace_calls": { + "type": "string", + "title": "Blocking-gate grace budget", + "description": "Blocking mode only: number of matched tool calls allowed after the session first resolves dumb before the gate denies (in-script default 20)", + "default": "20" + } + } } diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index 8b27b384f5..8ea56fcb29 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,138 @@ All notable changes to the `context-guard` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] + +### Added + +- **Zone-crossing hooks — the first shipped consumer of the plugin's own seam (#1475).** + `hooks/hooks.json` registers four handlers, all fail-open, all covered by co-located + `*.test.sh` contract tests: + - `zone-crossing-inject.sh` (`PostToolBatch` + `UserPromptSubmit`): injects continuation + guidance via `additionalContext` ONCE per transition into a worse zone — silent while the + zone is unchanged, improving, or `unknown` (no data is not a transition, and `unknown` never + updates the per-session state). PostToolBatch fires once per parallel batch before the next + model call, which replaces the per-tool dedupe a PostToolUse design would have needed; + UserPromptSubmit covers turns that begin without a prior batch. The injected message carries + a minimal generic continuation tree plus presence-gated pointers to `session-flow:handoff` + and `session-flow:workflow`. + - `zone-gate.sh` (`PreToolUse`, matcher `Write|Edit|NotebookEdit|Agent|Workflow`): the + `blocking` posture — inert under the default `advisory` mode; in `blocking` mode it denies + matched calls only on a FRESH dumb-zone snapshot past a per-session grace budget + (`zone_gate_grace_calls`, in-script default 20). Fail-open on `unknown` and on every missing + prerequisite. Handoff-path writes are exempt, and read-only tools, Bash, and Skill + invocations never match — a session told to stop can always write its handoff and always run + the handoff skill (no deadlock by construction). + - `post-compact-mark.sh` (`PostCompact`, side-effect-only per the upstream event contract): + persists the evidence-degraded marker + `~/.claude/context-guard/context/.compacted` (`compacted_at`, `trigger` + manual|auto|unknown), closing the reader contract's documented "the snapshot cannot tell you + compaction happened" gap, re-arms the blocking gate's grace budget (a fresh budget, not a + disarmed gate — both zone consumers treat a marked session's effective zone as dumb + regardless of its post-compaction numbers, so the marker is never write-only), and prunes + sibling markers on the tee's 14-day cutoff. jq-free by design, mirroring the + rate-limit-guard StopFailure recorder. + - All three hooks read stdin through a plugin-local chunked drain loop (`hooks/payload.sh`, + mirroring the tee's proven `read -N` pattern) instead of the shared lib's single bounded + read, which on Windows/MSYS pipes times out on exactly the payloads these events carry — + PostCompact's full `compact_summary`, a large Write's `tool_input`, PostToolBatch's + serialized results (measured: ~80KB payloads already lost, which silently suppressed the + marker and failed the blocking gate open for the biggest writes). Each hook carries a + large-payload regression test that fails against the single-read form. + - Config per `docs/conventions/hook-config-delivery`: non-safety knobs over channel B + (`CLAUDE_PLUGIN_OPTION_` env mirrors) with in-script defaults (the declared `default` + field is not delivered to hook processes). New `userConfig`: `context_guard_hooks_enabled`, + `zone_hook_mode` (`advisory` | `blocking`, matching the repo's shipped gate-posture enum), + `zone_gate_grace_calls`. Telemetry envelopes registered as `zone-crossing-inject`, + `zone-gate`, and `post-compact-mark` producers with data schemas under + `docs/conventions/hook-telemetry/data/`. Hook state (last-seen zone, gate counters) lives + under `${CLAUDE_PLUGIN_DATA}` — plugin-private, not part of the reader-contract seam. +- **Window-class token bands + combination rule in the zone resolver (#1475).** The resolver now + computes two zone shapes and combines them conservatively (the worse computable zone wins; one + computable shape stands alone; neither → `unknown` — the rule is stated verbatim in the reader + contract for consumers to inline): the existing percentage shape over `used_percentage` + (distance to compaction; upstream computes it input-only), and a token shape over occupancy + `total_input_tokens + total_output_tokens` (distance to quality loss — degradation evidence + tracks absolute tokens, not window fraction) against per-window-class bands selected by the + largest class key ≤ `context_window_size`. Shipped token defaults: 200k class 100000/160000, + 1M class 200000/400000 — declared judgment defaults with named anchors (provenance table on + #1475), equally low confidence on both rows; `zones.json` is the correction path. TWO + independent gates protect the token shape: a **version floor** (the snapshot's new `cli_version` + must be present, purely numeric dotted, and ≥ 2.1.132 — before that release the token fields + were cumulative session totals, and a cumulative value BELOW the window size is + indistinguishable from a real occupancy, so numbers alone can never rule it out), and the + **plausibility guard** (occupancy > window size → not computable) for corrupt or forged data. + `zones.json` + gains an optional `token_bands` object validated independently of the percentage keys — absent + is zero-config, so every existing v1 file keeps working unchanged; the percentage keys are + retained with a recorded retirement trigger (they answer distance-to-compaction, which the + token shape cannot; they retire when no shipped consumer inlines the percentage floor). +- **`statusline-tee.sh` tees `cli_version`** — the statusline payload's top-level `version` field + (the Claude Code version), copied only when it is a string and never fabricated. It is the + signal the token-shape version floor above needs; an absent one simply leaves the percentage + shape standing alone. +- Setup skill seeds/repairs the v2 `zones.json` shape (including adding shipped `token_bands` to + a v1 file on `apply`), and `check` now reports hook **registration**, hook-set **activation** + (the `context_guard_hooks_enabled` kill switch read from its configured value, `UNKNOWN` rather + than "active" when unreadable), and **gate posture** (`zone_hook_mode`) as three separate facts + — equating plugin-enablement with active hooks reported the opposite of the runtime state + exactly when an operator was diagnosing missing injections or gating. Reader contract documents + the occupancy definition, combination rule, version floor and plausibility guard, + evidence-degraded marker, hook surface, and band provenance, and its capability table now + classifies per shape rather than dropping the whole reading to `unknown` on one missing field + (which contradicted the combination rule it sits above); README updated to the five-part + overview. + +### Fixed + +- **The blocking gate's grace counter is now atomic.** Claude starts matched tools in parallel, so + several `PreToolUse` hook processes ran concurrently against one session's counter; a + read-modify-write let them all read the same count and record the same increment, so far more + than the configured budget was allowed (measured 6–7 allowed of 24 concurrent calls against a + budget of 4). Each call now appends one byte and takes the file size as its count — single-byte + `O_APPEND` writes do not interleave, so at most `zone_gate_grace_calls` calls can observe a + count within budget, and the only residual error is over-denial, the conservative direction for + a gate. +- **`zone_gate_grace_calls` is parsed as base 10.** A digit-only value with a leading zero (`08`) + cleared validation but is an octal literal in Bash arithmetic: the comparison errored on the + invalid digit, evaluated false, and denied the FIRST call instead of allowing eight. The value + is now length-bounded and normalized once, which also keeps the deny reason and the telemetry + payload carrying a canonical decimal (`{"grace":08}` was invalid JSON). +- **`zone-crossing-inject.sh` fails open silently when the zone state file cannot be persisted** + (full or newly read-only filesystem). The write failure was previously swallowed (`|| true`), + so the hook fell through and compared the current zone against the same stale `last` on every + subsequent `PostToolBatch`/`UserPromptSubmit`, re-injecting the ~1KB guidance block every call + instead of once per transition — the worst time to spend extra context. The hook now emits + telemetry `status:error` and exits immediately on a persist failure instead of injecting. +- **`post-compact-mark.sh` reports the marker's actual write outcome in telemetry.** A failed + temp-file write or a failed atomic rename into place was swallowed, and the hook still emitted + telemetry `status:ok` — telling operators the evidence-degraded marker was recorded when + consumers will never see it. The write-and-rename result is now tracked and telemetry reports + `error` on either failure path; the hook still always exits 0 (PostCompact has no decision + control, so the marker's own success is signaled through telemetry, not the exit code). A + directory occupying the contract marker path counts as a persist failure for the same reason: + `mv` onto a directory SUCCEEDS by moving the temp file inside it, so the hook reported `ok` + while consumers found nothing readable at the path. The rename is now refused up front, which + also stops a temp file being stranded in that directory on every compaction. +- **Both stateful hooks fail open instead of writing state into the working directory.** + `zone-gate.sh` and `zone-crossing-inject.sh` resolved their state root as + `${CLAUDE_PLUGIN_DATA:-${HOME:-.}/.claude/context-guard}`, so with neither variable set the + blocking gate's grace counter and the injector's last-seen zone landed under `./.claude/` — + relative to whatever directory the hook process happened to start in. A counter that resets + with the working directory is not a budget, and a last-seen zone that moves with it cannot + hold the once-per-transition contract (the injector would re-emit on every `cd`). Both now + require an explicit root and exit 0 without it, matching the doctrine `post-compact-mark.sh` + already applied to its marker path. `HOME` is set by Claude Code in practice, so this changes + no normal session. +- **The handoff exemption's path extraction uses the file's own jq helper.** `zone-gate.sh` read + the target path with an open-coded `jq -r … <<<"$INPUT"` while its other two extractions went + through `hook::jq_field`; it now uses the helper too, which is the idiom for whole-payload + reads, CR-strips the value, and keeps the exemption path off bash's here-string size heuristic. + No behavior change was observed — a 200KB here-string completes on bash 5.3.9 (Cygwin), which + routes an over-capacity here-string through a temp file rather than a pipe — so this is + consistency, not a hang fix. A 70KB handoff-path Write is now covered end-to-end, which does + exercise the chunked payload drain. + ## [0.3.0] ### Added diff --git a/plugins/context-guard/README.md b/plugins/context-guard/README.md index 07229c6218..43b2aefb68 100644 --- a/plugins/context-guard/README.md +++ b/plugins/context-guard/README.md @@ -2,7 +2,7 @@ A Claude Code plugin that makes each session's context-window usage observable to any session or tool that needs it — so long-running workflows can route heavy work away from a degraded context -**before** quality slips, instead of guessing. Four parts: +**before** quality slips, instead of guessing. Five parts: - **Statusline shim** (`scripts/statusline-shim.sh`) — the durable wiring target. Installed once to `~/.claude/context-guard/bin/`, it resolves whichever tee version is installed at run time, so a @@ -14,10 +14,22 @@ tool that needs it — so long-running workflows can route heavy work away from `~/.claude/context-guard/context/.json`, then passes your statusline through byte-for-byte. With no statusline configured it doubles as a minimal standalone statusline. - **Zone resolver** (`scripts/context-zone.sh`) — `context-zone.sh ` prints exactly one - word: `smart` / `acceptable` / `dumb` / `unknown`. Bands come from the machine-scope - `~/.claude/context-guard/zones.json` when present and valid, else from shipped defaults - (smart ≤ 50 < acceptable ≤ 75 < dumb, over `used_percentage`). Zones say *where you are*; - consumers decide *what to do*. + word: `smart` / `acceptable` / `dumb` / `unknown`. Two band shapes, combined conservatively (the + worse computable zone wins): percentage bands over `used_percentage` (shipped defaults + smart ≤ 50 < acceptable ≤ 75 < dumb) and window-class token bands over occupancy + (`total_input_tokens + total_output_tokens`; shipped defaults 100k/160k on a 200k window, + 200k/400k on a 1M window). Bands come from the machine-scope + `~/.claude/context-guard/zones.json` when present and valid, else from the shipped defaults. + Zones say *where you are*; consumers decide *what to do*. +- **Zone-crossing hooks** (`hooks/`) — the first shipped consumer. Once per transition into a + worse zone, a PostToolBatch/UserPromptSubmit hook injects continuation guidance (advisory; + silent on unchanged, improving, or `unknown` zones). A PostCompact hook writes an + evidence-degraded marker next to the session's snapshot, and both zone consumers honor it: a + compacted session's effective zone is dumb regardless of its post-compaction numbers. An + optional **blocking** mode (`zone_hook_mode` userConfig) adds a PreToolUse gate that denies new + Write/Edit/NotebookEdit/Agent/Workflow calls on a fresh dumb-zone snapshot past a grace budget — + fail-open on `unknown`, with handoff-path writes, reads, Bash, and Skill invocations never + gated, so a durable handoff is always writable. - **Reader contract** (`reference/reader-contract.md`) — the authoritative consumer contract: the snapshot path pattern, file shape, the 10-minute staleness rule, fail-open capability detection, the zones.json shape, session-id discovery via `${CLAUDE_SESSION_ID}`, and the @@ -87,19 +99,22 @@ governs how often it runs. ## Configuration -No `userConfig`. The snapshot path and the 10-minute staleness rule are deliberately **not** -configurable: they are contract constants that cross-plugin consumers inline from the -[reader contract](reference/reader-contract.md); a per-user override would silently split the -writer from its readers. Band numbers are the one tunable — via `~/.claude/context-guard/zones.json` -(shape in the reader contract), which the operator's own statusline display may read too, so -display and consumers never drift. Disabling the tee is the operator's edit (remove or unwrap the -statusline command); disabling everything is `enabledPlugins` / uninstall. +Three `userConfig` options, all hook-scoped: `context_guard_hooks_enabled` (kill switch, default +true), `zone_hook_mode` (`advisory` default | `blocking`), and `zone_gate_grace_calls` (blocking +mode's grace budget, in-script default 20). The snapshot path and the 10-minute staleness rule are +deliberately **not** configurable: they are contract constants that cross-plugin consumers inline +from the [reader contract](reference/reader-contract.md); a per-user override would silently split +the writer from its readers. Band numbers are the one tunable — via +`~/.claude/context-guard/zones.json` (shape in the reader contract), which the operator's own +statusline display may read too, so display and consumers never drift. Disabling the tee is the +operator's edit (remove or unwrap the statusline command); disabling everything is +`enabledPlugins` / uninstall. ## Consumers -First consumer: the `plugin-quality` audit skill (zone-informed dispatch and evidence-flush -decisions, conservative on `unknown`). Any session or tool on the machine may read the same files -under the same contract. +The plugin's own zone-crossing hooks are the first shipped consumer. Next: the `plugin-quality` +audit skill (zone-informed dispatch and evidence-flush decisions, conservative on `unknown`). Any +session or tool on the machine may read the same files under the same contract. ## License diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh new file mode 100755 index 0000000000..0fdcce4ba4 --- /dev/null +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -0,0 +1,1425 @@ +# shellcheck shell=bash +# Shared hook utility library for this marketplace's hook plugins. Sourced +# (not executed): kill switch, file_path parsing + path normalization, +# repo-root resolution, additionalContext accumulator, telemetry envelope. +# +# SINGLE SOURCE OF TRUTH: lib/hook-utils.sh at the marketplace repo root. The +# copies at plugins/*/hooks/hook-utils.sh exist because installed plugins are +# cache-isolated and must be self-contained — never edit a copy. Edit the +# source and run scripts/sync-hook-utils.sh; CI rejects drifted copies. + +# Guard against double-sourcing. +[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0 +readonly _HOOK_UTILS_LOADED=1 + +# Per-hook kill switch via the plugin's _enabled userConfig boolean, +# read from the hook-process CLAUDE_PLUGIN_OPTION__ENABLED mirror. +# Exits 0 (allow) if disabled. Place after source, before stdin parsing. +# hook::check_enabled "MARKDOWN_FORMAT" # checks CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED +hook::check_enabled() { + local var_name="CLAUDE_PLUGIN_OPTION_${1}_ENABLED" + if [[ "${!var_name:-true}" != "true" ]]; then + exit 0 + fi +} + +# --- Prerequisite visibility -------------------------------------------------- +# Doctrine: a missing runtime prerequisite must surface to BOTH the agent +# (additionalContext) and the user (systemMessage) — a silently skipped feature +# is a defect. Everything in this section is jq-FREE by design: the most common +# missing prerequisite is jq itself. + +# JSON-escape a string for embedding in a hand-built JSON document. Escapes +# backslash, double quote, and the line-structure control bytes by name +# (\n \r \t); the remaining C0 bytes JSON forbids raw are dropped — notice text +# never carries meaningful control bytes beyond line structure. Byte-safe under +# UTF-8: every escaped byte is ASCII, and UTF-8 continuation bytes are >= 0x80. +hook::json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + # tr drops the residual C0 bytes; if tr itself is unavailable, fall back to + # the escaped string as-is — notice text is hook-authored and does not carry + # raw control bytes in practice. + local out + out=$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037' 2>/dev/null) || out="$s" + printf '%s' "$out" +} + +# Emit hook JSON carrying an agent-channel context (additionalContext) and/or a +# user-channel message (systemMessage) as ONE document — CC parses the hook's +# whole stdout as a single JSON doc, so a run that has both lint findings and a +# pending skip notice must compose them here rather than print twice. Either +# channel may be empty; emits nothing when both are. +# hook::emit_channels PostToolUse "$ctx" "$sysmsg" +hook::emit_channels() { + local event="$1" ctx="$2" sysmsg="$3" + [[ -n "$ctx" || -n "$sysmsg" ]] || return 0 + local out="{" + if [[ -n "$ctx" ]]; then + out+='"hookSpecificOutput":{"hookEventName":"'"$(hook::json_escape "$event")"'","additionalContext":"'"$(hook::json_escape "$ctx")"'"}' + [[ -n "$sysmsg" ]] && out+="," + fi + [[ -n "$sysmsg" ]] && out+='"systemMessage":"'"$(hook::json_escape "$sysmsg")"'"' + out+="}" + printf '%s\n' "$out" +} + +# Visible skip notice: the same message on both channels. The caller must exit 0 +# right after unless it composes via hook::emit_channels itself. +# hook::emit_skip_notice PostToolUse "my-plugin: tool X not found — ..." +hook::emit_skip_notice() { + hook::emit_channels "$1" "$2" "$2" +} + +# systemMessage-only variant for hook events with no additionalContext channel +# (e.g. Notification). +hook::emit_system_message() { + hook::emit_channels "" "" "$1" +} + +# Once-per-session gate for skip notices. Returns 0 (emit now) the first time a +# given fires in the current session, 1 afterwards — a missing-tool notice +# behind a broad matcher (every Write|Edit) must not repeat on every edit. The +# session id is regex-extracted from the raw hook input JSON (jq-free, see +# section header); marker files live under ${CLAUDE_PLUGIN_DATA} (survives +# plugin updates; mkdir -p defensively since creation is documented only on +# first *reference*) and markers older than 7 days are pruned so per-session +# files cannot accumulate unboundedly. Fails open toward visibility: when no +# marker can be tracked, emit every time. +# hook::notice_once "my-plugin-jq" "$INPUT" && hook::emit_skip_notice ... +hook::notice_once() { + local key="$1" input="${2:-}" session="no-session" + if [[ "$input" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then + session="${BASH_REMATCH[1]}" + session="${session//[^A-Za-z0-9_-]/-}" + fi + local dir="${CLAUDE_PLUGIN_DATA:-}" + [[ -n "$dir" ]] || return 0 + dir="$dir/skip-notices" + mkdir -p "$dir" 2>/dev/null || return 0 + find "$dir" -type f -mtime +7 -delete 2>/dev/null + local marker="$dir/${key}.${session}" + [[ -f "$marker" ]] && return 1 + : >"$marker" 2>/dev/null + return 0 +} + +# Best-effort jq-free extraction of tool_input.file_path from the raw hook +# input, for the applicability pre-filter an extension-scoped hook runs BEFORE +# its jq gate — a missing-jq notice must never fire for an edit the hook would +# not process anyway (e.g. a README edit reaching a workflow-lint hook whose +# Write|Edit matcher is broader than its file filter). The value is returned +# JSON-escaped (backslashes doubled); that is fine for extension/segment +# matching, which is all the pre-filter does. Returns 1 when no file_path is +# present. +# RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0 +hook::raw_file_path() { + [[ "$1" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]] || return 1 + [[ -n "${BASH_REMATCH[1]}" ]] || return 1 + printf '%s' "${BASH_REMATCH[1]}" +} + +# jq gate for hooks whose input parsing cannot proceed without it. When jq is +# absent: one visible skip notice per session, then exit 0 — an advisory hook +# never blocks the tool over a missing prerequisite. Place after +# hook::check_enabled (and after any jq-free applicability pre-filter), passing +# the buffered stdin for session scoping. +# hook::require_jq PostToolUse my-plugin "$INPUT" +hook::require_jq() { + command -v jq >/dev/null 2>&1 && return 0 + local event="$1" plugin="$2" input="${3:-}" + if hook::notice_once "${plugin}-jq" "$input"; then + hook::emit_skip_notice "$event" \ + "$plugin: jq not found on PATH — hook skipped for this session. Install jq (https://jqlang.org/download/) to enable it." + fi + exit 0 +} + +# Normalize a path for the membership comparison below: backslashes → forward +# slashes, and — only on Windows/MSYS, whose filesystem is case-insensitive — +# fold a leading drive (POSIX `/c/...` or `c:/...`) to an upper-case drive +# letter + lower-cased remainder so the byte-exact comparison is effectively +# case-insensitive. The fold is gated on the host (OSTYPE), NOT on the path +# shape: on a case-sensitive POSIX filesystem a real single-letter top-level +# directory such as `/c/Repo` must pass through unchanged, otherwise it would +# collapse with `/c/repo` and the membership guard would admit a sibling +# outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the +# emitted path is always the caller's original. +hook::normalize_path() { + local p="${1//\\//}" + case "${OSTYPE:-}" in + msys* | cygwin* | win32) + if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then + local rest="${p:2}" + printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" + return + fi + ;; + *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below + esac + printf '%s' "$p" +} + +# Expand Windows 8.3 short-name components (KYLESE~1 → KyleSexton) on +# Windows/MSYS hosts, where GNU realpath resolves symlinks but leaves short +# names as-is. Without this a short-form file_path — the shape Claude Code's +# own scratchpad paths take — fails the membership prefix comparison below and +# an IN-project file is silently skipped. 8.3 generation is a PER-VOLUME +# property (`fsutil 8dot3name query `): a checkout on a volume that +# generates short names hits this constantly while one on a non-generating +# volume can never reproduce it, so the guard must not assume either. +# +# `cygpath -m` (form conversion only) is compared against `cygpath -l -m` +# (long names via Win32), and the path is replaced only when the two DIFFER — +# a legitimate long name that merely contains '~' (foo~bar.md) converts +# identically both ways and passes through byte-for-byte untouched. A genuine +# expansion returns mixed form (C:/...); the membership comparison normalizes +# both sides, so the form change is absorbed, and any such path failed the +# comparison outright before this expansion existed. Fail-open on this host +# class: cygpath ships with Git Bash (the documented Windows bash), so its +# absence or failure keeps the resolver's answer unchanged — degrading to the +# pre-expansion comparison, same doctrine as the resolver fallback below. +hook::expand_8dot3() { + local p="$1" + case "${OSTYPE:-}" in + msys* | cygwin* | win32) ;; + *) + printf '%s' "$p" + return + ;; + esac + if [[ "$p" == *~* ]] && command -v cygpath >/dev/null 2>&1; then + local plain long + if plain=$(cygpath -m -- "$p" 2>/dev/null) && + long=$(cygpath -l -m -- "$p" 2>/dev/null) && + [[ -n "$long" && "$long" != "$plain" ]]; then + printf '%s' "$long" + return + fi + fi + printf '%s' "$p" +} + +# Canonicalize to a physical path — symlinks resolved, Windows 8.3 short names +# expanded — for the membership comparison below, so an in-project symlink +# pointing outside the project root cannot defeat the guard (the lexical path +# would pass the prefix check while the write lands elsewhere) and a short-form +# spelling of an in-project path cannot dodge it (the long-form prefix would +# never match). GNU realpath ships with Git Bash and Linux coreutils; +# readlink -f covers the BSD/macOS hosts that have no realpath. When neither +# resolver exists the caller falls back to comparing the lexical path as +# before — the guard is defense-in-depth scoping for a file the agent already +# wrote via its own tools, so degrading to the historical comparison beats +# silently disabling the hook on those hosts. The 8.3 expansion applies only +# on the resolver's success path: an unchanged return is the documented +# signature of failed canonicalization, and consumers that fail closed on that +# signature must not see a form-converted path instead. +hook::physical_path() { + local resolved + if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then + if [[ -n "$resolved" ]]; then + hook::expand_8dot3 "$resolved" + return + fi + fi + printf '%s' "$1" +} + +# Parse file_path from PostToolUse JSON on stdin; validate existence and (when +# CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership +# comparison are canonicalized (symlinks resolved) first, so neither an +# escaping symlink nor a project root reached via a symlinked path (e.g. +# macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. +# FILE=$(hook::read_file_path) || exit 0 +hook::read_file_path() { + local file + file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + [[ -n "$file" ]] || return 1 + [[ -f "$file" ]] || return 1 + if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + local norm_file norm_project + norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") + norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + norm_project="${norm_project%/}" + # Anchor on a path-segment boundary: accept the project root itself or a + # child under it, but not a sibling whose name merely shares the prefix + # (e.g. /c/repo must not admit /c/repo-backup/...). + if [[ "$norm_file" != "$norm_project" && "$norm_file" != "$norm_project"/* ]]; then + return 1 + fi + fi + printf '%s' "$file" +} + +# Resolve the repository root (working-tree top) for a path inside the tree. +# markdownlint config auto-discovery is CWD-anchored, so the hook cd's here +# before linting. File-anchored (`git -C "$hint" rev-parse --show-toplevel`) +# so it is correct for clones, linked worktrees, and bare-hub clones; falls +# back to the hint (with a trailing /.claude stripped) when git cannot resolve. +# ROOT=$(hook::repo_root "$some_path") +hook::repo_root() { + local hint="${1:-.}" + local root + root=$(git -C "$hint" rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + if [[ -z "$root" ]]; then + root="$hint" + root="${root%/.claude}" + root="${root%\\.claude}" + fi + printf '%s' "$root" +} + +# Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe +# late-EOF stalls via a bounded read on the inherited fd0. Returns the payload +# on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the +# read stalled before a complete JSON payload arrived (caller may block). +# +# The bound (stdin_read_timeout userConfig option, in seconds, read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2) is an IDLE bound, not a +# total one: only a window in which NOTHING arrives ends the read. A single +# `read -d ''` bounded by -t was a total bound, and because bash consumes +# `read -d ''` on a pipe one byte at a time (~32 KB/s on Git Bash) that made it +# a ~64 KB THROUGHPUT ceiling — every larger payload tripped the timeout branch, +# so fail-closed callers blocked a legitimate write and fail-open callers +# skipped silently. Two things together make the bound mean what it says: +# +# * The read is chunked. `read -N` lets bash satisfy it in blocks instead of +# byte-at-a-time: 50 KB drops from ~2100 ms to ~20 ms, 200 KB from ~6800 ms +# to ~85 ms. +# * The timer measures inactivity, not the read. `read -t` is a deadline for +# the WHOLE requested read, so a producer that keeps delivering but slower +# than one chunk per window would still trip it. `read` assigns whatever it +# did receive even when it times out, so any byte counts as progress: the +# loop keeps that partial chunk and reads on. Only the absence of bytes for a +# whole stdin_read_timeout is a stall. +# * The bound is read in HOOK_STDIN_READ_SLICES slices. `read -t` reports only +# that its window expired, never WHEN inside it the last byte arrived, so a +# bound armed as one window would declare a stall anywhere between one and +# TWO bounds after the pipe actually went quiet. Slicing bounds that +# overshoot: with four slices a stall lands within a quarter-bound of the +# configured interval. That residual quarter is the honest limit of the +# approximation, and it errs toward waiting — never toward declaring a live +# producer dead. On a shell whose `read -t` rejects the fractional slice the +# count degrades to 1, i.e. the unsliced one-to-two-bound behavior. +# +# Reading on is skipped once the buffer already parses as whole JSON, so the +# late-EOF case costs ONE slice past the payload rather than the rest of the +# bound — a producer holding the pipe open cannot be distinguished from a slow +# one until a window expires, so some wait there is the floor. +# +# The trade this makes: a producer trickling bytes indefinitely is never cut off +# here. That is deliberate — the harness already caps a `command` hook at 600 s +# by default (https://code.claude.com/docs/en/hooks), and blocking a live +# producer is exactly the failure this function had. +# +# `read` reports which stop condition it hit — EOF returns 1, an exceeded -t +# returns >128 — so the loop takes the verdict off $? rather than inferring it +# from elapsed-time arithmetic. jq (when present) is still the completeness +# backstop: a stall that nevertheless delivered a complete payload is the Win32 +# late-EOF case this function exists for and must succeed, not block. A +# missing/broken jq (exit 127) fails open like absent jq. +# +# `read -N` is Bash 4.1+; macOS ships Bash 3.2 and these hooks document 3.2+ +# support, so the pre-4.1 branch falls back to the delimiter read, which already +# reads to EOF and is fast enough on native POSIX pipes. Same guard and same +# rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming +# loop wraps both forms, so 3.2 gets the progress semantics too — just in +# byte-at-a-time-sized steps. +# INPUT=$(hook::buffer_stdin) || exit 0 + +# The `read -N` availability guard, split out as its own predicate so the +# pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is +# readonly, so it cannot be shadowed, but a test can override this function +# after sourcing. Not a consumer seam — nothing reads it from the environment. +hook::read_supports_nchars() { + ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))) +} + +# Is the buffered text already a complete JSON document? Lets the read stop the +# moment the payload is whole instead of spending another idle window waiting +# for an EOF a Win32 pipe may never deliver. Returns non-zero when jq is +# unavailable or broken (exit 127) as well as when the text is incomplete — the +# caller must keep reading rather than guess, and the caller's own fail-open +# handling for absent jq is unaffected. +hook::json_complete() { + # Structural pre-filter before paying for a jq process: a hook payload is a + # JSON object, so a complete one ends in `}` (possibly with trailing newline + # or CR). Testing the last few characters is O(1) and skips the spawn for + # every mid-payload buffer, which is what keeps this off the hot path of a + # large or slow read. A false negative here costs only the early break — the + # read continues and the caller's final completeness check still decides — so + # the pre-filter can never turn a whole payload into a wrong verdict. + [[ "${1: -4}" == *"}"* ]] || return 1 + command -v jq >/dev/null 2>&1 || return 1 + # `printf | jq`, never `jq <<< "$1"`. A here-string is delivered through a pipe + # that bash fills itself, so a payload at or above the pipe capacity (65536 + # bytes on this platform — exactly one read chunk) blocks the shell forever + # before jq is ever exec'd. Reproduced: a 65536-byte buffer hung here + # indefinitely while 65000 returned immediately. A separate writer process + # cannot deadlock that way. + printf '%s' "$1" | jq -e . >/dev/null 2>&1 +} + +# Resolve the read timeout to a value THIS shell's `read -t` will actually +# accept, falling back to the documented default of 2 otherwise. +# +# The configured value reaches `read -t` directly, and an unusable one is not a +# tuning mistake — it is a silent disable. `read` rejects a bad spec with rc 1 +# plus a usage error on stderr for EVERY hook invocation; the buffer loop reads +# rc 1 as EOF, produces an empty payload, and every caller skips. `0` is worse +# still: it makes `read` return immediately having consumed nothing, which would +# spin the loop. +# +# Acceptance is settled by PROBING this shell rather than consulting a version +# table: which spellings `read -t` accepts varies across the Bash releases these +# hooks support (fractional values are not universally available, and the +# upstream changelog does not date their introduction), so asking the running +# shell is exact where a version check would be a guess. Reading /dev/null hits +# EOF immediately, so a valid timeout produces no stderr at all. The probe is +# skipped for the default, which is known-good everywhere. +hook::resolve_read_timeout() { + local t="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" + if [[ "$t" != "2" ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$t" discard &1) + if ! [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$t" =~ ^0+(\.0+)?$ ]] || [[ -n "$probe" ]]; then + t=2 + fi + fi + printf '%s' "$t" +} + +# How many slices the idle bound is divided into. `read -t` reports only that a +# window expired, never WHEN inside it the last byte arrived, so a bound armed as +# one window declares a stall anywhere between one and two bounds after the pipe +# actually went quiet. Asking more often shrinks that: with N slices, a stall is +# declared within one slice of the configured interval. Four is the compromise — +# it cuts worst-case overshoot from 100% of the bound to 25% while keeping the +# idle path to four cheap builtin reads. +HOOK_STDIN_READ_SLICES=4 + +# Resolve the per-read slice for an already-resolved timeout, printing +# " ". Falls back to " 1" — exactly the unsliced +# behavior — when this shell's `read -t` will not accept the fractional slice, +# which is the pre-4.1/no-fractional-timeout case the delimiter-read branch +# already covers. Probed, not version-tested, for the same reason as +# hook::resolve_read_timeout. +hook::resolve_read_slice() { + local t="$1" slice + slice=$(awk -v t="$t" -v n="$HOOK_STDIN_READ_SLICES" \ + 'BEGIN { printf "%.3f", t / n }' 2>/dev/null) || slice="" + if [[ -n "$slice" && "$slice" =~ ^[0-9]+\.[0-9]+$ ]] && ! [[ "$slice" =~ ^0+\.0+$ ]]; then + local probe + # shellcheck disable=SC2034 # `discard` is the read target; only stderr matters + probe=$(read -r -t "$slice" discard &1) + if [[ -z "$probe" ]]; then + printf '%s %s' "$slice" "$HOOK_STDIN_READ_SLICES" + return 0 + fi + fi + printf '%s 1' "$t" +} + +hook::buffer_stdin() { + local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 + local read_timeout read_slice slice_count + read_timeout=$(hook::resolve_read_timeout) + read -r read_slice slice_count < <(hook::resolve_read_slice "$read_timeout") + local -a read_opts=(-r -t "$read_slice") + if hook::read_supports_nchars; then + read_opts+=(-N 65536) + else + read_opts+=(-d '') + fi + while :; do + chunk="" + read_rc=0 + # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array + IFS= read "${read_opts[@]}" chunk || read_rc=$? + input+="$chunk" + # Any byte at all resets the idle count — that, not the read's exit status, + # is what makes this an idle timer rather than a per-read deadline. + [[ -n "$chunk" ]] && idle_slices=0 + if ((read_rc == 0)); then + # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL + # read that consumed nothing, however, cannot make progress, so continuing + # would spin: break instead. hook::resolve_read_timeout already excludes + # the only known way to reach that (`read -t 0`, which returns success + # without consuming); this keeps loop termination a structural property + # rather than a consequence of validation staying correct. + [[ -n "$chunk" ]] || break + continue + fi + if ((read_rc > 128)); then + # A slice expired. Bytes in it mean the producer is alive: keep them and + # read on. Only slice_count CONSECUTIVE empty slices — one whole + # stdin_read_timeout with nothing arriving — is the stall this guard + # exists to catch, which is why the count is not reset here. + if [[ -n "$chunk" ]]; then + # ... but stop immediately if what we already hold is a whole JSON + # document. That is the Win32 late-EOF case — the payload arrived, the + # pipe just never closed — and reading on there would spend the rest of + # the bound waiting for an EOF that is not coming. + hook::json_complete "${input//$'\r'/}" && break + continue + fi + # An EMPTY slice can also be the late-EOF case: the payload may have been + # completed by the PREVIOUS read, which returned rc 0 and so never reached + # the completeness check above. That happens whenever the payload ends on a + # 65536-character boundary, and without this the helper would wait out the + # whole bound instead of a single slice. Checking here rather than on the + # rc-0 path keeps jq off the hot path — a large payload costs one check + # when the producer first pauses, not one per 64 KB chunk. + # + # Only on the FIRST empty slice of a quiet period: the buffer cannot grow + # while nothing is arriving, so re-checking an unchanged buffer would spend + # a jq process per slice to re-derive the same answer — enough overhead on + # a slow-spawning host to cost more than slicing saves. idle_slices resets + # the moment a byte lands, so the next quiet period checks again. + ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}" && break + ((idle_slices++)) + ((idle_slices >= slice_count)) || continue + stalled=1 + fi + break # EOF (rc 1), a full idle bound with no bytes, or a read error + done + input=$(printf '%s' "$input" | tr -d '\r') + [[ -n "$input" ]] || return 1 + local jq_rc=0 + if command -v jq >/dev/null 2>&1; then + # `printf | jq`, not a here-string — see hook::json_complete: a here-string + # at or above the pipe capacity deadlocks the shell before jq is exec'd, and + # a hook payload routinely exceeds it. + printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + fi + if ((jq_rc != 0 && jq_rc != 127)); then + if ((stalled)); then + echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 + return 2 + fi + return 1 + fi + printf '%s' "$input" +} + +# Extract a single jq field from a buffered input string. CR-stripped. Returns 1 +# when the field is empty or jq fails, so the caller can skip. +# +# Fed through `printf | jq`, never a here-string: bash fills a here-string's pipe +# itself, so a payload at or above the pipe capacity (65536 bytes here) blocks +# before jq is exec'd. Callers pass the WHOLE buffered hook payload, which now +# routinely exceeds that — a bounded stdin read used to reject anything that +# large before it reached this helper. +# FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 +hook::jq_field() { + local field + field=$(printf '%s' "$1" | jq -r "(${2} // empty)"' | gsub("\r";"")' 2>/dev/null) + [[ -n "$field" ]] || return 1 + printf '%s' "$field" +} + +# Reduce a tool + optional Bash command to a privacy-safe subject label. For +# Bash, returns "Bash:" (leading sudo / VAR=val prefixes stripped, +# basename applied) — never the full command. For any other tool, returns the +# tool name unchanged. Carries no argument body, path, or command tail. +# +# Whitespace-splitting is only safe when no quoted value spans the whitespace. +# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a +# fragment of the value into the token, so any token carrying a quote aborts to a +# bare "Bash" subject rather than risk exposing part of the value. +# +# A bare or trailing unquoted assignment that no following command consumed +# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must +# not carry, so a resolved token still shaped like a NAME=value assignment aborts +# to the bare "Bash" subject too. +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +hook::extract_bash_subject() { + local tool="$1" cmd="${2:-}" + if [[ "$tool" != "Bash" ]]; then + printf '%s' "$tool" + return 0 + fi + # Trim leading whitespace so the first token is real. + cmd="${cmd#"${cmd%%[![:space:]]*}"}" + local first_token="${cmd%%[[:space:]]*}" + while [[ "$first_token" == "sudo" || "$first_token" == *=* ]] && + [[ -n "$cmd" && "$cmd" == *[[:space:]]* ]]; do + # A quote in the prefix token means a quoted value spans the next whitespace; + # we cannot tokenize it safely — bail rather than leak a value fragment. + if [[ "$first_token" == *[\"\']* ]]; then + printf '%s' "$tool" + return 0 + fi + cmd="${cmd#*[[:space:]]}" + cmd="${cmd#"${cmd%%[![:space:]]*}"}" + first_token="${cmd%%[[:space:]]*}" + done + # The resolved command token itself must not carry a quote (e.g. a value that + # ended here), which would likewise be a value fragment. + if [[ "$first_token" == *[\"\']* ]]; then + printf '%s' "$tool" + return 0 + fi + # A resolved token still shaped like a bare/trailing assignment (no following + # command word consumed it in the strip loop) would emit the assignment's + # value — a possible credential — as the subject; bail to the bare "Bash" + # subject as with a quoted value. All valid Bash assignment forms count: + # NAME=value, append NAME+=value, and subscripted NAME[idx]=value / + # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash + # accepts nested subscripts like NAME[1+IDX[0]]=value, which a + # no-close-bracket class would miss. This runs BEFORE the basename strip so + # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. + if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then + printf '%s' "$tool" + return 0 + fi + first_token="${first_token##*/}" + if [[ -n "$first_token" ]]; then + printf 'Bash:%s' "$first_token" + else + printf '%s' "$tool" + fi +} + +# Append one line to a JSONL file, serialized under an flock advisory lock when +# flock is present (bounded 2s wait; a lost race drops the line rather than +# blocking) and a best-effort bare append otherwise. Fire-and-forget: never +# fails the caller. Used by audit hooks that maintain a bespoke second store. +# hook::append_jsonl +hook::append_jsonl() { + local file="$1" line="$2" + if command -v flock >/dev/null 2>&1; then + ( + flock -w 2 9 || exit 0 + printf '%s\n' "$line" >>"$file" + ) 9>"${file}.lock" 2>/dev/null + else + printf '%s\n' "$line" >>"$file" 2>/dev/null + fi +} + +# Per-hook stdout context accumulator. ctx_reset at entry, ctx_append per line, +# ctx_flush once at exit with the hook event name. +_HOOK_CTX_BUFFER="" + +hook::ctx_reset() { + _HOOK_CTX_BUFFER="" +} + +hook::ctx_append() { + _HOOK_CTX_BUFFER+="$1"$'\n' +} + +# Emit the accumulated context as hookSpecificOutput JSON, then clear the buffer. +hook::ctx_flush() { + local event_name="$1" + local trimmed="${_HOOK_CTX_BUFFER%"${_HOOK_CTX_BUFFER##*[![:space:]]}"}" + trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" + hook::emit_additional_context "$event_name" "$trimmed" + hook::ctx_reset +} + +# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers +# gate telemetry-payload construction on this (repo-relative path +# normalization, data JSON) so the unwired default path spawns zero +# telemetry-only subprocesses. Pure shell test, no subprocess. +# hook::emit_telemetry re-checks the sink itself, so skipping this probe +# costs only wasted payload work, never correctness. +hook::telemetry_enabled() { + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] +} + +# Emit one telemetry envelope per hook run to the consumer-set sink. +# Fire-and-forget: sink is dispatched in the background; the hook never waits +# on it and its failure never affects the hook's own exit code or stdout. +# Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. +# Fail-open: jq absent → return 0 immediately. +# +# Usage: +# hook::emit_telemetry [repo_root] +# +# Value of $EPOCHREALTIME captured by the caller before work began. +# Handles both '.' and ',' as the decimal separator (LC_NUMERIC). +# Pre-built JSON object for the `data` field. +# Optional consuming-repo root, used to resolve a RELATIVE +# HOOK_TELEMETRY_SINK. The caller passes the root it already +# resolved for data.file; ignored when the sink is absolute. +# +# Sink path resolution: HOOK_TELEMETRY_SINK may be absolute OR relative to the +# consuming repo root. Absolute (POSIX /… or Windows X:\ / X:/) is used as-is; a +# relative value is joined onto (or $CLAUDE_PROJECT_DIR when no root +# is passed), and skipped fail-open if neither is available. Relative is the +# portable, team-shared wiring form: CC injects settings.json env values +# literally (no ${VAR} expansion), so a relative path tracked in settings.json is +# the only clone-portable, worktree-safe option. +# +# NEVER writes to fd1 (the hook's stdout / additionalContext channel). +hook::emit_telemetry() { + # Opt-in guard. + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 + # Fail-open when jq is absent. + command -v jq >/dev/null 2>&1 || return 0 + + local hook_id="$1" + local hook_event="$2" + local status="$3" + local start_epoch="$4" + local data_json="$5" + local repo_root="${6:-}" + + # Compute duration_ms from caller's $EPOCHREALTIME snapshot to now. + # Both '.' and ',' separators handled; 10# prefix prevents octal misreading + # of fractional parts with leading zeros (e.g. .045123 → 10#045123 = 45123). + # EPOCHREALTIME is Bash 5.0+; on an older host it (and the caller's start + # snapshot) is empty. Skip telemetry fail-open rather than abort under set -u — + # the same silent-skip the caller's `START=${EPOCHREALTIME:-}` guard intends. + local now=${EPOCHREALTIME:-} + [[ -n "$start_epoch" && -n "$now" ]] || return 0 + local s_s="${start_epoch%[.,]*}" s_f="${start_epoch#*[.,]}" + local e_s="${now%[.,]*}" e_f="${now#*[.,]}" + local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) + + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). + local timestamp + timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) + + # Build the envelope. Redirect jq stderr to /dev/null; output goes to a local + # variable — never to fd1. + local envelope + envelope=$(jq -n \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --argjson data "$data_json" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:$data}' \ + 2>/dev/null) || return 0 + + # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the + # consuming repo root (portable, tracked wiring); absolute is used as-is. A + # relative value with no anchor is skipped fail-open — never exec a path the + # drifted hook CWD would resolve incorrectly. + local sink="$HOOK_TELEMETRY_SINK" + case "$sink" in + /* | [A-Za-z]:[/\\]*) ;; + *) + local root="${repo_root:-${CLAUDE_PROJECT_DIR:-}}" + [[ -n "$root" ]] || return 0 + sink="${root%/}/$sink" + ;; + esac + + # Fire-and-forget: pipe the envelope to the sink in a background subshell. + # The subshell's stdout AND stderr are redirected to /dev/null so the sink + # cannot write to the hook's fd1 (the additionalContext channel) and the + # backgrounded subshell does not hold a copy of the hook's fd1 open — which + # would block any command substitution wrapping the hook until the sink exits + # (the "C1 fd1-inheritance blocker"). The sink is quoted — it is a single + # executable path (wrap in a script to pass arguments). + printf '%s\n' "$envelope" | ("$sink" >/dev/null 2>&1) & +} + +# Print cross-host hook JSON to stdout (exit 0). No-op when context is empty. +# Shape: { hookSpecificOutput: { hookEventName[, additionalContext] } }. +hook::emit_additional_context() { + local event_name="$1" + local context="$2" + [[ -n "$context" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + jq -n \ + --arg event "$event_name" \ + --arg ctx "$context" \ + '{hookSpecificOutput: ( + {hookEventName: $event} + + (if $ctx != "" then {additionalContext: $ctx} else {} end) + )}' +} + +# --------------------------------------------------------------------------- +# Argv-grammar-faithful Bash command parsing for git guards. The command is +# parsed the way the shell builds argv — top-level segments split on unquoted +# control operators, each tokenized into argv words honoring '…', "…", $'…' +# (ANSI-C), and backslash escapes — then a real git executable is resolved at +# the segment's command position past env-var assignments and known wrappers, +# and its subcommand resolved past git global options. +# +# Static matching over the literal command string only: shell variable and +# command substitution ($VAR, $(…)) are NOT evaluated. Guards built on this +# are friction against accidental/casual bypass, not a sandbox. + +# Decode an ANSI-C `$'…'` body to its literal bytes (\xHH, \NNN octal, \uHHHH, +# \n, \\, …). %-escaped so the body can never act as a printf format specifier; +# `--` guards a body that begins with `-`. Errors are swallowed (fail-open on a +# malformed body — the raw text still flows through the caller unchanged). +hook::ansi_c_decode() { + local b="${1//%/%%}" + # shellcheck disable=SC2059 # the body IS the format — that is how ANSI-C escapes decode; %-escaped above so it cannot inject a specifier + printf -- "$b" 2>/dev/null +} + +# Split a GNU `env -S` operand the way env does: whitespace-separated words +# honoring "…" and '…' quotes and backslash escapes — so a flag quoted inside +# the operand (`env -S 'git push "--force"'`) still surfaces as its unquoted +# argv word. env's $VAR expansion inside the operand is NOT evaluated (static +# analysis over the literal string — same residual as the segment tokenizer). +# Result in the global HOOK_ENV_S_WORDS array. +# shellcheck disable=SC2034 # result global is consumed by hook::git_resolve_index +# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape +hook::env_s_split() { + local s="$1" i c n=${#1} word="" have=0 + HOOK_ENV_S_WORDS=() + for ((i = 0; i < n; i++)); do + c="${s:i:1}" + case "$c" in + "'") + ((i++)) + while ((i < n)) && [[ "${s:i:1}" != "'" ]]; do + word+="${s:i:1}" + ((i++)) + done + have=1 + ;; + '"') + ((i++)) + while ((i < n)) && [[ "${s:i:1}" != '"' ]]; do + if [[ "${s:i:1}" == '\' ]] && ((i + 1 < n)); then + word+="${s:i+1:1}" + ((i += 2)) + continue + fi + word+="${s:i:1}" + ((i++)) + done + have=1 + ;; + '\') + if ((i + 1 < n)); then + word+="${s:i+1:1}" + ((i++)) + fi + have=1 + ;; + ' ' | $'\t') + if ((have)); then + HOOK_ENV_S_WORDS+=("$word") + word="" + have=0 + fi + ;; + *) + word+="$c" + have=1 + ;; + esac + done + ((have)) && HOOK_ENV_S_WORDS+=("$word") +} + +# Detect a `sh -c` style shell wrapper in a segment's argv: a shell at the +# command position (after leading env-var assignments) carrying a `-c` flag. +# On match, the command-string operand lands in HOOK_SHELL_C_OPERAND for the +# caller to re-parse with hook::bash_parse_segments — the operand is a full +# shell command (operators, quoting, everything), so re-parsing with the same +# tokenizer is the faithful treatment. Wrappers stacked in front of the shell +# (`sudo bash -c …`) are NOT resolved here — a documented residual of the +# static-matcher posture. A shell invoked on a script file (no -c) never +# matches: file contents cannot be inspected statically. +# shellcheck disable=SC2034 # result global is consumed by the sourcing guard +hook::shell_c_operand() { + local -a w=("$@") + local n=${#w[@]} i=0 b t has_c=0 + # Skip leading VAR=val assignments, mirroring the git resolver. + while ((i < n)) && [[ "${w[i]}" == *=* && "${w[i]}" != -* ]]; do ((i++)); done + ((i < n)) || return 1 + b="${w[i]##*/}" + b="${b##*\\}" + case "${OSTYPE:-}" in + msys* | cygwin* | win32) + b="${b,,}" + b="${b%.exe}" + ;; + *) ;; + esac + case "$b" in + bash | sh | zsh | dash | ksh | mksh) ;; + *) return 1 ;; + esac + ((i++)) + while ((i < n)); do + t="${w[i]}" + case "$t" in + --) + ((i++)) + break + ;; + # -o/-O (and +o/+O) consume a set/shopt operand; --rcfile/--init-file + # consume a startup-file operand (bash) — none of these ends the option + # scan, so `bash --rcfile /dev/null -c '…'` still reaches its -c. + -o | +o | -O | +O | --rcfile | --init-file) ((i += 2)) ;; + -*) + [[ "$t" =~ ^-[A-Za-z]+$ && "$t" == *c* ]] && has_c=1 + ((i++)) + ;; + *) break ;; + esac + done + ((has_c)) || return 1 + ((i < n)) || return 1 + HOOK_SHELL_C_OPERAND="${w[i]}" + return 0 +} + +# Does an argv word name the git executable? Basename compared exactly on +# POSIX; on Windows/MSYS also case-folded and `.exe`-stripped (mirrors the +# OS-gate in hook::normalize_path) so `GIT` / `git.exe` are caught there but a +# case-variant stays distinct on a case-sensitive POSIX filesystem. +hook::git_is_bin() { + local b="${1##*/}" + b="${b##*\\}" + case "${OSTYPE:-}" in + msys* | cygwin* | win32) + local lc="${b,,}" + lc="${lc%.exe}" + [[ "$lc" == "git" ]] + ;; + *) [[ "$b" == "git" ]] ;; + esac +} + +# Locate a real `git` executable at the segment's command position (after +# env-var prefixes and known wrappers), or return 1 when absent. Results go in +# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller +# must match on the rewritten words, so the index alone is not enough. +# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS +# HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# Leading `NAME=value` env-assignment prefixes and `env NAME=value` operands are walked +# PAST to reach the git token, but their values are not collected: a `--config-env` alias +# for the invoked subcommand is refused by SHAPE (hook::git_alias_expansion), so the +# resolver never needs to know what an environment variable holds. +# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape +# shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file +hook::git_resolve_index() { + HOOK_GIT_RESOLVED_WORDS=("$@") + HOOK_GIT_RESOLVED_GI=-1 + # shellcheck disable=SC2178 # nameref to the array result global, not a string assignment + local -n w=HOOK_GIT_RESOLVED_WORDS + local n=${#w[@]} i=0 tok + + while ((i < n)); do + tok="${w[i]}" + if [[ "$tok" == *=* ]]; then + # A leading NAME=value token is a command-line env-assignment prefix; skip it to + # reach the git token (the shell treats only a valid-name assignment as such, but + # skipping any `*=*` word here is harmless — a non-assignment command word never + # contains an unquoted `=` at argv position 0 in a real invocation). + ((i++)) + continue + fi + + case "${tok##*/}" in + env) + # env [OPTION]... [--] [NAME=VALUE]... [COMMAND ...]: options first, then + # operand assignments, then the command. `--` ends option parsing (so a + # following leading-dash operand like `-AV=…` is an assignment, not an + # option). Unlike a shell prefix, env sets any name — collect every operand + # assignment regardless of name shape so a hyphenated/leading-dash name git + # reads via --config-env is captured, not dropped. + ((i++)) + local env_past_optmark=0 + while ((i < n)); do + if ((env_past_optmark == 0)) && [[ "${w[i]}" == -* ]]; then + case "${w[i]}" in + --) + ((i++)) + env_past_optmark=1 + ;; + # -S/--split-string re-splits its operand into argv (GNU env), so a + # quoted 'git commit --no-verify' would otherwise hide from the + # resolver as one non-git word. Splice the split words back into the + # scan and restart at the command position. + -S | --split-string) + local sval="" + ((i + 1 < n)) && sval="${w[i + 1]}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + elif [[ "${w[i]}" == *=* ]]; then + # An `env NAME=value` operand — skip it to reach the command (git). Its value + # is never read: a --config-env alias is refused by shape, not resolved. + ((i++)) + else + break + fi + done + continue + ;; + nice | nohup) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + -n | --adjustment) ((i += 2)) ;; + --adjustment=*) ((i++)) ;; + -*) ((i++)) ;; + *) break ;; + esac + done + if ((i < n)) && [[ "${w[i]}" =~ ^-?[0-9]+$ ]]; then + ((i++)) + fi + continue + ;; + sudo) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + -u | -g | -h | -p | -C | -D | -R | -T | --user | --group | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + done + continue + ;; + timeout) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + -s | --signal | -k | --kill-after) ((i += 2)) ;; + --preserve-status | --foreground | --verbose) ((i++)) ;; + -*) ((i++)) ;; + *) break ;; + esac + done + if ((i < n)) && [[ "${w[i]}" =~ ^[0-9]+([.][0-9]+)?(s|m|h|d)?$ ]]; then + ((i++)) + fi + continue + ;; + # eval concatenates and re-executes its arguments, so for the unquoted + # form (`eval git commit ...`) scanning the following words is exact. + # command/exec carry their own options before the real command + # (`command [-pVv]`, `exec [-cl] [-a name]`) and an optional `--` + # end-of-options marker (`command -- git …`) — skip them so the git + # command behind the wrapper is still resolved. But `command -v`/`-V` + # only PRINT the command's path/description — nothing runs — so a git + # word behind them is a probe, not an invocation: bail out. + command | exec | builtin | eval | !) + local is_command=0 + [[ "${tok##*/}" == "command" ]] && is_command=1 + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* && "${w[i]}" != "--" ]]; do + if ((is_command)) && [[ "${w[i]}" == -*[vV]* ]]; then + return 1 + fi + [[ "${w[i]}" == "-a" ]] && ((i++)) + ((i++)) + done + ((i < n)) && [[ "${w[i]}" == "--" ]] && ((i++)) + continue + ;; + time) + ((i++)) + if ((i < n)) && [[ "${w[i]}" == "-p" ]]; then + ((i++)) + fi + continue + ;; + # Compound-command reserved words at the execution position: a command + # can directly follow any of these within one segment (`if git …`, + # `then git …`, `do git …`), so skip them and keep resolving. + if | then | elif | else | while | until | for | do | case | select | coproc | '{' | '}') + ((i++)) + continue + ;; + *) + if hook::git_is_bin "$tok"; then + HOOK_GIT_RESOLVED_GI=$i + return 0 + fi + return 1 + ;; + esac + done + return 1 +} + +# Resolve the git subcommand in an already-resolved segment: walk words after +# the git executable, skipping git global options. The listed options consume +# the FOLLOWING word as their value (two-word form); their =-forms and every +# other option are single words handled by the generic `-*` skip. Results in +# globals: +# HOOK_GIT_SUB — the subcommand word ("" when none found) +# HOOK_GIT_SUB_IDX — its index in the argv (-1 when none) +# HOOK_GIT_CONFIG_VALUES — values of -c/--config/--config-env options, in +# order, so a guard can inspect config assignments +# without re-walking (commit messages and pathspecs +# are never collected here) +# HOOK_GIT_CONFIG_VALUE_KINDS — parallel to HOOK_GIT_CONFIG_VALUES (1:1 by +# index): "inline" for a -c/--config value (the literal +# assignment) or "env" for a --config-env value (whose +# operand is `=`, an environment-variable +# NAME, not the value). An env-kind alias for the invoked +# subcommand is REFUSED by shape (hook::git_alias_expansion), +# never resolved — the value is deliberately never read. +# Call as: hook::git_resolve_subcommand +# shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file +hook::git_resolve_subcommand() { + local gi="$1" + shift + local -a w=("$@") + local nseg=${#w[@]} j gw + HOOK_GIT_SUB="" + HOOK_GIT_SUB_IDX=-1 + HOOK_GIT_CONFIG_VALUES=() + HOOK_GIT_CONFIG_VALUE_KINDS=() + + j=$((gi + 1)) + while ((j < nseg)); do + gw="${w[j]}" + case "$gw" in + -c | --config) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + } + ((j += 2)) + ;; + --config-env) + ((j + 1 < nseg)) && { + HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + } + ((j += 2)) + ;; + --config=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("inline") + ((j++)) + ;; + --config-env=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + HOOK_GIT_CONFIG_VALUE_KINDS+=("env") + ((j++)) + ;; + -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) + ((j += 2)) + ;; + -*) + ((j++)) + ;; + *) + HOOK_GIT_SUB="$gw" + HOOK_GIT_SUB_IDX=$j + return 0 + ;; + esac + done + return 1 +} + +# Classify how a guard should treat the alias for the invoked subcommand, from the +# config values collected by hook::git_resolve_subcommand. git reads TWO spellings as the +# alias for a subcommand — `alias.` and its `alias..command` subkey (the only +# alias subkey git reads) — and which spelling wins when both are set is git-version- +# dependent. Rather than model that precedence (and risk a benign value in one spelling +# masking a dangerous value in the other on a git that resolves it the opposite way), this +# classifier fails closed on the MAX-DANGER UNION of the two spellings: the LAST value +# WITHIN each spelling decides that spelling (git applies the last value for a given key), +# then the spellings combine so the guard blocks if EITHER could carry a guarded op. +# +# - "env" (--config-env==) in EITHER spelling: the expansion lives in an +# environment variable whose VALUE is deliberately never read — that value is the +# recurring attack surface (an ambient var, an inline/`env` prefix, an `export`, +# `set -a`, or a nested `bash -c`, in this or any enclosing wrapper), and each attempt +# to resolve it has reopened a fail-open. Nobody legitimately defines an alias for a +# guarded subcommand via --config-env on the invoking command line (the canonical form +# is a gitconfig alias or the plain subcommand), so the SHAPE alone is sufficient. +# Returns 2 — the guard blocks without reading anything. +# - "inline" (-c/--config), no env spelling: each present spelling's expansion is +# literally present and bounded. Returns 0 with HOOK_GIT_ALIAS_EXPS holding one entry +# per present spelling (1 or 2), so the guard re-checks every expansion and blocks if +# any is dangerous — a benign expansion never suppresses a dangerous sibling. +# - neither spelling present: returns 1, the subcommand is not an inline/env alias here. +# +# A --config-env that sets a NON-alias key, or an alias for a subcommand OTHER than the +# invoked one, never matches — those stay resolvable/allowed. Call after +# hook::git_resolve_subcommand; read HOOK_GIT_ALIAS_EXPS only on return 0. +# shellcheck disable=SC2034 # HOOK_GIT_ALIAS_EXPS is consumed by the sourcing guard +hook::git_alias_expansion() { + local sub="$1" i cv key kind + local plain_exp="" plain_kind="" cmd_exp="" cmd_kind="" + HOOK_GIT_ALIAS_EXPS=() + # git config names are case-insensitive: fold both sides of the exact key match. Keep + # the LAST value WITHIN each spelling separately, never collapsed across the two, so one + # spelling's value cannot mask the other's. + for i in "${!HOOK_GIT_CONFIG_VALUES[@]}"; do + cv="${HOOK_GIT_CONFIG_VALUES[i]}" + key="${cv%%=*}" + kind="${HOOK_GIT_CONFIG_VALUE_KINDS[i]:-inline}" + if [[ "${key,,}" == "alias.${sub,,}" ]]; then + plain_exp="${cv#*=}" + plain_kind="$kind" + elif [[ "${key,,}" == "alias.${sub,,}.command" ]]; then + cmd_exp="${cv#*=}" + cmd_kind="$kind" + fi + done + # Max-danger union: an env spelling in either place is unreadable — value-blind refusal. + [[ "$plain_kind" == "env" || "$cmd_kind" == "env" ]] && return 2 + [[ -n "$plain_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$plain_exp") + [[ -n "$cmd_kind" ]] && HOOK_GIT_ALIAS_EXPS+=("$cmd_exp") + ((${#HOOK_GIT_ALIAS_EXPS[@]})) && return 0 + return 1 +} + +# Single linear pass: read the command into a char array once (O(n)), then walk +# it splitting top-level segments on UNQUOTED control operators and tokenizing +# each segment into argv words honoring '…', "…", $'…', and backslash escapes +# (including backslash-newline continuation). Each completed segment is passed +# to the callback as it closes, so no full segment list is retained. +# Call as: hook::bash_parse_segments ; the callback +# receives one segment's argv words as "$@". +# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape +hook::bash_parse_segments() { + local cmd="$1" cb="$2" + local -a chars=() + local c nx + while IFS= read -rN1 c; do chars+=("$c"); done < <(printf '%s' "$cmd") + local n=${#chars[@]} i + local word="" have=0 skipnext=0 + local -a seg=() + # Pending heredoc delimiters (FIFO) and their `<<-` tab-strip flags. A + # heredoc body is the command's stdin, not commands — recorded when `<<` + # is seen and skipped wholesale at the command-line newline. + local -a hd_delims=() hd_strip=() + + for ((i = 0; i < n; i++)); do + c="${chars[i]}" + case "$c" in + "'") + ((i++)) + while ((i < n)) && [[ "${chars[i]}" != "'" ]]; do + word+="${chars[i]}" + ((i++)) + done + have=1 + ;; + '"') + ((i++)) + while ((i < n)) && [[ "${chars[i]}" != '"' ]]; do + if [[ "${chars[i]}" == '\' ]] && ((i + 1 < n)); then + nx="${chars[i + 1]}" + case "$nx" in + '"' | '\' | '$' | '`') + word+="$nx" + ((i += 2)) + continue + ;; + $'\n') + ((i += 2)) + continue + ;; + *) ;; + esac + fi + word+="${chars[i]}" + ((i++)) + done + have=1 + ;; + '$') + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == "'" ]]; then + i=$((i + 2)) + local body="" + while ((i < n)) && [[ "${chars[i]}" != "'" ]]; do + if [[ "${chars[i]}" == '\' ]] && ((i + 1 < n)); then + body+="${chars[i]}${chars[i + 1]}" + ((i += 2)) + continue + fi + body+="${chars[i]}" + ((i++)) + done + word+="$(hook::ansi_c_decode "$body")" + have=1 + else + word+="$c" + have=1 + fi + ;; + '\') + if ((i + 1 < n)); then + nx="${chars[i + 1]}" + if [[ "$nx" == $'\n' ]]; then + ((i++)) + else + word+="$nx" + ((i++)) + have=1 + fi + else + have=1 + fi + ;; + ' ' | $'\t') + if ((have)); then + if ((skipnext)); then skipnext=0; else seg+=("$word"); fi + word="" + have=0 + fi + ;; + '>' | '<') + # Redirection: bash removes the operator and its target word from + # argv (redirections may appear anywhere in a simple command), so + # `git reset --hard>/tmp/out` still runs reset --hard. A pure-digit + # word immediately before the operator is its fd prefix, not argv; + # an fd-dup/close form (`2>&1`, `>&-`) has no target word to skip. + if ((have)); then + if [[ "$word" =~ ^[0-9]+$ ]]; then + : + elif ((skipnext)); then + skipnext=0 + else + seg+=("$word") + fi + word="" + have=0 + fi + # Heredoc `<<` / `<<-` (but NOT here-string `<<<`): the body on the + # following lines is the command's stdin, so record the delimiter and + # let the newline handler skip the body. A quoted/backslashed delimiter + # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] && + { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + ((i++)) + local hstrip=0 + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then + hstrip=1 + ((i++)) + fi + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == ' ' || "${chars[i + 1]}" == $'\t' ]]; do ((i++)); done + local delim="" + while ((i + 1 < n)); do + nx="${chars[i + 1]}" + case "$nx" in + ' ' | $'\t' | $'\n' | ';' | '&' | '|' | '<' | '>') break ;; + "'") + ((i++)) + while ((i + 1 < n)) && [[ "${chars[i + 1]}" != "'" ]]; do + delim+="${chars[i + 1]}" + ((i++)) + done + ((i + 1 < n)) && ((i++)) + ;; + '"') + ((i++)) + while ((i + 1 < n)) && [[ "${chars[i + 1]}" != '"' ]]; do + delim+="${chars[i + 1]}" + ((i++)) + done + ((i + 1 < n)) && ((i++)) + ;; + '\') + ((i++)) + ((i + 1 < n)) && { + delim+="${chars[i + 1]}" + ((i++)) + } + ;; + *) + delim+="$nx" + ((i++)) + ;; + esac + done + hd_delims+=("$delim") + hd_strip+=("$hstrip") + continue + fi + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '(' ]]; then + # Process substitution <(list)/>(list): the list is a real command + # substituted as a filename — it satisfies any pending target and + # the '(' separator splits it into a segment that gets scanned. + skipnext=0 + else + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done # portability-ok: bash glob bracket class matching a literal < or > character, not a GNU grep \< \> word boundary + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then + ((i++)) + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; do ((i++)); done + else + skipnext=1 + fi + else + skipnext=1 + fi + fi + ;; + ';' | '&' | '|' | '(' | ')' | '`' | $'\n') + if ((have)); then + if ((skipnext)); then skipnext=0; else seg+=("$word"); fi + word="" + have=0 + fi + if ((${#seg[@]})); then + "$cb" "${seg[@]}" + seg=() + fi + # A command-line newline ends the line that introduced any pending + # heredocs; their bodies (up to and including each delimiter line) are + # stdin, so consume them without tokenizing. Delimiters match in FIFO + # order; `<<-` strips leading tabs from body lines before comparing. + if [[ "$c" == $'\n' ]] && ((${#hd_delims[@]})); then + local hidx line lc d strip + for ((hidx = 0; hidx < ${#hd_delims[@]}; hidx++)); do + d="${hd_delims[hidx]}" + strip="${hd_strip[hidx]}" + while ((i + 1 < n)); do + line="" + while ((i + 1 < n)) && [[ "${chars[i + 1]}" != $'\n' ]]; do + line+="${chars[i + 1]}" + ((i++)) + done + ((i + 1 < n)) && ((i++)) + lc="$line" + if ((strip)); then + while [[ "$lc" == $'\t'* ]]; do lc="${lc#?}"; done + fi + [[ "$lc" == "$d" ]] && break + done + done + hd_delims=() + hd_strip=() + fi + ;; + *) + word+="$c" + have=1 + ;; + esac + done + if ((have)) && ((!skipnext)); then seg+=("$word"); fi + if ((${#seg[@]})); then "$cb" "${seg[@]}"; fi +} diff --git a/plugins/context-guard/hooks/hooks.json b/plugins/context-guard/hooks/hooks.json new file mode 100644 index 0000000000..cabcf5c28a --- /dev/null +++ b/plugins/context-guard/hooks/hooks.json @@ -0,0 +1,53 @@ +{ + "hooks": { + "PostToolBatch": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/zone-crossing-inject.sh", + "timeout": 10, + "statusMessage": "Checking for a context-zone crossing..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/zone-crossing-inject.sh", + "timeout": 10, + "statusMessage": "Checking for a context-zone crossing..." + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Write|Edit|NotebookEdit|Agent|Workflow", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/zone-gate.sh", + "timeout": 10, + "statusMessage": "Checking the context-zone gate..." + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/post-compact-mark.sh", + "timeout": 10, + "statusMessage": "Recording the compaction marker..." + } + ] + } + ] + } +} diff --git a/plugins/context-guard/hooks/payload.sh b/plugins/context-guard/hooks/payload.sh new file mode 100755 index 0000000000..377a4db2ae --- /dev/null +++ b/plugins/context-guard/hooks/payload.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# context-guard-local stdin reader for hook payloads. +# +# The shared lib's hook::buffer_stdin performs ONE bounded read, which on +# Windows/MSYS pipes (~40KB/s byte-at-a-time delivery) times out on exactly +# the payloads these hooks exist for — PostCompact carries the full +# compact_summary, PreToolUse carries the full Write content, PostToolBatch +# carries every serialized tool result (measured: ~80KB payloads already +# lost). This reader mirrors statusline-tee.sh's proven drain loop: read -N +# buffers in 1MiB blocks until EOF with a per-block 5s timeout, so a stalled +# pipe is still bounded while a large healthy payload arrives whole. Bash +# below 4.1 (macOS ships 3.2) lacks -N and falls back to the delimiter form, +# which already reads to EOF fast on native POSIX pipes. +# +# Returns 1 on an empty payload; callers fail open on that. On a stalled +# pipe the caller sees a truncated payload whose regex/jq extraction then +# fails its own validation — never a fabricated value. + +cg::read_payload() { + local input="" chunk="" + if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); then + while IFS= read -r -N 1048576 -t 5 chunk; do + input+="$chunk" + chunk="" + done + input+="$chunk" # EOF/timeout leaves the final partial block in chunk + else + IFS= read -r -d '' -t 5 input || true + fi + input=${input//$'\r'/} + [[ -n "$input" ]] || return 1 + printf '%s' "$input" +} diff --git a/plugins/context-guard/hooks/post-compact-mark.sh b/plugins/context-guard/hooks/post-compact-mark.sh new file mode 100755 index 0000000000..17ce2ee317 --- /dev/null +++ b/plugins/context-guard/hooks/post-compact-mark.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# PostCompact hook: persist an evidence-degraded marker for the session. +# +# Closes the reader contract's documented gap ("Zone is NOT a compaction +# indicator"): a compacted session's percentage resets downward while the +# evidence in its conversational context is already gone, and the snapshot +# alone cannot tell a consumer that compaction happened. This hook writes a +# sibling marker file next to the session's snapshot: +# +# ~/.claude/context-guard/context/.compacted +# {"compacted_at":"","trigger":"manual|auto|unknown", +# "hook_event_name":"PostCompact"} +# +# The marker is part of the cross-plugin artifact seam (fixed HOME-anchored +# contract path, deliberately OUTSIDE ${CLAUDE_PLUGIN_DATA}); consumers +# presence-check it and treat the session as evidence-degraded regardless of +# a green zone (reader contract, "Evidence-degraded marker"). Last-write- +# wins per session: only the most recent compaction matters. +# +# SIDE-EFFECT-ONLY by upstream contract: PostCompact has no decision control +# (verified 2026-07-26 against code.claude.com/docs/en/hooks), so this +# hook's one job is the marker. jq-FREE by design, mirroring the +# rate-limit-guard StopFailure recorder: the fields are regex-extracted so a +# degraded environment still records. It also resets the blocking gate's +# grace counter — compaction opens a fresh window. +# +# Kill switch: context_guard_hooks_enabled userConfig boolean, read via the +# CLAUDE_PLUGIN_OPTION_CONTEXT_GUARD_HOOKS_ENABLED hook-process mirror. + +set -uo pipefail + +# shellcheck source=hook-utils.sh +source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +# shellcheck source=payload.sh +source "$(dirname "${BASH_SOURCE[0]}")/payload.sh" + +hook::check_enabled "CONTEXT_GUARD_HOOKS" + +START_EPOCH=${EPOCHREALTIME:-0} + +# A missing or incomplete payload degrades the record, never suppresses it. +# Chunked reader: PostCompact carries the FULL compact_summary, so a single +# bounded read times out on the normal case, not an edge (measured ~80KB +# already lost on Git Bash pipes). +INPUT=$(cg::read_payload) || INPUT="" + +SESSION="" +if [[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]]; then + SESSION="${BASH_REMATCH[1]}" +fi +# silent-skip-ok: without a session id there is no snapshot to mark, and the +# marker path cannot be keyed; the filename character class also provides +# path containment. +[[ "$SESSION" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0 + +TRIGGER="unknown" +if [[ "$INPUT" =~ \"trigger\"[[:space:]]*:[[:space:]]*\"(manual|auto)\" ]]; then + TRIGGER="${BASH_REMATCH[1]}" +fi + +# silent-skip-ok: no HOME means no resolvable contract path anywhere on this +# host; PostCompact is side-effect-only with no decision channel, and the +# setup skill's check probe is the visibility surface for a broken contract +# path. +[[ -n "${HOME:-}" ]] || exit 0 +CTX_DIR="$HOME/.claude/context-guard/context" +mkdir -p "$CTX_DIR" 2>/dev/null || exit 0 +chmod 700 "$HOME/.claude/context-guard" "$CTX_DIR" 2>/dev/null || true +umask 077 + +ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="" + +marker='{"compacted_at":"'"$(hook::json_escape "$ts")"'","trigger":"'"$TRIGGER"'","hook_event_name":"PostCompact"}' +target="$CTX_DIR/$SESSION.compacted" +tmp="$CTX_DIR/$SESSION.compacted.tmp.$$" +# Track the write-and-rename result explicitly: telemetry status must reflect +# whether the marker was actually recorded, not just whether the hook ran. +# marker_ok stays 0 (and status reports "error") whenever the temp-file write, +# the atomic rename, or the contract path itself leaves consumers without a +# readable marker — operators must never be told "ok" for a marker consumers +# will never see. +marker_ok=0 +if printf '%s\n' "$marker" >"$tmp" 2>/dev/null; then + # A directory at the contract path makes `mv` SUCCEED by moving the temp + # file inside it, stranding the marker where no consumer looks. Refuse the + # rename up front rather than report a false "ok" (and rather than discover + # it afterwards, which would leave the temp file littered in that + # directory). + if [[ -d "$target" ]]; then + rm -f "$tmp" 2>/dev/null + elif mv -f "$tmp" "$target" 2>/dev/null; then + marker_ok=1 + else + rm -f "$tmp" 2>/dev/null + fi +fi + +# Prune stale sibling markers with the same 14-day cutoff the tee applies to +# snapshots — the shared contract dir must not grow unboundedly, and the +# tee's own sweep matches *.json only. +find "$CTX_DIR" -maxdepth 1 -name '*.compacted' -mmin +20160 -exec rm -f {} + 2>/dev/null || true + +# Compaction opens a fresh window: re-arm the blocking gate's grace budget. +STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/context-guard}/state" +rm -f "$STATE_DIR/$SESSION.gate-count" 2>/dev/null || true + +# SIDE-EFFECT-ONLY contract still holds: PostCompact has no decision control, +# so this always exits 0 regardless of marker_ok — only the telemetry status +# reports the real outcome. +telemetry_status="ok" +((marker_ok)) || telemetry_status="error" +hook::emit_telemetry "post-compact-mark" "PostCompact" "$telemetry_status" "$START_EPOCH" \ + '{"trigger":"'"$TRIGGER"'"}' +exit 0 diff --git a/plugins/context-guard/hooks/post-compact-mark.test.sh b/plugins/context-guard/hooks/post-compact-mark.test.sh new file mode 100755 index 0000000000..05f9a56a2a --- /dev/null +++ b/plugins/context-guard/hooks/post-compact-mark.test.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# Contract test for post-compact-mark.sh (PostCompact, side-effect-only). +# +# Contract: writes the evidence-degraded marker +# ~/.claude/context-guard/context/.compacted with compacted_at (strict +# ISO-8601 UTC), trigger (manual|auto|unknown), and hook_event_name; resets +# the blocking gate's grace counter; fails open (no write, exit 0) on a +# missing/hostile session id or missing HOME; kill switch honored. Exit 0 +# always. +# +# Self-contained: defines its own assertion helpers — installed plugins are +# cache-isolated with no shared test lib. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/post-compact-mark.sh" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +WORK="$(mktemp -d)" +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +H="$WORK/home" +D="$WORK/data" +MARK="$H/.claude/context-guard/context" + +run() { # [extra env k=v...] + local payload="$1" + shift + printf '%s' "$payload" | HOME="$H" CLAUDE_PLUGIN_DATA="$D" HOOK_TELEMETRY_SINK="" env "$@" bash "$HOOK" 2>/dev/null +} + +# 1. Auto trigger recorded. +run '{"session_id":"s1","hook_event_name":"PostCompact","trigger":"auto"}' +RC=$? +if [[ $RC -eq 0 && -f "$MARK/s1.compacted" ]]; then ok "marker written (auto)"; else fail "marker missing: rc=$RC"; fi +if grep -q '"trigger":"auto"' "$MARK/s1.compacted" 2>/dev/null; then ok "trigger=auto recorded"; else fail "trigger not recorded"; fi +if grep -Eq '"compacted_at":"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z"' "$MARK/s1.compacted"; then + ok "compacted_at is strict ISO-8601 UTC" +else + fail "compacted_at malformed: $(cat "$MARK/s1.compacted" 2>/dev/null)" +fi +if command -v jq >/dev/null 2>&1; then + if jq -e . "$MARK/s1.compacted" >/dev/null 2>&1; then ok "marker is valid JSON"; else fail "marker JSON invalid"; fi +fi + +# 2. Manual trigger recorded; marker is last-write-wins. +run '{"session_id":"s1","hook_event_name":"PostCompact","trigger":"manual"}' +if grep -q '"trigger":"manual"' "$MARK/s1.compacted" 2>/dev/null; then ok "manual overwrite (last-write-wins)"; else fail "manual overwrite failed"; fi + +# 3. Unrecognized trigger degrades to unknown. +run '{"session_id":"s2","hook_event_name":"PostCompact","trigger":"weird"}' +if grep -q '"trigger":"unknown"' "$MARK/s2.compacted" 2>/dev/null; then ok "unrecognized trigger → unknown"; else fail "trigger sanitization failed"; fi + +# 4. Grace counter reset. +mkdir -p "$D/state" +printf '9\n' >"$D/state/s3.gate-count" +run '{"session_id":"s3","hook_event_name":"PostCompact","trigger":"auto"}' +if [[ ! -e "$D/state/s3.gate-count" ]]; then ok "gate grace counter reset on compact"; else fail "grace counter not reset"; fi + +# 5. Missing session id → no write, exit 0. +before=$(find "$MARK" -name '*.compacted' 2>/dev/null | wc -l) +run '{"hook_event_name":"PostCompact","trigger":"auto"}' +RC=$? +after=$(find "$MARK" -name '*.compacted' 2>/dev/null | wc -l) +if [[ $RC -eq 0 && "$before" == "$after" ]]; then ok "missing session id fails open"; else fail "missing sid: rc=$RC"; fi + +# 6. Hostile session id → no write outside the contract dir, exit 0. +run '{"session_id":"../../evil","hook_event_name":"PostCompact","trigger":"auto"}' +RC=$? +if [[ $RC -eq 0 && ! -e "$H/.claude/evil.compacted" && ! -e "$H/.claude/context-guard/evil.compacted" ]]; then + ok "hostile session id fails open (no traversal)" +else + fail "hostile sid: rc=$RC" +fi + +# 7. Kill switch honored. +run '{"session_id":"s4","hook_event_name":"PostCompact","trigger":"auto"}' CLAUDE_PLUGIN_OPTION_CONTEXT_GUARD_HOOKS_ENABLED=false +RC=$? +if [[ $RC -eq 0 && ! -e "$MARK/s4.compacted" ]]; then ok "kill switch suppresses the marker"; else fail "kill switch: rc=$RC"; fi + +# 8. Empty stdin → no write, exit 0. +run '' +RC=$? +if [[ $RC -eq 0 ]]; then ok "empty stdin fails open"; else fail "empty stdin: rc=$RC"; fi + +# 9. Large payload (real PostCompact carries the full compact_summary): +# marker must still be written. Guards the Win32-pipe single-read timeout +# regression measured at ~80KB. +BIG=$(printf 'x%.0s' $(seq 1 150000)) +run "{\"session_id\":\"sbig\",\"hook_event_name\":\"PostCompact\",\"trigger\":\"auto\",\"compact_summary\":\"$BIG\"}" +RC=$? +if [[ $RC -eq 0 && -f "$MARK/sbig.compacted" ]]; then ok "marker written for a 150KB payload"; else fail "large payload: rc=$RC marker=$([[ -f "$MARK/sbig.compacted" ]] && echo yes || echo no)"; fi + +# 10. Old sibling markers are pruned on write (14-day cutoff, mirroring the +# tee's snapshot sweep) so the shared contract dir cannot grow unboundedly. +printf '{"compacted_at":"2020-01-01T00:00:00Z","trigger":"auto"} +' >"$MARK/sold.compacted" +if command -v touch >/dev/null 2>&1; then + touch -d '30 days ago' "$MARK/sold.compacted" 2>/dev/null || touch -t 202001010000 "$MARK/sold.compacted" 2>/dev/null || true +fi +run '{"session_id":"sprune","hook_event_name":"PostCompact","trigger":"auto"}' +if [[ ! -e "$MARK/sold.compacted" && -f "$MARK/sprune.compacted" ]]; then + ok "stale markers pruned on write (14-day cutoff)" +else + fail "stale marker not pruned" +fi + +# 11. Marker persist failure must still exit 0 (SIDE-EFFECT-ONLY: PostCompact +# has no decision control) but report telemetry status=error rather than "ok" — +# operators must not be told a marker was recorded when consumers will never +# see it. Simulated with a directory sitting at the exact contract marker path, +# the same portable idiom zone-crossing-inject.test.sh uses: it depends on no +# permission bits, so it holds on filesystems without enforced POSIX modes +# (Windows ACL volumes under Git Bash) too. A directory-mode block cannot +# express this case at all — the hook re-asserts `chmod 700` on its own +# contract directory every run, so it heals the block before writing. +make_sink() { + local s + s="$(mktemp "$WORK/sink.XXXXXX")" + { + printf '#!/usr/bin/env bash\n' + printf 'cat >%q\n' "$1" + } >"$s" + chmod +x "$s" + printf '%s' "$s" +} +wait_for_sink() { + local f="$1" tries=150 + while ((tries-- > 0)); do + [[ -s "$f" ]] && return 0 + sleep 0.02 + done + return 1 +} + +BLOCKED="$MARK/sfail.compacted" +mkdir -p "$BLOCKED" +TEL="$WORK/tel-fail.json" +SINK="$(make_sink "$TEL")" +printf '{"session_id":"sfail","hook_event_name":"PostCompact","trigger":"auto"}' | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" HOOK_TELEMETRY_SINK="$SINK" bash "$HOOK" >/dev/null 2>&1 +RC=$? +if [[ $RC -eq 0 ]]; then ok "marker persist failure still exits 0"; else fail "marker persist failure: rc=$RC"; fi +if wait_for_sink "$TEL" && [[ "$(jq -r '.status' "$TEL" 2>/dev/null)" == "error" ]]; then + ok "marker persist failure reports telemetry status=error" +else + fail "telemetry status not error: $(cat "$TEL" 2>/dev/null)" +fi +# Pins the refusal as a PRE-rename guard: `mv` onto a directory succeeds by +# moving the temp file inside it, so a post-hoc check would report the error +# but litter the blocked path with temp files on every compaction. +if [[ -z "$(ls -A "$BLOCKED" 2>/dev/null)" ]]; then + ok "no temp file stranded at the blocked contract path" +else + fail "temp file stranded at blocked path: $(ls -A "$BLOCKED")" +fi + +echo +echo "PASS=$PASS FAIL=$FAIL" +[[ $FAIL -eq 0 ]] diff --git a/plugins/context-guard/hooks/zone-crossing-inject.sh b/plugins/context-guard/hooks/zone-crossing-inject.sh new file mode 100755 index 0000000000..ddb0ee03a4 --- /dev/null +++ b/plugins/context-guard/hooks/zone-crossing-inject.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# PostToolBatch + UserPromptSubmit hook: inject continuation guidance ONCE per +# transition into a WORSE context zone; stay silent otherwise. +# +# Cadence contract: the session's zone is resolved from the plugin's +# own snapshot seam via scripts/context-zone.sh (the single band/combination +# authority — this hook never re-implements band logic). The last-seen zone +# is kept per session in a private state file; injection fires only when the +# rank worsens (smart → acceptable/dumb, acceptable → dumb, or a first +# observation already past smart). `unknown` is always silent and never +# updates state — no data is not a transition. Improvements update state +# silently so a later relapse injects again. +# +# ADVISORY-ONLY: this hook only ever exits 0 and only ever emits +# additionalContext. The blocking posture lives in the separate PreToolUse +# gate (zone-gate.sh). PostToolBatch fires once per parallel tool batch +# before the next model call — one injection point per model turn, no +# per-tool dedupe needed; UserPromptSubmit covers turns that begin without a +# prior batch (fresh prompt after idle). +# +# State root: ${CLAUDE_PLUGIN_DATA} (plugin-private runtime state, NOT part +# of the reader contract seam), falling back to ~/.claude/context-guard/state +# when the harness doesn't export it. +# +# Kill switch: context_guard_hooks_enabled userConfig boolean, read via the +# CLAUDE_PLUGIN_OPTION_CONTEXT_GUARD_HOOKS_ENABLED hook-process mirror. + +set -uo pipefail + +# shellcheck source=hook-utils.sh +source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +# shellcheck source=payload.sh +source "$(dirname "${BASH_SOURCE[0]}")/payload.sh" + +hook::check_enabled "CONTEXT_GUARD_HOOKS" + +START_EPOCH=${EPOCHREALTIME:-0} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESOLVER="$SCRIPT_DIR/../scripts/context-zone.sh" + +# silent-skip-ok: without a stdin payload there is no session_id to key the +# snapshot seam — nothing this hook could resolve or say. Chunked reader: +# PostToolBatch payloads carry every serialized tool result and routinely +# exceed what a single bounded read survives on Windows pipes. +INPUT=$(cg::read_payload) || exit 0 + +EVENT=$(hook::jq_field "$INPUT" '.hook_event_name') || EVENT="PostToolBatch" +hook::require_jq "$EVENT" "context-guard" "$INPUT" + +SESSION=$(hook::jq_field "$INPUT" '.session_id') || exit 0 +# Same character class the tee/resolver enforce — also path containment for +# the state file below. +[[ "$SESSION" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0 + +zone=$(bash "$RESOLVER" "$SESSION" 2>/dev/null) || zone="unknown" + +# Evidence-degraded marker (reader contract): a compacted session is treated +# as dumb regardless of the resolved word — including a green post-compaction +# reading and including unknown, because the marker IS data even when the +# snapshot has none. +degraded="" +if [[ -n "${HOME:-}" && -e "$HOME/.claude/context-guard/context/$SESSION.compacted" ]]; then + degraded="yes" + zone="dumb" +fi + +# Silent on unknown, and state is left untouched: absence of data is not a +# transition, and a later real reading must compare against the last REAL one. +[[ "$zone" == "smart" || "$zone" == "acceptable" || "$zone" == "dumb" ]] || exit 0 + +# silent-skip-ok: with neither CLAUDE_PLUGIN_DATA nor HOME there is no +# resolvable state root, and a `.`-relative fallback would key the last-seen +# zone to whatever directory the hook happened to start in — the once-per- +# transition contract cannot hold against state that moves with the working +# directory, so the hook would re-inject on every cd. Same doctrine +# post-compact-mark.sh applies to its marker path. +if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then + STATE_DIR="$CLAUDE_PLUGIN_DATA/state" +elif [[ -n "${HOME:-}" ]]; then + STATE_DIR="$HOME/.claude/context-guard/state" +else + exit 0 +fi +STATE_FILE="$STATE_DIR/$SESSION.zone" +last="" +[[ -r "$STATE_FILE" ]] && last=$(tr -cd '[:lower:]' <"$STATE_FILE" 2>/dev/null | head -c 16) + +rank() { + case "$1" in + acceptable) printf '1' ;; + dumb) printf '2' ;; + *) printf '0' ;; # smart, or no prior observation (baseline) + esac +} +new_rank=$(rank "$zone") +last_rank=$(rank "$last") + +# Persist the current zone regardless of direction (improvements update +# silently) — owner-only, atomic enough for a single-writer-per-session file. +# A write failure (full or newly read-only filesystem) must fail OPEN +# SILENTLY: proceeding past it would compare this turn's zone against the +# same stale `last` again on the next call, re-emitting the ~1KB guidance +# block every subsequent PostToolBatch/UserPromptSubmit instead of once per +# transition. "Silent" means no additionalContext is injected — the failure +# itself is still surfaced to operators as telemetry, never swallowed twice. +umask 077 +mkdir -p "$STATE_DIR" 2>/dev/null || exit 0 +if ! printf '%s\n' "$zone" >"$STATE_FILE" 2>/dev/null; then + hook::emit_telemetry "zone-crossing-inject" "$EVENT" "error" "$START_EPOCH" \ + '{"zone":"'"$zone"'","previous":"'"${last:-}"'","reason":"state_persist_failed"}' + exit 0 +fi + +((new_rank > last_rank)) || { + # No worsening. A recovery (rank drop) is still a meaningful outcome for + # telemetry; an unchanged zone is not. + if [[ -n "$last" && "$zone" != "$last" ]]; then + hook::emit_telemetry "zone-crossing-inject" "$EVENT" "ok" "$START_EPOCH" \ + '{"zone":"'"$zone"'","previous":"'"$last"'","injected":false}' + fi + exit 0 +} + +prev_label="${last:-unobserved}" +zone_label="$zone" +[[ -n "$degraded" ]] && zone_label="dumb (evidence-degraded: this session was compacted, so its context evidence is already lossy regardless of the snapshot's numbers)" +guidance="context-guard: this session crossed from the ${prev_label} into the ${zone_label} context zone (snapshot seam, conservative-min over percentage and token bands). Response quality degrades as context occupancy grows. Prefer finishing the current step, then choose the continuation mechanism deliberately: (1) continue in-session only if the remaining work is small or simple enough for degraded context; (2) /clear if this session's context is disposable; (3) write a durable handoff then /clear if state must survive — run /session-flow:handoff (if that plugin is installed; otherwise write a resume file by hand before clearing); (4) /compact only at a phase boundary, as a last resort. For the full continuation router, run /session-flow:workflow (if installed)." +if [[ "$zone" == "dumb" ]]; then + guidance+=" The dumb zone means degradation is likely already measurable: avoid starting new complex work in this window." +fi + +hook::emit_channels "$EVENT" "$guidance" "" +hook::emit_telemetry "zone-crossing-inject" "$EVENT" "ok" "$START_EPOCH" \ + '{"zone":"'"$zone"'","previous":"'"${last:-}"'","injected":true}' +exit 0 diff --git a/plugins/context-guard/hooks/zone-crossing-inject.test.sh b/plugins/context-guard/hooks/zone-crossing-inject.test.sh new file mode 100755 index 0000000000..5d7b5e7d9b --- /dev/null +++ b/plugins/context-guard/hooks/zone-crossing-inject.test.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# Contract test for zone-crossing-inject.sh (PostToolBatch/UserPromptSubmit). +# +# Contract: inject additionalContext ONCE per transition into a WORSE zone; +# silent while the zone is unchanged, improving, or unknown; unknown never +# updates state; kill switch honored; hostile ids fail open. Exit 0 always. +# +# Self-contained: defines its own assertion helpers — installed plugins are +# cache-isolated with no shared test lib. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/zone-crossing-inject.sh" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +command -v jq >/dev/null 2>&1 || { + echo "SKIP: jq not installed" + exit 0 +} + +WORK="$(mktemp -d)" +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +CTX_REL=".claude/context-guard/context" + +# write_snapshot +write_snapshot() { + local home="$1" sid="$2" used="$3" + mkdir -p "$home/$CTX_REL" + printf '{"captured_at":"%s","session_id":"%s","context_window":{"used_percentage":%s,"remaining_percentage":50,"current_usage":{"input_tokens":100}}}\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$sid" "$used" >"$home/$CTX_REL/$sid.json" +} + +# run [] → stdout captured to $OUT, rc to $RC +OUT="" +RC=0 +run() { + local home="$1" data="$2" sid="$3" event="${4:-PostToolBatch}" + OUT=$(printf '{"session_id":"%s","hook_event_name":"%s","cwd":"/tmp"}' "$sid" "$event" | + HOME="$home" CLAUDE_PLUGIN_DATA="$data" HOOK_TELEMETRY_SINK="" bash "$HOOK" 2>/dev/null) + RC=$? +} + +H="$WORK/home" +D="$WORK/data" + +# 1. First observation in the dumb zone (no prior state) injects. +write_snapshot "$H" s1 90 +run "$H" "$D" s1 +if [[ $RC -eq 0 && "$OUT" == *additionalContext* && "$OUT" == *dumb* ]]; then + ok "first-seen dumb injects additionalContext" +else + fail "first-seen dumb: rc=$RC out=$OUT" +fi +if jq -e '.hookSpecificOutput.hookEventName == "PostToolBatch"' <<<"$OUT" >/dev/null 2>&1; then + ok "injection is valid JSON carrying the firing event name" +else + fail "injection JSON invalid: $OUT" +fi + +# 2. Same zone again → silent (once per transition). +run "$H" "$D" s1 +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "unchanged zone is silent"; else fail "unchanged zone: rc=$RC out=$OUT"; fi + +# 3. Improvement (dumb → smart) → silent, state updated. +write_snapshot "$H" s1 10 +run "$H" "$D" s1 +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "improvement is silent"; else fail "improvement: rc=$RC out=$OUT"; fi +if [[ "$(cat "$D/state/s1.zone" 2>/dev/null)" == "smart" ]]; then + ok "improvement still updates state" +else + fail "state not updated on improvement" +fi + +# 4. Relapse after improvement (smart → acceptable) injects again. +write_snapshot "$H" s1 60 +run "$H" "$D" s1 UserPromptSubmit +if [[ $RC -eq 0 && "$OUT" == *additionalContext* && "$OUT" == *acceptable* ]]; then + ok "relapse injects again (UserPromptSubmit)" +else + fail "relapse: rc=$RC out=$OUT" +fi + +# 5. Unknown zone (no snapshot) → silent, state untouched. +run "$H" "$D" nosuchsession +if [[ $RC -eq 0 && -z "$OUT" && ! -e "$D/state/nosuchsession.zone" ]]; then + ok "unknown zone is silent and stateless" +else + fail "unknown zone: rc=$RC out=$OUT" +fi + +# 6. Kill switch honored. +write_snapshot "$H" s2 90 +OUT=$(printf '{"session_id":"s2","hook_event_name":"PostToolBatch"}' | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" CLAUDE_PLUGIN_OPTION_CONTEXT_GUARD_HOOKS_ENABLED=false bash "$HOOK" 2>/dev/null) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "kill switch silences the hook"; else fail "kill switch: rc=$RC out=$OUT"; fi + +# 7. Hostile session id → silent exit 0, no state write. +OUT=$(printf '{"session_id":"../../etc","hook_event_name":"PostToolBatch"}' | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" bash "$HOOK" 2>/dev/null) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "hostile session id fails open"; else fail "hostile sid: rc=$RC out=$OUT"; fi + +# 8. Empty stdin → silent exit 0. +OUT=$(printf '' | HOME="$H" CLAUDE_PLUGIN_DATA="$D" bash "$HOOK" 2>/dev/null) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "empty stdin fails open"; else fail "empty stdin: rc=$RC out=$OUT"; fi + +# 9. Injection body stays under the documented 10k output cap. +write_snapshot "$H" s3 90 +run "$H" "$D" s3 +LEN=${#OUT} +if ((LEN > 0 && LEN < 10000)); then ok "injection length $LEN under 10k cap"; else fail "injection length $LEN"; fi + +# 10. Large PostToolBatch payload (serialized tool_calls) must not suppress +# a due injection (Win32-pipe single-read timeout regression guard). +write_snapshot "$H" sbig 90 +BIG=$(printf 'x%.0s' $(seq 1 150000)) +OUT=$(printf '{"session_id":"sbig","hook_event_name":"PostToolBatch","tool_calls":[{"tool_name":"Read","tool_response":"%s"}]}' "$BIG" | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" HOOK_TELEMETRY_SINK="" bash "$HOOK" 2>/dev/null) +RC=$? +if [[ $RC -eq 0 && "$OUT" == *additionalContext* ]]; then + ok "150KB batch payload still injects (chunked stdin read)" +else + fail "large payload suppressed injection: rc=$RC out=${OUT:0:120}" +fi + +# 11. Evidence-degraded marker: green snapshot + .compacted marker is treated +# as dumb — one injection fires (and mentions the degradation), then silence. +write_snapshot "$H" smk 10 +printf '{"compacted_at":"2026-07-26T00:00:00Z","trigger":"auto"} +' >"$H/$CTX_REL/smk.compacted" +run "$H" "$D" smk +if [[ $RC -eq 0 && "$OUT" == *additionalContext* && "$OUT" == *degraded* ]]; then + ok "marker + smart snapshot injects the evidence-degraded notice once" +else + fail "marker ignored by inject: rc=$RC out=${OUT:0:120}" +fi +run "$H" "$D" smk +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "marker-driven dumb state stays silent on repeat" +else + fail "marker repeat not silent: rc=$RC out=${OUT:0:120}" +fi + +# 12. State-persist failure (e.g. full/read-only filesystem) fails open +# SILENTLY — no additionalContext, exit 0 — rather than falling through and +# comparing against the same stale `last` again on every subsequent call. +# The failure itself is still surfaced as telemetry status=error, never +# swallowed twice. Simulated portably (no chmod/permission dependence): a +# directory sitting at the exact state-file path makes the write fail on +# every platform, including Git Bash on Windows. +make_sink() { + local s + s="$(mktemp "$WORK/sink.XXXXXX")" + { + printf '#!/usr/bin/env bash\n' + printf 'cat >%q\n' "$1" + } >"$s" + chmod +x "$s" + printf '%s' "$s" +} +wait_for_sink() { + local f="$1" tries=150 + while ((tries-- > 0)); do + [[ -s "$f" ]] && return 0 + sleep 0.02 + done + return 1 +} + +write_snapshot "$H" spersist 90 +mkdir -p "$D/state" +mkdir -p "$D/state/spersist.zone" # a directory blocks the write, not a permission bit +TEL="$WORK/tel-persist.json" +SINK="$(make_sink "$TEL")" +OUT=$(printf '{"session_id":"spersist","hook_event_name":"PostToolBatch"}' | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" HOOK_TELEMETRY_SINK="$SINK" bash "$HOOK" 2>/dev/null) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "state-persist failure fails open silently (no additionalContext)" +else + fail "state-persist failure: rc=$RC out=${OUT:0:120}" +fi +if wait_for_sink "$TEL" && [[ "$(jq -r '.status' "$TEL" 2>/dev/null)" == "error" ]]; then + ok "state-persist failure reports telemetry status=error" +else + fail "telemetry status not error: $(cat "$TEL" 2>/dev/null)" +fi + +# No resolvable state root → stay silent rather than key the last-seen zone to +# the working directory, which would re-inject on every cd. +write_snapshot "$WORK/nohome" snr 90 +NR_OUT=$(printf '{"session_id":"snr","hook_event_name":"PostToolBatch"}' | + env -u HOME -u CLAUDE_PLUGIN_DATA HOOK_TELEMETRY_SINK="" bash "$HOOK" 2>/dev/null) +NR_RC=$? +if [[ $NR_RC -eq 0 && -z "$NR_OUT" ]]; then + ok "no HOME and no CLAUDE_PLUGIN_DATA stays silent" +else + fail "no state root: rc=$NR_RC out=${NR_OUT:0:120}" +fi +if [[ ! -e "./.claude/context-guard/state" ]]; then + ok "no zone state written relative to the working directory" +else + fail "zone state leaked into the working directory" +fi + +echo +echo "PASS=$PASS FAIL=$FAIL" +[[ $FAIL -eq 0 ]] diff --git a/plugins/context-guard/hooks/zone-gate.sh b/plugins/context-guard/hooks/zone-gate.sh new file mode 100755 index 0000000000..48e436b99e --- /dev/null +++ b/plugins/context-guard/hooks/zone-gate.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# PreToolUse gate (matcher: Write|Edit|NotebookEdit|Agent|Workflow): in +# BLOCKING mode only, deny new mutating/spawning work once a session sits in +# a FRESH dumb-zone snapshot past a small grace budget. +# +# Posture contract (conforming to docs/conventions/hook-observability's +# gate-posture enum advisory | blocking): +# - advisory (default): this gate is inert — guidance comes from the +# advisory injection hook (zone-crossing-inject.sh). +# - blocking: deny matched tool calls when ALL hold: the resolver returns +# `dumb` from a fresh snapshot (staleness enforced inside the resolver), +# AND the per-session grace budget is exhausted, AND the call is not +# exempt. FAIL-OPEN everywhere else: `unknown` never blocks, a missing +# prerequisite never blocks, resolver failure never blocks. +# +# NO-DEADLOCK EXEMPTIONS (why the matcher is what it is): the gate matches +# only mutating/spawning tools. Read-only tools, Bash, and Skill invocations +# never reach it, and Write/Edit targets whose path mentions "handoff" are +# exempted below — so a session told to stop can ALWAYS produce a durable +# handoff (the save-point machinery is reads + Bash + a handoff-path Write) +# and can always run the handoff skill itself. +# +# Grace budget: the first N matched calls after the session first resolves +# dumb are allowed (N = zone_gate_grace_calls userConfig, default 20), so an +# in-flight step can land before the gate closes. The counter resets when +# the session leaves the dumb zone. +# +# Kill switches: context_guard_hooks_enabled (whole hook set) and +# zone_hook_mode (this gate is active only when it equals "blocking"), read +# via their CLAUDE_PLUGIN_OPTION_* hook-process mirrors with in-script +# defaults (the userConfig `default` field is not delivered to hook +# processes — docs/conventions/hook-config-delivery, fact 3). + +set -uo pipefail + +# shellcheck source=hook-utils.sh +source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +# shellcheck source=payload.sh +source "$(dirname "${BASH_SOURCE[0]}")/payload.sh" + +hook::check_enabled "CONTEXT_GUARD_HOOKS" + +MODE="${CLAUDE_PLUGIN_OPTION_ZONE_HOOK_MODE:-advisory}" +# Pure inapplicability: the gate exists only in blocking mode; the advisory +# posture's visible surface is the injection hook. +[[ "$MODE" == "blocking" ]] || exit 0 + +START_EPOCH=${EPOCHREALTIME:-0} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESOLVER="$SCRIPT_DIR/../scripts/context-zone.sh" + +# silent-skip-ok: no stdin payload → no session_id → fail-open (a gate that +# cannot identify its session must never block). Chunked reader: a large +# Write's tool_input rides in this payload, and a single bounded read timing +# out on it would fail the gate open for exactly the biggest writes. +INPUT=$(cg::read_payload) || exit 0 +hook::require_jq "PreToolUse" "context-guard" "$INPUT" + +SESSION=$(hook::jq_field "$INPUT" '.session_id') || exit 0 +[[ "$SESSION" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0 + +# silent-skip-ok: with neither CLAUDE_PLUGIN_DATA nor HOME there is no +# resolvable state root, and a `.`-relative fallback would scatter the grace +# counter through whatever directory the hook happened to start in — a counter +# that resets with the working directory is not a budget. A gate that cannot +# keep its own count must fail open, the same doctrine post-compact-mark.sh +# applies to its marker path. +if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then + STATE_DIR="$CLAUDE_PLUGIN_DATA/state" +elif [[ -n "${HOME:-}" ]]; then + STATE_DIR="$HOME/.claude/context-guard/state" +else + exit 0 +fi +COUNT_FILE="$STATE_DIR/$SESSION.gate-count" + +zone=$(bash "$RESOLVER" "$SESSION" 2>/dev/null) || zone="unknown" + +# Evidence-degraded marker (reader contract): a compacted session is treated +# as dumb regardless of the resolved word — a post-compaction percentage +# resets downward while the context evidence is already gone, and without +# this override the marker would be write-only and compaction would disarm +# the gate the continuation router's own fallthrough recommends. +if [[ -n "${HOME:-}" && -e "$HOME/.claude/context-guard/context/$SESSION.compacted" ]]; then + zone="dumb" +fi + +if [[ "$zone" != "dumb" ]]; then + # Fail-open on smart/acceptable/unknown — and leaving the dumb zone + # (recovery, /clear into a new session, fresh snapshot) re-arms the grace + # budget. + rm -f "$COUNT_FILE" 2>/dev/null || true + exit 0 +fi + +# Handoff-writing exemption: a Write/Edit/NotebookEdit whose target path +# mentions "handoff" is exactly the operation blocking mode exists to force — +# never gate it. Extracted through hook::jq_field like the file's other two +# extractions: the helper exists for whole-payload reads, it CR-strips, and +# routing through it keeps this path off bash's here-string size heuristic +# rather than depending on which side of it a given payload lands. +target=$(hook::jq_field "$INPUT" '.tool_input.file_path // .tool_input.notebook_path') || target="" +shopt -s nocasematch +if [[ -n "$target" && "$target" == *handoff* ]]; then + shopt -u nocasematch + exit 0 +fi +shopt -u nocasematch + +# Digit-count bound and explicit base-10 normalization, both load-bearing: +# a digit-only value is still not a usable decimal. `08` is octal in every +# arithmetic context (`((count <= GRACE))` errors on the invalid digit, +# evaluates false, and denies call 1 instead of allowing 8), and it is also +# an invalid JSON number in the telemetry payload below. A very long digit +# string overflows to a negative in the same contexts. Normalizing once here +# makes the arithmetic, the operator-facing reason string, and the telemetry +# all carry the same canonical decimal. +GRACE="${CLAUDE_PLUGIN_OPTION_ZONE_GATE_GRACE_CALLS:-20}" +[[ "$GRACE" =~ ^[0-9]{1,9}$ ]] || GRACE=20 +GRACE=$((10#$GRACE)) + +umask 077 +mkdir -p "$STATE_DIR" 2>/dev/null || exit 0 + +# ATOMIC COUNTER (why not read-modify-write): Claude starts matched tools in +# PARALLEL, so several PreToolUse hook processes run concurrently against one +# session's counter. A read-then-write of a decimal count lets all of them +# read the same value and record the same increment — N calls starting at 0 +# each record 1, each pass the budget, and blocking mode silently allows far +# more than the configured grace. Instead each call APPENDS one byte and +# takes the file's size as its count: O_APPEND writes of a single byte do not +# interleave, so the calls occupy distinct byte positions 1..N and the call +# landing at position k always reads a size >= k. At most GRACE calls can +# therefore observe count <= GRACE. The scheme can only over-count under a +# racing read (a later append landing first), which allows FEWER calls than +# the budget — the conservative direction for a gate. +# +# Residuals, accepted: Git Bash on Windows relies on MSYS honoring O_APPEND +# for the single-byte write; a failure there is no worse than the +# read-modify-write it replaces. A counter file left by a pre-0.4.0 decimal +# writer is measured as bytes for the rest of that one session (a stored "5\n" +# reads as 2), which under-counts once and self-heals on the next zone exit, +# which unlinks the file. +printf 'x' >>"$COUNT_FILE" 2>/dev/null || exit 0 +count=$(wc -c <"$COUNT_FILE" 2>/dev/null | tr -cd '0-9') +[[ "$count" =~ ^[0-9]{1,9}$ ]] || exit 0 +count=$((10#$count)) + +if ((count <= GRACE)); then + exit 0 +fi + +TOOL=$(hook::jq_field "$INPUT" '.tool_name') || TOOL="tool" +reason="context-guard blocking mode: this session is in the dumb context zone (fresh snapshot) and the grace budget ($GRACE matched calls) is exhausted, so new $TOOL work is denied. Write a durable handoff now and resume in a fresh session: handoff-path writes, read-only tools, Bash, and Skill invocations all remain allowed — run /session-flow:handoff (if installed), or write a resume file whose path contains 'handoff'. Operators can soften this via the zone_hook_mode userConfig option (advisory)." +jq -n --arg reason "$reason" \ + '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason}}' +hook::emit_telemetry "zone-gate" "PreToolUse" "blocked" "$START_EPOCH" \ + '{"zone":"dumb","grace":'"$GRACE"',"calls_seen":'"$count"'}' +exit 0 diff --git a/plugins/context-guard/hooks/zone-gate.test.sh b/plugins/context-guard/hooks/zone-gate.test.sh new file mode 100755 index 0000000000..3d84a35a92 --- /dev/null +++ b/plugins/context-guard/hooks/zone-gate.test.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# Contract test for zone-gate.sh (PreToolUse, blocking mode only). +# +# Contract: inert in advisory mode (the default) and when disabled; in +# blocking mode it denies matched calls ONLY on a fresh dumb-zone snapshot +# past the grace budget, with handoff-path writes exempt; fail-open on +# unknown, on smart/acceptable (which also reset the counter), on missing +# stdin, and on hostile ids. Exit 0 always. +# +# Self-contained: defines its own assertion helpers — installed plugins are +# cache-isolated with no shared test lib. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$SCRIPT_DIR/zone-gate.sh" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +command -v jq >/dev/null 2>&1 || { + echo "SKIP: jq not installed" + exit 0 +} + +WORK="$(mktemp -d)" +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +CTX_REL=".claude/context-guard/context" + +write_snapshot() { # + local home="$1" sid="$2" used="$3" + mkdir -p "$home/$CTX_REL" + printf '{"captured_at":"%s","session_id":"%s","context_window":{"used_percentage":%s,"remaining_percentage":50,"current_usage":{"input_tokens":100}}}\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$sid" "$used" >"$home/$CTX_REL/$sid.json" +} + +# run [] [] +OUT="" +RC=0 +run() { + local home="$1" data="$2" sid="$3" extra="$4" mode="${5:-blocking}" grace="${6:-}" + local env_grace=() + [[ -n "$grace" ]] && env_grace=(CLAUDE_PLUGIN_OPTION_ZONE_GATE_GRACE_CALLS="$grace") + OUT=$(printf '{"session_id":"%s","hook_event_name":"PreToolUse","tool_name":"Write"%s}' "$sid" "$extra" | + HOME="$home" CLAUDE_PLUGIN_DATA="$data" HOOK_TELEMETRY_SINK="" \ + CLAUDE_PLUGIN_OPTION_ZONE_HOOK_MODE="$mode" env "${env_grace[@]}" bash "$HOOK" 2>/dev/null) + RC=$? +} + +H="$WORK/home" +D="$WORK/data" + +# 1. Advisory mode (default) is inert even on dumb. +write_snapshot "$H" s1 95 +OUT=$(printf '{"session_id":"s1","hook_event_name":"PreToolUse","tool_name":"Write"}' | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" bash "$HOOK" 2>/dev/null) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "advisory default is inert"; else fail "advisory: rc=$RC out=$OUT"; fi + +# 2. Blocking + dumb within grace → allowed, counter counts. +run "$H" "$D" s1 '' blocking 2 +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "blocking within grace allows (call 1)"; else fail "grace call 1: rc=$RC out=$OUT"; fi +run "$H" "$D" s1 '' blocking 2 +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "blocking within grace allows (call 2)"; else fail "grace call 2: rc=$RC out=$OUT"; fi + +# 3. Grace exhausted → deny with permissionDecision. +run "$H" "$D" s1 '' blocking 2 +if jq -e '.hookSpecificOutput.permissionDecision == "deny"' <<<"$OUT" >/dev/null 2>&1; then + ok "grace exhausted denies" +else + fail "grace exhausted: rc=$RC out=$OUT" +fi +if [[ "$OUT" == *handoff* ]]; then ok "deny reason routes to a handoff"; else fail "deny reason lacks handoff routing: $OUT"; fi + +# 4. Handoff-path write exempt even past grace. +run "$H" "$D" s1 ',"tool_input":{"file_path":"/work/.work/handoffs/20260726-handoff-x.md"}' blocking 2 +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "handoff-path write exempt"; else fail "handoff exempt: rc=$RC out=$OUT"; fi + +# 5. Unknown zone (no snapshot) → fail-open, counter reset. +run "$H" "$D" nosuchsession '' blocking 2 +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "unknown zone fails open"; else fail "unknown: rc=$RC out=$OUT"; fi + +# 6. Leaving the dumb zone resets the counter. +write_snapshot "$H" s1 10 +run "$H" "$D" s1 '' blocking 2 +if [[ $RC -eq 0 && -z "$OUT" && ! -e "$D/state/s1.gate-count" ]]; then + ok "smart zone allows and resets the grace counter" +else + fail "smart reset: rc=$RC out=$OUT" +fi +write_snapshot "$H" s1 95 +run "$H" "$D" s1 '' blocking 2 +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "re-entering dumb starts a fresh grace budget"; else fail "fresh grace: rc=$RC out=$OUT"; fi + +# 7. Kill switch wins over blocking mode. +OUT=$(printf '{"session_id":"s1","hook_event_name":"PreToolUse","tool_name":"Write"}' | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" CLAUDE_PLUGIN_OPTION_ZONE_HOOK_MODE=blocking \ + CLAUDE_PLUGIN_OPTION_CONTEXT_GUARD_HOOKS_ENABLED=false bash "$HOOK" 2>/dev/null) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "kill switch wins over blocking"; else fail "kill switch: rc=$RC out=$OUT"; fi + +# 8. Hostile session id / empty stdin fail open. +run "$H" "$D" '../../etc' '' blocking 0 +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "hostile session id fails open"; else fail "hostile sid: rc=$RC out=$OUT"; fi +OUT=$(printf '' | HOME="$H" CLAUDE_PLUGIN_DATA="$D" CLAUDE_PLUGIN_OPTION_ZONE_HOOK_MODE=blocking bash "$HOOK" 2>/dev/null) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "empty stdin fails open"; else fail "empty stdin: rc=$RC out=$OUT"; fi + +# 9. Malformed grace value falls back to the in-script default (allows). +write_snapshot "$H" s9 95 +run "$H" "$D" s9 '' blocking 'banana' +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "malformed grace falls back to default (call 1 allowed)"; else fail "malformed grace: rc=$RC out=$OUT"; fi + +# 10. Large Write payload must still be denied (Win32-pipe single-read +# timeout made the gate fail open for exactly the biggest writes). +write_snapshot "$H" sbig 95 +BIG=$(printf 'x%.0s' $(seq 1 130000)) +OUT=$(printf '{"session_id":"sbig","hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/w/big.txt","content":"%s"}}' "$BIG" | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" HOOK_TELEMETRY_SINK="" CLAUDE_PLUGIN_OPTION_ZONE_HOOK_MODE=blocking CLAUDE_PLUGIN_OPTION_ZONE_GATE_GRACE_CALLS=0 bash "$HOOK" 2>/dev/null) +RC=$? +if jq -e '.hookSpecificOutput.permissionDecision == "deny"' <<<"$OUT" >/dev/null 2>&1; then + ok "130KB Write payload still denied (chunked stdin read)" +else + fail "large payload fail-open: rc=$RC out=${OUT:0:120}" +fi + +# 11. Evidence-degraded marker: a green snapshot with the .compacted marker +# present is treated as dumb (reader contract: presence alone is the signal), +# so the gate stays armed after compaction resets the percentage. +write_snapshot "$H" smk 10 +printf '{"compacted_at":"2026-07-26T00:00:00Z","trigger":"auto"} +' >"$H/$CTX_REL/smk.compacted" +run "$H" "$D" smk '' blocking 0 +if jq -e '.hookSpecificOutput.permissionDecision == "deny"' <<<"$OUT" >/dev/null 2>&1; then + ok "marker + smart snapshot still gates (evidence-degraded wins)" +else + fail "marker ignored by gate: rc=$RC out=${OUT:0:120}" +fi + +# 12. Leading-zero grace budget is read as base 10, not octal. `08` clears the +# digit-only validation but is an invalid octal literal, which made the +# arithmetic error out and deny call 1 instead of allowing 8. +write_snapshot "$H" soct 95 +run "$H" "$D" soct '' blocking '08' +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "leading-zero grace parses base 10 (call 1 allowed)"; else fail "octal grace: rc=$RC out=$OUT"; fi +for _ in 2 3 4 5 6 7 8; do run "$H" "$D" soct '' blocking '08'; done +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "leading-zero grace allows the full 8 calls"; else fail "octal grace call 8: rc=$RC out=$OUT"; fi +run "$H" "$D" soct '' blocking '08' +if jq -e '.hookSpecificOutput.permissionDecision == "deny"' <<<"$OUT" >/dev/null 2>&1; then + ok "leading-zero grace denies call 9" +else + fail "octal grace call 9: rc=$RC out=$OUT" +fi +if [[ "$OUT" == *"(8 matched calls)"* ]]; then + ok "deny reason reports the normalized budget" +else + fail "deny reason kept the non-canonical budget: $OUT" +fi + +# 13. Concurrent matched calls must not overspend the grace budget. Claude +# starts matched tools in parallel; a read-modify-write counter lets those +# hook processes all read the same value and record the same increment, so +# more than GRACE of them land inside the budget (measured 6–7 allowed of 24 +# against a budget of 4 before the fix). The atomic counter must allow at +# most GRACE. Over-denial is the conservative direction and is not asserted +# against — only the ceiling is. +write_snapshot "$H" spar 95 +CONC=24 +GRACE_N=4 +PAR_OUT="$WORK/par" +mkdir -p "$PAR_OUT" +for i in $(seq 1 "$CONC"); do + printf '{"session_id":"spar","hook_event_name":"PreToolUse","tool_name":"Write"}' | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" HOOK_TELEMETRY_SINK="" \ + CLAUDE_PLUGIN_OPTION_ZONE_HOOK_MODE=blocking \ + CLAUDE_PLUGIN_OPTION_ZONE_GATE_GRACE_CALLS="$GRACE_N" \ + bash "$HOOK" >"$PAR_OUT/$i.out" 2>/dev/null & +done +wait +allowed=0 +for f in "$PAR_OUT"/*.out; do + # An allowed call prints nothing; a denied one prints the deny JSON. + [[ -s "$f" ]] || allowed=$((allowed + 1)) +done +if [[ "$allowed" -le "$GRACE_N" ]]; then + ok "concurrent calls never exceed the grace budget ($allowed allowed of $CONC, budget $GRACE_N)" +else + fail "concurrent overspend: $allowed allowed of $CONC with budget $GRACE_N" +fi +denied=$((CONC - allowed)) +if [[ "$denied" -ge $((CONC - GRACE_N)) ]]; then + ok "concurrent calls past the budget are denied ($denied denied)" +else + fail "concurrent under-denial: only $denied denied of $CONC" +fi + +# A LARGE handoff-path write must still reach the exemption end-to-end: the +# chunked payload drain has to deliver the whole tool_input before the path +# check can see it, and a truncated read was a measured bug on Git Bash pipes. +# 70KB clears the 65536-byte pipe capacity with margin. +# +# Not a regression test for a here-string deadlock: the pre-fix +# `jq <<<"$INPUT"` form passes this case too (probed at 200KB on bash 5.3.9 +# Cygwin, which routes an over-capacity here-string through a temp file). The +# extraction was moved onto hook::jq_field for consistency with the file's +# other extractions, not to fix a reproducible hang. +write_snapshot "$H" sbig 95 +# Built via --rawfile, not --arg: 70KB on the command line exceeds the argv +# limit on some hosts, and a failed jq would leave an EMPTY payload that passes +# this test vacuously. +head -c 70000 /dev/zero | tr '\0' 'x' >"$WORK/big.txt" +BIG_PAYLOAD=$(jq -nc --rawfile c "$WORK/big.txt" \ + '{session_id:"sbig",hook_event_name:"PreToolUse",tool_name:"Write", + tool_input:{file_path:"/work/.work/handoffs/20260729-handoff-big.md",content:$c}}') +if ((${#BIG_PAYLOAD} < 65536)); then + fail "large-payload fixture is only ${#BIG_PAYLOAD} bytes — below the pipe capacity this test exists to cross" +fi +# The deadlock is a hang, not a failure — bound it so a regression reports +# instead of stalling the suite. +BIG_OUT=$(printf '%s' "$BIG_PAYLOAD" | + HOME="$H" CLAUDE_PLUGIN_DATA="$D" HOOK_TELEMETRY_SINK="" \ + CLAUDE_PLUGIN_OPTION_ZONE_HOOK_MODE=blocking \ + CLAUDE_PLUGIN_OPTION_ZONE_GATE_GRACE_CALLS=0 \ + timeout 20 bash "$HOOK" 2>/dev/null) +BIG_RC=$? +if [[ $BIG_RC -eq 124 ]]; then + fail "large handoff write deadlocked the gate (timed out)" +elif [[ $BIG_RC -eq 0 && -z "$BIG_OUT" ]]; then + ok "70KB handoff-path write reaches the exemption intact" +else + fail "large handoff write: rc=$BIG_RC out=$BIG_OUT" +fi + +# No resolvable state root → fail open rather than scatter the grace counter +# into the working directory. +write_snapshot "$WORK/nohome" snr 95 +NR_OUT=$(printf '{"session_id":"snr","hook_event_name":"PreToolUse","tool_name":"Write"}' | + env -u HOME -u CLAUDE_PLUGIN_DATA HOOK_TELEMETRY_SINK="" \ + CLAUDE_PLUGIN_OPTION_ZONE_HOOK_MODE=blocking \ + CLAUDE_PLUGIN_OPTION_ZONE_GATE_GRACE_CALLS=0 \ + bash "$HOOK" 2>/dev/null) +NR_RC=$? +if [[ $NR_RC -eq 0 && -z "$NR_OUT" ]]; then + ok "no HOME and no CLAUDE_PLUGIN_DATA fails open" +else + fail "no state root: rc=$NR_RC out=$NR_OUT" +fi +if [[ ! -e "./.claude/context-guard/state" ]]; then + ok "no state written relative to the working directory" +else + fail "grace counter leaked into the working directory" +fi + +echo +echo "PASS=$PASS FAIL=$FAIL" +[[ $FAIL -eq 0 ]] diff --git a/plugins/context-guard/reference/reader-contract.md b/plugins/context-guard/reference/reader-contract.md index c0a1bf3fa5..d350469993 100644 --- a/plugins/context-guard/reference/reader-contract.md +++ b/plugins/context-guard/reference/reader-contract.md @@ -18,10 +18,23 @@ grep-matches its inlined values against this file. - **Zones file (fixed path, optional):** `~/.claude/context-guard/zones.json` - **Staleness rule:** a snapshot whose `captured_at` is older than **10 minutes** is stale — treat the zone as **unknown** for that decision. -- **Default zone bands (over `context_window.used_percentage`, uppers inclusive):** +- **Default percentage bands (over `context_window.used_percentage`, uppers inclusive):** `smart` ≤ **50** < `acceptable` ≤ **75** < `dumb`. These shipped defaults apply only when `zones.json` is absent or malformed; when the file is present and valid, its bands win (see Zones below). +- **Default token bands (over occupancy = `total_input_tokens` + `total_output_tokens`, uppers + inclusive, selected by window class — see "Occupancy and combination rule"):** + window class **200000**: `smart` ≤ **100000** < `acceptable` ≤ **160000** < `dumb`; + window class **1000000**: `smart` ≤ **200000** < `acceptable` ≤ **400000** < `dumb`. +- **Token-shape version floor (fixed):** the token shape is computable only when the snapshot's + `cli_version` is present, purely numeric dotted, and **≥ 2.1.132** — the release from which the + token fields mean current occupancy rather than cumulative session totals. +- **Combination rule (verbatim — consumers inline this sentence):** when both shapes are + computable, the worse zone wins (conservative-min); when only one is computable, it stands + alone; when neither is, the zone is unknown. +- **Evidence-degraded marker (fixed path, optional):** + `~/.claude/context-guard/context/.compacted` — presence means the session was + compacted; treat it as evidence-degraded regardless of zone. - **Zone vocabulary:** `smart` / `acceptable` / `dumb` / `unknown` — `unknown` is the conservative word; consumers treat it as "assume degraded". @@ -35,6 +48,7 @@ concurrent sessions each own the file named by their `session_id`. { "captured_at": "2026-07-24T05:32:48Z", "session_id": "abc123", + "cli_version": "2.1.218", "context_window": { "total_input_tokens": 15500, "total_output_tokens": 1200, @@ -54,6 +68,9 @@ concurrent sessions each own the file named by their `session_id`. - `captured_at` — ISO-8601 UTC write time; always present. Drives the staleness rule. - `session_id` — always present (the tee refuses to write without one); also the filename stem, sanitized to `[A-Za-z0-9_-]`. +- `cli_version` — the statusline payload's top-level `version` (the Claude Code version), copied + only when it is a string; absent otherwise, never guessed. It gates the token shape (see "Version + floor"), so an absent one is not a defect — it just leaves the percentage shape standing alone. - `context_window` — copied **verbatim** from the statusline stdin schema (, verified 2026-07-24), so upstream field additions flow through without a plugin change. The key is absent when the session's statusline payload @@ -74,18 +91,127 @@ concurrent sessions each own the file named by their `session_id`. ## Capability detection (fail-open) -A consumer classifies before every zone-informed decision: +A consumer classifies before every zone-informed decision. Capability is **per shape**, because the +combination rule below already says what to do when only one shape is computable — a row that +dropped straight to `unknown` on a single missing field would contradict it. Only the snapshot-wide +rows answer `unknown` on their own: -| Observation | Zone | +| Observation | Effect | |---|---| -| Fresh snapshot, numeric `used_percentage` 0–100, non-null `current_usage` | resolve bands normally | -| Snapshot absent, stale, or unparsable | **unknown** | -| `used_percentage` null / missing / non-numeric / outside 0–100 | **unknown** | -| `current_usage` null or missing (early-session or post-`/compact` state) | **unknown** | -| jq (or equivalent JSON parsing) unavailable to the consumer | **unknown** | +| Snapshot absent, stale, or unparsable | **unknown** (snapshot-wide) | +| Embedded `session_id` not equal to the requested id | **unknown** (snapshot-wide) | +| `current_usage` null or missing (early-session or post-`/compact` state) | **unknown** (snapshot-wide — a compacted session's numbers are not evidence for either shape) | +| jq (or equivalent JSON parsing) unavailable to the consumer | **unknown** (snapshot-wide) | +| `used_percentage` null / missing / non-numeric / outside 0–100 | **percentage shape not computable** | +| `total_input_tokens` / `total_output_tokens` null, missing, non-numeric, or negative | **token shape not computable** | +| `context_window_size` null, missing, non-positive, or below every configured band class | **token shape not computable** | +| `cli_version` absent, non-numeric, or below the version floor (see below) | **token shape not computable** | +| occupancy greater than `context_window_size` | **token shape not computable** | +| Both shapes computable | combine per the combination rule (worse zone wins) | + +A "not computable" shape drops out of the combination rule; the surviving shape stands alone, and +`unknown` follows only when neither survives. Absurd values fail open, never closed: the consumer +never skips its conservative path on data it cannot trust, and never fabricates a zone. `unknown` +always means "take the conservative route". + +## Occupancy and combination rule + +The contract carries TWO zone shapes because the two underlying measures answer different +questions — never equate them without normalizing: + +- **Percentage shape** — `context_window.used_percentage` against the percentage bands. Upstream + computes it from **input tokens only** (`input_tokens + cache_creation_input_tokens + + cache_read_input_tokens`, no output — statusline doc, verified 2026-07-26). It answers + *distance to compaction*, because compaction thresholds key off the same accounting. +- **Token shape** — **occupancy**, defined as `total_input_tokens + total_output_tokens`, against + the window-class token bands. Occupancy counts both directions because both occupy the window, + and the degradation evidence (Chroma context-rot report; Anthropic system-card fixed-point + evals) tracks **absolute tokens in context, not window fraction**. It answers *distance to + quality loss*. That is also why the token bands are absolute numbers selected by window class + rather than percentages: 50% of a 1M window is a materially different cognitive state than 50% + of a 200k window. + +**Window-class selection:** use the band row whose class key is the **largest one ≤ +`context_window_size`**. A window smaller than every configured class has no row — the token +shape is then not computable (never borrow a larger class's looser bands). + +**Combination rule (consumers inline this sentence verbatim):** when both shapes are computable, +the worse zone wins (conservative-min); when only one is computable, it stands alone; when +neither is, the zone is unknown. Rationale: the two shapes disagree exactly when one measure has +information the other lacks (a deep-but-cache-heavy window, a small window near compaction), and +a routing hint must degrade toward caution, never toward optimism. + +**Version floor:** `total_input_tokens` / `total_output_tokens` mean *current context occupancy* +only since Claude Code **2.1.132** — before that they were cumulative session totals ("Before +v2.1.132 these were cumulative session totals", statusline doc, verified 2026-07-26), which would +misfire the token bands badly. Cumulative semantics are **not observable from the numbers**: a +cumulative 170k in a 200k window is a perfectly plausible current occupancy, sits inside the +window, and resolves `dumb` while the live context may be smart-zone. So the token shape requires +an explicit version signal — the snapshot's `cli_version`, which the tee copies from the +statusline payload's top-level `version` field (Claude Code version — statusline doc, verified +2026-07-26). **The token shape is computable only when `cli_version` is present, purely numeric +dotted, and ≥ 2.1.132**; absent, malformed, or older leaves the percentage shape to stand alone. + +**Plausibility guard (independent, retained):** **occupancy greater than `context_window_size` +also marks the token shape not-computable** — that is corrupt or forged data, and it catches what +a version field cannot (there is no writer authentication, so `cli_version` is untrusted like +every other snapshot value). The bundled resolver implements both gates. + +**Percentage-key retirement trigger:** the percentage vocabulary is retained because it answers a +question the token shape cannot (distance to compaction) and because shipped consumers inline its +floor today. It retires when no shipped consumer inlines the percentage floor any longer — +recorded here so back-compat alone never makes the second vocabulary permanent. + +**Band provenance:** all shipped band numbers are **declared judgment defaults with named +anchors** (issue #1475 carries the full provenance table), not benchmark-derived constants. The +1M row's anchor is a named-staff informal range (self-hedged "highly task-dependent"); the 200k +row is declared judgment near — but deliberately below — practitioner folklore values. Both rows +carry equally low confidence; `zones.json` is the correction path, and the numeric agreement of +the 200k row's percentage translation with the shipped 50/75 percentage defaults is coincidence, +not validation. + +## Zone-crossing hooks (first shipped consumer) + +Since 0.4.0 the plugin itself ships hooks over its own seam — the first shipped consumer: + +- **Advisory injection** (`PostToolBatch` + `UserPromptSubmit`): once per transition into a worse + zone, inject continuation guidance (a minimal generic continuation tree plus a presence-gated + pointer to `session-flow:workflow`'s router). Silent while the zone is unchanged, improving, or + `unknown`. +- **Blocking gate** (`PreToolUse`, only when the `zone_hook_mode` userConfig option is + `blocking`): denies new `Write|Edit|NotebookEdit|Agent|Workflow` calls on a **fresh dumb-zone + snapshot** past a small grace budget. Fail-open on `unknown`; handoff-path writes, read-only + tools, Bash, and Skill invocations are never gated, so a durable handoff is always writable. +- **PostCompact marker**: writes the evidence-degraded marker file (below) and re-arms the + blocking gate's grace budget (compaction opens a fresh window — a fresh budget, not a disarmed + gate). +- **Both zone consumers honor the marker**: when the marker exists, the injection hook and the + blocking gate treat the session's effective zone as **dumb** regardless of the resolved word — + including a green post-compaction reading and including `unknown` — implementing this + contract's own "evidence-degraded regardless of zone" rule so the marker is never write-only. + +Hook state (last-seen zone, gate counters) lives under `${CLAUDE_PLUGIN_DATA}` — plugin-private, +NOT part of this contract. The hooks consume the seam through the same resolver consumers +re-implement; they add no new snapshot semantics. + +## Evidence-degraded marker + +`~/.claude/context-guard/context/.compacted` — written by the PostCompact hook, +last-write-wins per session: -Absurd values fail open, never closed: the consumer never skips its conservative path on data it -cannot trust, and never fabricates a zone. `unknown` always means "take the conservative route". +```json +{ "compacted_at": "2026-07-26T12:00:00Z", "trigger": "auto", "hook_event_name": "PostCompact" } +``` + +`trigger` is `manual` | `auto` | `unknown`. **Presence alone is the signal**: a consumer that +finds the marker treats the session as evidence-degraded regardless of a green zone (see the next +section for why). Consumers should not gate on `compacted_at` freshness — compaction's evidence +loss does not expire with time in the same session. The marker is part of this contract's seam +(fixed path, same character-class and trust rules as snapshots); it closes the documented gap +that the snapshot alone cannot reveal compaction. Housekeeping: the writer hook prunes sibling +markers older than 14 days on each write — the same cutoff the tee applies to snapshots, far +above any live session's horizon, so a marker is never deleted out from under the session it +describes. ## Zone is NOT a compaction indicator @@ -121,14 +247,28 @@ what the human sees and what consumers decide on. Zones say *where you are*; con ```json { "smart_max_used_percentage": 50, - "acceptable_max_used_percentage": 75 + "acceptable_max_used_percentage": 75, + "token_bands": { + "200000": { "smart_max_tokens": 100000, "acceptable_max_tokens": 160000 }, + "1000000": { "smart_max_tokens": 200000, "acceptable_max_tokens": 400000 } + } } ``` -Validity: both values numeric, `0 < smart_max < acceptable_max ≤ 100`. A malformed file (unparsable, -non-numeric, inverted, out of range) falls back to the shipped defaults with a visible stderr -notice from the resolver. Unrecognized keys are permitted and preserved (the setup skill's `apply` -seeds/refreshes this file idempotently; the resolver only reads it). +Validity is **per shape, independently**: + +- **Percentage keys:** both values numeric, `0 < smart_max < acceptable_max ≤ 100`. Malformed + (unparsable file, non-numeric, inverted, out of range) → shipped percentage defaults with a + visible stderr notice from the resolver (unchanged v1 behavior, including when the keys are + simply absent from an otherwise-parsable file). +- **`token_bands` (optional):** when present, an object whose every key is a decimal window-class + string and every value carries numeric `smart_max_tokens` and `acceptable_max_tokens` with + `0 < smart < acceptable ≤ class`. Malformed as a whole → shipped token bands with its own + visible stderr notice. **Absent is zero-config** (shipped token bands, silently) — a v1 + percentage-only file keeps working unchanged. + +Unrecognized keys are permitted and preserved (the setup skill's `apply` seeds/refreshes this +file idempotently; the resolver only reads it). **Consumers read `zones.json` directly** (it is a data seam): under plugin cache isolation a consumer cannot invoke this plugin's `context-zone.sh`, so it re-implements the band lookup — @@ -179,6 +319,8 @@ above the staleness window, so idle sessions' files are never deleted out from u ## Consumers -First consumer: the `plugin-quality` audit skill (context-gate: zone-informed dispatch and -evidence-flush decisions, conservative on `unknown`). Its inlined floor values are drift-checked -against this file in its own lane. +- The plugin's own zone-crossing hooks (first shipped consumer — see "Zone-crossing hooks"). +- The `plugin-quality` audit skill (context-gate: zone-informed dispatch and evidence-flush + decisions, conservative on `unknown`). Its inlined floor values are drift-checked against this + file by its co-located `zones-inline-drift.test.sh` lane, which runs in the repo's plugin-gate + CI job. diff --git a/plugins/context-guard/scripts/context-zone.sh b/plugins/context-guard/scripts/context-zone.sh index 17defb0fbe..bc4ab85af5 100755 --- a/plugins/context-guard/scripts/context-zone.sh +++ b/plugins/context-guard/scripts/context-zone.sh @@ -12,34 +12,82 @@ # # Fail-open (../reference/reader-contract.md is the authoritative contract): # absent, stale (captured_at older than the 10-minute staleness window), or -# unparsable snapshot → unknown; used_percentage null / missing / -# non-numeric / outside 0–100 → unknown; current_usage null or missing -# (documented early-session and post-/compact statusline states — a -# compacted session's percentage is not evidence) → unknown; jq absent → -# unknown. The resolver never throttles a consumer on data it cannot trust -# and never fabricates a zone. Exit code is always 0 — the word is the -# contract. +# unparsable snapshot → unknown; current_usage null or missing (documented +# early-session and post-/compact statusline states — a compacted session's +# numbers are not evidence) → unknown; jq absent → unknown. The resolver +# never throttles a consumer on data it cannot trust and never fabricates a +# zone. Exit code is always 0 — the word is the contract. # -# SHIPPED DEFAULT BANDS (zones.json absent = zero-config): smart ≤ 50 < -# acceptable ≤ 75 < dumb, over context_window.used_percentage. These are -# declared judgment defaults, NOT doc-derived constants: no official page -# documents an auto-compaction threshold percentage (verified 2026-07-24 -# against the statusline, how-Claude-Code-works, context-window, settings, -# and costs pages — they say only "as you approach the limit"). zones.json -# is the operator's tuning path: +# TWO ZONE SHAPES, ONE COMBINATION RULE (reader contract, "Occupancy and +# combination rule"): # -# { "smart_max_used_percentage": 50, "acceptable_max_used_percentage": 75 } +# percentage shape — context_window.used_percentage against the percentage +# bands. Upstream computes used_percentage from INPUT tokens only +# (input + cache_creation + cache_read; no output — statusline doc), so +# it answers "distance to compaction". +# token shape — occupancy = total_input_tokens + total_output_tokens +# against the window-class token bands. Occupancy counts BOTH directions +# because both occupy the window, and degradation research tracks +# absolute tokens in context, not window fraction. It answers "distance +# to quality loss". The two shapes are different units answering +# different questions — never equate them without normalizing. # -# A malformed zones.json (unparsable, non-numeric bands, inverted ordering, -# bands outside 0–100) falls back to the shipped defaults with a visible -# stderr notice — never silently. The resolver only ever READS zones.json; -# seeding/refreshing it is the setup skill's `apply`. +# Combination: when both shapes are computable, the WORSE zone wins +# (conservative-min). When only one is computable, it stands alone. When +# neither is, the zone is unknown. +# +# TOKEN-SHAPE VERSION GATE: total_input_tokens / total_output_tokens mean +# *current context occupancy* only since Claude Code 2.1.132 — before that +# they were cumulative session totals, which would misfire the bands badly. +# A cumulative total is NOT observable from the numbers alone: 170k +# cumulative in a 200k window is a perfectly plausible current occupancy and +# resolves dumb while the live context may be nowhere near it. So the token +# shape requires the snapshot's cli_version to be >= 2.1.132; an absent, +# malformed, or older version marks it not-computable and leaves the +# percentage shape to stand alone. A second, independent plausibility guard +# still rejects occupancy greater than context_window_size (corrupt data, or +# a snapshot whose version field was forged). +# +# SHIPPED DEFAULT BANDS (zones.json absent = zero-config): percentage bands +# smart ≤ 50 < acceptable ≤ 75 < dumb over used_percentage, and per +# window-class token bands over occupancy: +# +# window class 200000: smart ≤ 100000 < acceptable ≤ 160000 < dumb +# window class 1000000: smart ≤ 200000 < acceptable ≤ 400000 < dumb +# +# All are declared judgment defaults, NOT doc- or benchmark-derived +# constants (anchors and provenance are recorded in the reader contract). +# zones.json is the operator's tuning path: +# +# { +# "smart_max_used_percentage": 50, +# "acceptable_max_used_percentage": 75, +# "token_bands": { +# "200000": { "smart_max_tokens": 100000, "acceptable_max_tokens": 160000 }, +# "1000000": { "smart_max_tokens": 200000, "acceptable_max_tokens": 400000 } +# } +# } +# +# Window-class selection: the band row whose class key is the LARGEST one +# ≤ context_window_size. A window smaller than every configured class has no +# row — the token shape is not-computable for it (never borrow a larger +# class's looser bands). +# +# Each shape's configuration is validated independently: malformed +# percentage keys fall back to the shipped percentage defaults with a +# visible stderr notice (unchanged v1 behavior); a malformed token_bands +# object falls back to the shipped token bands with its own notice; an +# ABSENT token_bands key is zero-config (shipped token defaults, silent) so +# a v1 percentage-only zones.json keeps working unchanged. The resolver only +# ever READS zones.json; seeding/refreshing it is the setup skill's `apply`. set -uo pipefail STALENESS_SECONDS=600 # 10 minutes — byte-matches the reader contract DEFAULT_SMART_MAX=50 DEFAULT_ACCEPTABLE_MAX=75 +# "class smart_max acceptable_max" rows, ascending class order. +DEFAULT_TOKEN_BANDS=$'200000 100000 160000\n1000000 200000 400000' unknown() { printf 'unknown\n' @@ -56,23 +104,40 @@ command -v jq >/dev/null 2>&1 || unknown snap="$HOME/.claude/context-guard/context/$sid.json" [[ -r "$snap" ]] || unknown -# One validation pass over the snapshot: emits "captured_at used_percentage" -# only when every trust precondition holds; anything else is "invalid". The -# embedded session_id must equal the REQUESTED id — the seam is per-session -# and a copied/renamed snapshot must not answer for another session. +# One validation pass over the snapshot. Trust gates (shape, captured_at, +# embedded session_id equal to the REQUESTED id — the seam is per-session and +# a copied/renamed snapshot must not answer for another session, non-null +# current_usage) emit "invalid"; past them it emits one line +# "captured_at ver pct ti to cws" where each measurement field is a number or +# the literal x when that field is null / missing / out of documented range, +# and ver is the snapshot's cli_version string or x. parsed=$(jq -r --arg sid "$sid" ' if (type != "object") then "invalid" elif ((.captured_at? // null) | type) != "string" then "invalid" elif (.session_id? // null) != $sid then "invalid" elif ((.context_window? // null) | type) != "object" then "invalid" elif (.context_window.current_usage? // null) == null then "invalid" - elif ((.context_window.used_percentage? // null) | type) != "number" then "invalid" - elif (.context_window.used_percentage < 0) or (.context_window.used_percentage > 100) then "invalid" - else "\(.captured_at) \(.context_window.used_percentage)" + else + [ .captured_at, + (if ((.cli_version? // null) | type) == "string" and (.cli_version | test("^[0-9]+(\\.[0-9]+)*$")) + then .cli_version else "x" end), + (if ((.context_window.used_percentage? // null) | type) == "number" + and (.context_window.used_percentage >= 0) + and (.context_window.used_percentage <= 100) + then (.context_window.used_percentage | tostring) else "x" end), + (if ((.context_window.total_input_tokens? // null) | type) == "number" + and (.context_window.total_input_tokens >= 0) + then (.context_window.total_input_tokens | tostring) else "x" end), + (if ((.context_window.total_output_tokens? // null) | type) == "number" + and (.context_window.total_output_tokens >= 0) + then (.context_window.total_output_tokens | tostring) else "x" end), + (if ((.context_window.context_window_size? // null) | type) == "number" + and (.context_window.context_window_size > 0) + then (.context_window.context_window_size | tostring) else "x" end) + ] | join(" ") end' "$snap" 2>/dev/null) || unknown [[ -n "$parsed" && "$parsed" != "invalid" ]] || unknown -ts=${parsed%% *} -used=${parsed#* } +read -r ts ver pct ti to cws <<<"$parsed" || unknown # Strict ISO-8601 UTC format gate BEFORE any date parsing: GNU date -d also # accepts natural-language values ("now", "1 second ago") that would let a @@ -91,9 +156,10 @@ age=$((now_epoch - snap_epoch)) ((age >= -60 && age <= STALENESS_SECONDS)) || unknown # Band resolution: zones.json override when present and valid, shipped -# defaults otherwise (with a visible notice when the file exists but is bad). +# defaults otherwise (with a visible notice when a present shape is bad). smart_max=$DEFAULT_SMART_MAX acceptable_max=$DEFAULT_ACCEPTABLE_MAX +token_bands=$DEFAULT_TOKEN_BANDS zones="$HOME/.claude/context-guard/zones.json" if [[ -e "$zones" ]]; then bands=$(jq -r ' @@ -113,12 +179,111 @@ if [[ -e "$zones" ]]; then smart_max=${bands%% *} acceptable_max=${bands#* } fi + # token_bands is optional (a v1 percentage-only file is zero-config for the + # token shape); when PRESENT it must validate as a whole or the shipped + # token bands apply with a notice. + tb=$(jq -r ' + if (.token_bands? // null) == null then "absent" + elif ((.token_bands | type) == "object") + and ((.token_bands | length) > 0) + and (.token_bands | to_entries | all( + (.key | test("^[0-9]+$")) + and ((.value | type) == "object") + and ((.value.smart_max_tokens? // null) | type) == "number" + and ((.value.acceptable_max_tokens? // null) | type) == "number" + and (.value.smart_max_tokens > 0) + and (.value.smart_max_tokens < .value.acceptable_max_tokens) + and (.value.acceptable_max_tokens <= (.key | tonumber)) + )) + then (.token_bands | to_entries + | sort_by(.key | tonumber) + | map("\(.key) \(.value.smart_max_tokens) \(.value.acceptable_max_tokens)") + | join("\n")) + else "invalid" + end' "$zones" 2>/dev/null) || tb="invalid" + if [[ "$tb" == "invalid" || -z "$tb" ]]; then + printf 'context-guard: zones.json token_bands malformed — using shipped default token bands (200000:100000/160000, 1000000:200000/400000)\n' >&2 + elif [[ "$tb" != "absent" ]]; then + token_bands=$tb + fi +fi + +# Percentage shape: used_percentage against the percentage bands. +pct_zone="x" +if [[ "$pct" != "x" ]]; then + pct_zone=$(awk -v u="$pct" -v s="$smart_max" -v a="$acceptable_max" 'BEGIN { + if (u <= s) print "smart" + else if (u <= a) print "acceptable" + else print "dumb" + }' 2>/dev/null) || pct_zone="x" fi -# Float-safe comparison; band uppers are inclusive. -awk -v u="$used" -v s="$smart_max" -v a="$acceptable_max" 'BEGIN { - if (u <= s) print "smart" - else if (u <= a) print "acceptable" - else print "dumb" -}' 2>/dev/null || unknown +# Token-shape version gate: the token fields carry current-occupancy +# semantics only from TOKEN_SEMANTICS_MIN_VERSION on. Compared component by +# component in awk — deliberately NOT `sort -V`, which is a GNU-only +# construct this repo's portability lane rejects. Missing components compare +# as 0, so "2.2" reads as 2.2.0; a version the jq gate already rejected +# arrives as x and fails here. +TOKEN_SEMANTICS_MIN_VERSION="2.1.132" +version_at_least() { # + [[ "$1" =~ ^[0-9]+(\.[0-9]+)*$ ]] || return 1 + awk -F. -v a="$1" -v b="$2" 'BEGIN { + na = split(a, x, "."); nb = split(b, y, ".") + n = (na > nb ? na : nb) + for (i = 1; i <= n; i++) { + av = (i <= na ? x[i] + 0 : 0); bv = (i <= nb ? y[i] + 0 : 0) + if (av > bv) exit 0 + if (av < bv) exit 1 + } + exit 0 + }' 2>/dev/null +} + +# Token shape: occupancy = total_input_tokens + total_output_tokens against +# the selected window-class row. Requires the version gate above, plus the +# independent plausibility guard that occupancy above the window size is +# impossible as current occupancy (corrupt or forged data). +tok_zone="x" +if [[ "$ti" != "x" && "$to" != "x" && "$cws" != "x" ]] && + version_at_least "$ver" "$TOKEN_SEMANTICS_MIN_VERSION"; then + tok_zone=$(awk -v ti="$ti" -v to="$to" -v cws="$cws" ' + BEGIN { occ = ti + to; cls = -1 } + { + # rows arrive "class smart acceptable", ascending: keep the largest + # class that fits inside this window. + if ($1 + 0 <= cws + 0 && $1 + 0 > cls) { cls = $1 + 0; s = $2; a = $3 } + } + END { + if (occ > cws) { print "x"; exit } # plausibility guard + if (cls < 0) { print "x"; exit } # window smaller than every class + if (occ <= s) print "smart" + else if (occ <= a) print "acceptable" + else print "dumb" + }' <<<"$token_bands" 2>/dev/null) || tok_zone="x" +fi + +# Combination rule (verbatim in the reader contract): both computable → the +# worse zone wins; one computable → it stands alone; neither → unknown. +rank() { + case "$1" in + smart) printf '0' ;; + acceptable) printf '1' ;; + dumb) printf '2' ;; + *) printf 'x' ;; + esac +} +pr=$(rank "$pct_zone") +tr_=$(rank "$tok_zone") +if [[ "$pr" == "x" && "$tr_" == "x" ]]; then + unknown +fi +worst=-1 +[[ "$pr" != "x" ]] && ((pr > worst)) && worst=$pr +[[ "$tr_" != "x" ]] && ((tr_ > worst)) && worst=$tr_ +case "$worst" in +0) printf 'smart\n' ;; +1) printf 'acceptable\n' ;; +2) printf 'dumb\n' ;; +*) printf 'unknown\n' ;; +esac exit 0 diff --git a/plugins/context-guard/scripts/context-zone.test.sh b/plugins/context-guard/scripts/context-zone.test.sh index cafd133758..376ba4552b 100755 --- a/plugins/context-guard/scripts/context-zone.test.sh +++ b/plugins/context-guard/scripts/context-zone.test.sh @@ -120,8 +120,9 @@ done # --- Snapshot session_id must match the requested id ------------------------- write_snapshot "$H" simposter 40 -sed -i 's/"session_id":"simposter"/"session_id":"someone-else"/' "$H/$CTX_REL/simposter.json" 2>/dev/null || - perl -pi -e 's/"session_id":"simposter"/"session_id":"someone-else"/' "$H/$CTX_REL/simposter.json" +sed 's/"session_id":"simposter"/"session_id":"someone-else"/' "$H/$CTX_REL/simposter.json" \ + >"$H/$CTX_REL/simposter.json.tmp" && + mv "$H/$CTX_REL/simposter.json.tmp" "$H/$CTX_REL/simposter.json" expect "snapshot session_id mismatch (copied/renamed file)" unknown "$H" simposter # --- zones.json override ----------------------------------------------------- @@ -159,6 +160,96 @@ printf '{"smart_max_used_percentage":"low","acceptable_max_used_percentage":60}\ GOT="$(HOME="$HM" bash "$ZONE" m40 2>/dev/null)" if [[ "$GOT" == "smart" ]]; then ok "non-numeric band → shipped defaults applied"; else fail "non-numeric band: got '$GOT'"; fi +# --- Token shape: version gate, window-class bands, combination, plausibility - +# write_snapshot_tok [] [] +# cli_version defaults to a version at or above the current-occupancy floor; +# pass the literal `omit` to write a snapshot with no cli_version at all. +write_snapshot_tok() { + local home="$1" sid="$2" used="$3" ti="$4" to="$5" cws="$6" ts="${7:-}" ver="${8:-\"2.1.218\"}" + [[ -n "$ts" ]] || ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + local verfield="" + [[ "$ver" == "omit" ]] || verfield=",\"cli_version\":$ver" + mkdir -p "$home/$CTX_REL" + printf '{"captured_at":"%s","session_id":"%s"%s,"context_window":{"total_input_tokens":%s,"total_output_tokens":%s,"context_window_size":%s,"used_percentage":%s,"remaining_percentage":50,"current_usage":{"input_tokens":100}}}\n' \ + "$ts" "$sid" "$verfield" "$ti" "$to" "$cws" "$used" >"$home/$CTX_REL/$sid.json" +} + +HT="$WORK/h-token" +# Token shape stands alone when used_percentage is null but tokens are valid. +write_snapshot_tok "$HT" t1 null 150000 10000 1000000 && expect "tokens alone: occ=160k on 1M" smart "$HT" t1 +write_snapshot_tok "$HT" t2 null 280000 20000 1000000 && expect "tokens alone: occ=300k on 1M" acceptable "$HT" t2 +write_snapshot_tok "$HT" t3 null 390000 10001 1000000 && expect "tokens alone: occ=400001 on 1M" dumb "$HT" t3 +# Shipped 200k-class edges, uppers inclusive. +write_snapshot_tok "$HT" t4 null 90000 10000 200000 && expect "200k class: occ=100000 (smart edge)" smart "$HT" t4 +write_snapshot_tok "$HT" t5 null 150000 10000 200000 && expect "200k class: occ=160000 (acceptable edge)" acceptable "$HT" t5 +write_snapshot_tok "$HT" t6 null 150001 10000 200000 && expect "200k class: occ=160001" dumb "$HT" t6 +# Combination rule: the worse of the two computable shapes wins. +write_snapshot_tok "$HT" c1 40 150000 20000 200000 && expect "pct smart + tokens dumb → dumb" dumb "$HT" c1 +write_snapshot_tok "$HT" c2 80 40000 10000 200000 && expect "pct dumb + tokens smart → dumb" dumb "$HT" c2 +write_snapshot_tok "$HT" c3 40 40000 10000 200000 && expect "pct smart + tokens smart → smart" smart "$HT" c3 +write_snapshot_tok "$HT" c4 60 40000 10000 200000 && expect "pct acceptable + tokens smart → acceptable" acceptable "$HT" c4 +# Plausibility guard: occupancy above the window size is impossible as current +# occupancy (corrupt or forged data); the percentage shape stands alone. +write_snapshot_tok "$HT" p1 40 450000 50000 200000 && expect "implausible occ=500k>200k: pct stands alone" smart "$HT" p1 +write_snapshot_tok "$HT" p2 null 450000 50000 200000 && expect "implausible occ + null pct → unknown" unknown "$HT" p2 + +# Version gate. Before 2.1.132 the token fields were CUMULATIVE session totals, +# and a cumulative total below the window size is indistinguishable from a real +# occupancy — the occupancy>window guard alone never catches it. The regression +# case is exactly that: 170k cumulative in a 200k window sits inside the window, +# passes the plausibility guard, and would resolve dumb while the live context +# is smart-zone. +write_snapshot_tok "$HT" g1 10 160000 10000 200000 '' '"2.1.131"' && + expect "pre-2.1.132 cumulative 170k in a 200k window: token shape dropped, pct stands alone" smart "$HT" g1 +write_snapshot_tok "$HT" g2 null 160000 10000 200000 '' '"2.1.131"' && + expect "pre-2.1.132 + null pct → unknown, never a token-band zone" unknown "$HT" g2 +write_snapshot_tok "$HT" g3 null 160000 10000 200000 '' '"2.1.132"' && + expect "2.1.132 exactly (floor, inclusive): token shape computable" dumb "$HT" g3 +write_snapshot_tok "$HT" g4 null 160000 10000 200000 '' '"2.2"' && + expect "2.2 (short form, > floor): token shape computable" dumb "$HT" g4 +write_snapshot_tok "$HT" g5 null 160000 10000 200000 '' '"3.0.0"' && + expect "3.0.0 (major bump): token shape computable" dumb "$HT" g5 +write_snapshot_tok "$HT" g6 null 160000 10000 200000 '' '"2.1.99"' && + expect "2.1.99 (numeric, not lexical, comparison): token shape dropped" unknown "$HT" g6 +write_snapshot_tok "$HT" g7 null 160000 10000 200000 '' omit && + expect "cli_version absent (older tee, or no version on stdin): token shape dropped" unknown "$HT" g7 +write_snapshot_tok "$HT" g8 null 160000 10000 200000 '' '"2.1.132-beta"' && + expect "non-numeric version string: token shape dropped" unknown "$HT" g8 +write_snapshot_tok "$HT" g9 null 160000 10000 200000 '' 2 && + expect "cli_version not a string: token shape dropped" unknown "$HT" g9 +# Window smaller than every configured class: token shape not computable. +write_snapshot_tok "$HT" w1 null 40000 10000 100000 && expect "window below all classes + null pct → unknown" unknown "$HT" w1 +write_snapshot_tok "$HT" w2 40 40000 10000 100000 && expect "window below all classes: pct stands alone" smart "$HT" w2 + +# token_bands override honored. +HTB="$WORK/h-tokenbands" +mkdir -p "$HTB/.claude/context-guard" +printf '{"smart_max_used_percentage":50,"acceptable_max_used_percentage":75,"token_bands":{"200000":{"smart_max_tokens":50000,"acceptable_max_tokens":80000}}}\n' \ + >"$HTB/.claude/context-guard/zones.json" +write_snapshot_tok "$HTB" b1 null 60000 10000 200000 && expect "override token bands 50k/80k: occ=70k" acceptable "$HTB" b1 +write_snapshot_tok "$HTB" b2 null 40000 5000 200000 && expect "override token bands 50k/80k: occ=45k" smart "$HTB" b2 + +# Malformed token_bands → shipped token defaults + visible stderr notice; +# valid percentage keys in the same file still apply. +HTM="$WORK/h-tokenmal" +mkdir -p "$HTM/.claude/context-guard" +printf '{"smart_max_used_percentage":50,"acceptable_max_used_percentage":75,"token_bands":{"200000":{"smart_max_tokens":300000,"acceptable_max_tokens":400000}}}\n' \ + >"$HTM/.claude/context-guard/zones.json" +write_snapshot_tok "$HTM" tm1 null 150000 20000 200000 +GOT="$(HOME="$HTM" bash "$ZONE" tm1 2>"$WORK/tb-stderr")" +if [[ "$GOT" == "dumb" ]]; then ok "malformed token_bands (acceptable>class) → shipped token defaults"; else fail "malformed token_bands: got '$GOT'"; fi +if grep -qi 'token_bands' "$WORK/tb-stderr"; then ok "malformed token_bands → visible stderr notice"; else fail "malformed token_bands: silent fallback"; fi + +# v1 percentage-only zones.json: token_bands absent is zero-config (shipped +# token defaults apply, silently). +HTV="$WORK/h-tokenv1" +mkdir -p "$HTV/.claude/context-guard" +printf '{"smart_max_used_percentage":30,"acceptable_max_used_percentage":60}\n' >"$HTV/.claude/context-guard/zones.json" +write_snapshot_tok "$HTV" v1 null 150000 20000 200000 +GOT="$(HOME="$HTV" bash "$ZONE" v1 2>"$WORK/v1-stderr")" +if [[ "$GOT" == "dumb" ]]; then ok "v1 zones.json: shipped token defaults still apply"; else fail "v1 zones.json token defaults: got '$GOT'"; fi +if [[ -s "$WORK/v1-stderr" ]]; then fail "v1 zones.json: unexpected stderr notice for absent token_bands"; else ok "v1 zones.json: absent token_bands is silent zero-config"; fi + # --- Exactly one word on stdout, always -------------------------------------- for sid in s0 s75x snull nosuchsession storn; do OUT="$(resolve "$H" "$sid" 2>/dev/null)" diff --git a/plugins/context-guard/scripts/statusline-tee.sh b/plugins/context-guard/scripts/statusline-tee.sh index 061414b098..90342eed62 100755 --- a/plugins/context-guard/scripts/statusline-tee.sh +++ b/plugins/context-guard/scripts/statusline-tee.sh @@ -21,8 +21,10 @@ # Tee contract (../reference/reader-contract.md is the authoritative reader # side): every refresh writes # ~/.claude/context-guard/context/.json -# — one JSON object with captured_at (ISO-8601 UTC), session_id, and, when -# present on stdin, the context_window object copied VERBATIM (field +# — one JSON object with captured_at (ISO-8601 UTC), session_id, cli_version +# (the payload's top-level `version`, when present — it gates the reader's +# token shape) and, when present on stdin, the context_window object copied +# VERBATIM (field # additions upstream flow through without a plugin change; null fields are # the reader's concern). The path is deliberately HOME-anchored and outside # ${CLAUDE_PLUGIN_DATA}: it is a documented cross-plugin artifact seam that @@ -103,8 +105,17 @@ tee_snapshot() { local ts payload ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || return 0 + # cli_version carries the payload's top-level `version` (the Claude Code + # version — statusline reference, verified 2026-07-26). The reader needs it + # because `total_input_tokens` / `total_output_tokens` mean current context + # occupancy only from 2.1.132 and were CUMULATIVE session totals before it: + # a cumulative total below the window size is indistinguishable from a real + # occupancy, so without a version there is no sound way to trust the token + # bands. Copied only when it is a string; absent leaves the reader on the + # percentage shape alone. payload=$(printf '%s' "$INPUT" | jq -c --arg ts "$ts" ' {captured_at: $ts, session_id: .session_id} + + (if (.version? // null | type) == "string" then {cli_version: .version} else {} end) + (if has("context_window") then {context_window} else {} end) ' 2>/dev/null) || return 0 [[ -n "$payload" ]] || return 0 diff --git a/plugins/context-guard/scripts/statusline-tee.test.sh b/plugins/context-guard/scripts/statusline-tee.test.sh index 4de1014788..b8debc1a99 100755 --- a/plugins/context-guard/scripts/statusline-tee.test.sh +++ b/plugins/context-guard/scripts/statusline-tee.test.sh @@ -91,6 +91,32 @@ else fi if [[ "$(jq -r '.session_id' <"$SNAP1")" == "sess-42" ]]; then ok "session_id recorded"; else fail "session_id = $(jq -r '.session_id' <"$SNAP1")"; fi +# --- Case 2b: cli_version — the reader's token-shape version gate ------------ +# The token fields carry current-occupancy semantics only from 2.1.132; without +# the version the reader cannot tell current from pre-2.1.132 cumulative, so an +# absent version must leave the key absent rather than be guessed. +HOMEV="$WORK/home-version" +run "$HOMEV" "$(build_input sess-ver '"version":"2.1.218"')" cat >/dev/null +SNAPV="$HOMEV/.claude/context-guard/context/sess-ver.json" +if [[ "$(jq -r '.cli_version' <"$SNAPV" 2>/dev/null)" == "2.1.218" ]]; then + ok "cli_version teed from the payload's top-level version" +else + fail "cli_version = $(jq -c '.cli_version' <"$SNAPV" 2>/dev/null)" +fi +if jq -e 'has("cli_version") | not' <"$SNAP1" >/dev/null 2>&1; then + ok "no version on stdin → cli_version honestly absent (never fabricated)" +else + fail "cli_version fabricated: $(jq -c '.cli_version' <"$SNAP1")" +fi +HOMEVB="$WORK/home-version-bad" +run "$HOMEVB" "$(build_input sess-verbad '"version":42')" cat >/dev/null +SNAPVB="$HOMEVB/.claude/context-guard/context/sess-verbad.json" +if jq -e 'has("cli_version") | not' <"$SNAPVB" >/dev/null 2>&1; then + ok "non-string version dropped (reader gates on a version string)" +else + fail "non-string version teed: $(jq -c '.cli_version' <"$SNAPVB")" +fi + # --- Case 3: bounded snapshot — unrelated top-level fields not teed ---------- if jq -e 'has("model") or has("rate_limits") | not' <"$SNAP1" >/dev/null 2>&1; then ok "unrelated fields not teed (bounded snapshot)" diff --git a/plugins/context-guard/skills/setup/SKILL.md b/plugins/context-guard/skills/setup/SKILL.md index aba0ecdf83..3179b772e2 100644 --- a/plugins/context-guard/skills/setup/SKILL.md +++ b/plugins/context-guard/skills/setup/SKILL.md @@ -60,7 +60,7 @@ zone bands, zones.json shape) are owned by `statusLine` (user `~/.claude/settings.json`, project `.claude/settings.json`, local `.claude/settings.local.json`) and determine which one owns the EFFECTIVE command (the most specific scope wins). All wiring states below are evaluated against that effective command, - and the printed edit in step 6 targets THAT scope's file — wiring the user file while a + and the printed edit in step 7 targets THAT scope's file — wiring the user file while a project-level `statusLine` shadows it would apply cleanly and never run; when a shadow exists, say so explicitly and print the edit for the shadowing file (or note that removing the override is the alternative). Distinguish FOUR states: @@ -94,11 +94,38 @@ zone bands, zones.json shape) are owned by - If the literal string `${CLAUDE_SESSION_ID}` appears unexpanded above, report that this Claude Code version lacks the substitution and consumers will take the conservative path — probe the newest file in `~/.claude/context-guard/context/` instead, labeled as such. -5. **zones.json state** — read-only report: absent (shipped defaults 50/75 in effect — valid - zero-config state, not a defect), present and valid (report the bands in effect), or present - but malformed (report that the resolver falls back to shipped defaults with a stderr notice; - remediation: `apply`). -6. **Print the operator edit** — always print the applicable statusline edit for the settings +5. **zones.json state** — read-only report: absent (shipped defaults in effect — percentage 50/75 + plus the window-class token bands; valid zero-config state, not a defect), present and valid + (report the bands in effect, both shapes), or present with a malformed shape (report per shape + — the resolver validates percentage keys and `token_bands` independently and falls back per + shape with a stderr notice; a v1 file without `token_bands` is valid, with shipped token bands + silently in effect; remediation: `apply`). Note the hooks resolve zones through this same data — + a machine with no snapshots gets silent hooks, not errors. +6. **Hook registration vs hook activation** — THREE separate facts, never collapsed into one + status. A registered hook set that every hook exits out of immediately is the exact state an + operator is diagnosing when injections or gating are missing, and reporting "active" because the + plugin is enabled tells them the opposite of the runtime state. + - **Registered** — the plugin is enabled, so `hooks/hooks.json` is loaded and the matchers fire. + This follows from the plugin being enabled and says nothing about what the hooks then do. + - **Hook set armed** — the `context_guard_hooks_enabled` kill switch. Read its CONFIGURED value, + not the plugin's enablement: the value substituted here is + `${user_config.context_guard_hooks_enabled}`. Interpret it as + - `false` → **INERT**: registered but every hook (injection, gate, PostCompact marker) exits + immediately without acting. Remediation: re-enable the option via `/plugin`. + - `true` → armed. + - anything else, including the literal `${user_config.context_guard_hooks_enabled}` surviving + unexpanded (unset key, or a Claude Code without the substitution) → **UNKNOWN**, never + "armed". Say which source was read and that an unset key falls back to the hooks' in-script + default (armed); the operator-inspectable source of truth is this plugin's + `pluginConfigs` options block in the user `settings.json` + (`docs/conventions/hook-config-delivery` owns why the declared `default` field is not + delivered to hook processes). + - **Gate posture** — `zone_hook_mode` is `${user_config.zone_hook_mode}`, read and interpreted + the same way. Only `blocking` makes the PreToolUse gate do anything; `advisory` (the in-script + default) leaves it inert while the injection hook still runs. Report it separately: an armed + hook set with an advisory posture is a different runtime state from an inert hook set, and + only one of the two is a defect. +7. **Print the operator edit** — always print the applicable statusline edit for the settings file that owns the effective command (step 3), marked clearly as the operator's to apply. The wiring target is the SHIM's fixed path — never `${CLAUDE_PLUGIN_ROOT}`, which is version-pinned and belongs in no operator file: @@ -203,7 +230,7 @@ zone bands, zones.json shape) are owned by "Windows configuration"). State this with the printed edit: the wiring is applied ONCE and survives every later plugin update, because the shim — not the version-pinned cache path — is what the settings file names. -7. **Dotfiles tracking proposal** — the printed edit changes a durable user-scope file the operator +8. **Dotfiles tracking proposal** — the printed edit changes a durable user-scope file the operator maintains. When the operator's home directory is managed by a dotfiles system (chezmoi, yadm, a bare-repo setup, ...), surface the reminder to capture the `settings.json` change through that system's own add/track flow so the wiring survives machine rebuilds. This skill only surfaces @@ -226,36 +253,43 @@ result (a no-op on Windows ACL volumes; the wiring invokes it through `bash` any Otherwise overwrite it (this is the update path after a plugin version bump changes the shim) and report the `# shim-revision:` values, old → new. - The shim is **inert until wired**: installing it starts nothing. Only the operator's - `settings.json` edit — step 6 of `check`, which this skill never applies — puts it on the + `settings.json` edit — step 7 of `check`, which this skill never applies — puts it on the statusline path. Say that explicitly when reporting the write. -- After installing, print the wiring edit (`check` step 6) so the operator's next action is in +- After installing, print the wiring edit (`check` step 7) so the operator's next action is in front of them, and note that a statusline already wired to the shim needs NO change now or on any future plugin update. ### B. Seed or refresh the zones SSOT Seed or refresh `~/.claude/context-guard/zones.json` from the shipped defaults -(`smart_max_used_percentage: 50`, `acceptable_max_used_percentage: 75` — the reader contract owns -these numbers; read them from `${CLAUDE_PLUGIN_ROOT}/reference/reader-contract.md` rather than this -file if they ever disagree): +(`smart_max_used_percentage: 50`, `acceptable_max_used_percentage: 75`, and the window-class +`token_bands` — the reader contract owns these numbers; read them from +`${CLAUDE_PLUGIN_ROOT}/reference/reader-contract.md` rather than this file if they ever disagree): 1. **File absent** — create the directory if needed and write exactly: ```json { "smart_max_used_percentage": 50, - "acceptable_max_used_percentage": 75 + "acceptable_max_used_percentage": 75, + "token_bands": { + "200000": { "smart_max_tokens": 100000, "acceptable_max_tokens": 160000 }, + "1000000": { "smart_max_tokens": 200000, "acceptable_max_tokens": 400000 } + } } ``` 2. **File present** — behavior is mode-explicit, never ambiguous: - `apply` (no argument): REPAIR-ONLY. Valid recognized band values are left untouched and - reported; recognized keys that are missing or invalid (non-numeric, inverted, out of range) - are set to the shipped defaults. An operator's custom-but-valid thresholds are never - overwritten by a bare `apply`. - - `apply defaults`: set BOTH recognized band keys to the shipped defaults explicitly. This - converges forward to a known state; it is not teardown, and it never removes the file or any - key it does not recognize. + reported; recognized keys that are missing or invalid (non-numeric, inverted, out of range — + for `token_bands`, invalid per the reader contract's per-shape validity rules) are set to the + shipped defaults. A v1 file's ABSENT `token_bands` is repaired by adding the shipped token + bands (absence is valid zero-config for the resolver, but the seeded SSOT should carry the + full tunable surface). An operator's custom-but-valid thresholds are never overwritten by a + bare `apply`. + - `apply defaults`: set ALL recognized band keys (both percentage keys and `token_bands`) to + the shipped defaults explicitly. This converges forward to a known state; it is not teardown, + and it never removes the file or any key it does not recognize. - Both modes **preserve every unrecognized key semantically** — same keys, same JSON values — (the file is a shared SSOT the operator's own statusline may extend). Preservation is value-level, not lexical: a `jq` merge reserializes the document, so formatting and escape diff --git a/plugins/plugin-quality/.claude-plugin/plugin.json b/plugins/plugin-quality/.claude-plugin/plugin.json index 685509b36d..5cbd0696e1 100644 --- a/plugins/plugin-quality/.claude-plugin/plugin.json +++ b/plugins/plugin-quality/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "plugin-quality", - "version": "0.2.2", + "version": "0.3.0", "description": "Post-use behavioral audit of Claude Code plugin components: a six-step audit workflow (evidence capture, grounded mapping in a fresh subagent, blindspot pass, interactive contract lock, presence-gated review seams, work-item emit with draft+confirm) over any skill, agent, hook, command, or config you have actually used — zone-informed by context-guard snapshots when present, conservative when not.", "author": { "name": "Melodic Software", diff --git a/plugins/plugin-quality/CHANGELOG.md b/plugins/plugin-quality/CHANGELOG.md index c979006a9c..9752c36ebe 100644 --- a/plugins/plugin-quality/CHANGELOG.md +++ b/plugins/plugin-quality/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to the `plugin-quality` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-07-26 + +### Changed + +- **Context-gate migrated to the context-guard reader contract's v2 band shape (#1475).** The + gate now understands the token shape: `zones.json` validity is evaluated per shape (percentage + keys as before; optional `token_bands` with per-window-class rows — absent is valid + zero-config), the inlined fallback floor carries both the percentage bands (50/75) and the + window-class token bands (200k class 100000/160000, 1M class 200000/400000, over occupancy = + `total_input_tokens + total_output_tokens`), and the reader contract's combination rule is + inlined verbatim: when both shapes are computable, the worse zone wins (conservative-min); when + only one is computable, it stands alone; when neither is, the zone is unknown. This removes the + documented split-brain hazard where a token-shape `zones.json` would have been rejected + wholesale in favor of the stale inlined 50/75 table. The compaction override now also + recognizes context-guard's new evidence-degraded marker + (`~/.claude/context-guard/context/.compacted`). The inlined token shape also carries + the contract's version floor: it is computable only when the snapshot's `cli_version` is present, + purely numeric dotted, and >= 2.1.132, because before that release the token fields were + cumulative session totals and a cumulative value below the window size is indistinguishable from + a real occupancy. + +### Added + +- **`scripts/zones-inline-drift.test.sh`** — the consumer-lane drift check the reader contract's + "Inline-floor ownership" rule has always named but nothing implemented: asserts every + load-bearing inlined floor phrase (staleness window, snapshot/zones/marker paths, both band + shapes, the token-shape version floor, the combination-rule sentence) appears in BOTH this skill + and the context-guard reader contract after normalization. Runs in the repo's plugin-gate CI job via the shared + `*.test.sh` discovery; SKIPs cleanly in an installed plugin cache where the sibling contract + file is unreachable. + ## [0.2.2] - 2026-07-29 ### Changed diff --git a/plugins/plugin-quality/scripts/zones-inline-drift.test.sh b/plugins/plugin-quality/scripts/zones-inline-drift.test.sh new file mode 100755 index 0000000000..7a1885347b --- /dev/null +++ b/plugins/plugin-quality/scripts/zones-inline-drift.test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Drift check: plugin-quality's inlined context-guard floor values must stay +# byte-identical (value-level) to the context-guard reader contract that owns +# them. +# +# This is the consumer-lane drift check the reader contract's "Inline-floor +# ownership" section names. It asserts every load-bearing inlined phrase — +# staleness window, snapshot path, marker path, percentage bands, token +# bands, and the combination-rule sentence — appears in BOTH files after +# normalization (backticks/emphasis stripped, whitespace flattened), so a +# value change on either side fails this lane until both move together. +# +# SKIPs (exit 0) outside the monorepo checkout: an installed plugin cache is +# per-plugin isolated and cannot see the sibling plugin's contract file — +# the lane is meaningful only where both files exist (repo CI's plugin-gate). + +# shellcheck disable=SC2088 # the ~/ strings are documented contract PHRASES being grep-matched as data, never paths this script expands +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL="$SCRIPT_DIR/../skills/audit/SKILL.md" +CONTRACT="$SCRIPT_DIR/../../context-guard/reference/reader-contract.md" + +if [[ ! -r "$CONTRACT" || ! -r "$SKILL" ]]; then + echo "SKIP: context-guard reader contract not reachable (installed-cache isolation) — drift lane runs in the monorepo only" + exit 0 +fi + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +# Normalize: drop markdown emphasis/backticks, flatten all whitespace runs. +norm() { + tr -d '`*' <"$1" | tr '\n' ' ' | tr -s ' ' +} +SKILL_N=$(norm "$SKILL") +CONTRACT_N=$(norm "$CONTRACT") + +# both