Skip to content

fix(context-guard): raise the four hook timeouts to 60s from a measured tail - #2001

Merged
kyle-sexton merged 1 commit into
mainfrom
fix/context-guard-hook-timeouts
Aug 8, 2026
Merged

fix(context-guard): raise the four hook timeouts to 60s from a measured tail#2001
kyle-sexton merged 1 commit into
mainfrom
fix/context-guard-hook-timeouts

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

No linked issue

Consumer report drained from the handoff inbox: 20260730-182801-context-guard-zone-crossing-hook-times-out-100-percent. The zone-crossing hook was reported timing out 100% of the time; all four registrations sat at timeout: 10.

The number was authored here, not inherited

Per the hooks page fetched this session (https://code.claude.com/docs/en/hooks):

timeout | no | Seconds before canceling. Defaults: 600 for command, http, and mcp_tool; 30 for prompt; 60 for agent. UserPromptSubmit lowers the command, http, and mcp_tool default to 30, and MessageDisplay lowers it to 10.

Unit is seconds. The applicable defaults are 600 (PostToolBatch, PreToolUse, PostCompact) and 30 (UserPromptSubmit). So 10 was a deliberate narrowing to 1/60th of the default, not something inherited — and it is below what the hooks actually take on Windows.

Measured, post-#1979

The prior per-invocation saving (9b90e351) is on main, so the report's timings were stale. Re-measured with a harness invoking each script exactly as the hook would — real payload on stdin, HOME / CLAUDE_PLUGIN_DATA / CLAUDE_PLUGIN_ROOT set, snapshot present so the resolver does real work. Two runs, 18 samples per path:

Path min max
zone-crossing-inject.sh — PostToolBatch, 150 KB payload 2.21 s 6.68 s
zone-crossing-inject.sh — UserPromptSubmit, small payload 2.24 s 22.01 s
zone-gate.sh — PreToolUse 0.45 s 2.83 s
post-compact-mark.sh — PostCompact 1.27 s 12.37 s

These are noisy and are presented as such. CPU load was 14% before run 1 and 68% during run 2; 523 processes; Defender real-time protection enabled. Identical work spanned 3.3 s → 22.0 s, so the means are unreliable and only the maxima carry the decision.

Three things the measurement establishes:

  1. post-compact-mark.sh reached 12.4 s — over the old 10 s cap. The report flagged this one as inferred, not measured, and as the most consequential, since sibling plugins read its marker. It is now measured fact.
  2. zone-gate.sh peaked at 2.83 s with no observed overrun. It is raised for uniformity and tail-safety, not because it was failing — stated plainly rather than folded into a "they were all broken" claim.
  3. The tail is environmental, not payload-scaling. The small UserPromptSubmit payload (22.0 s) beat the 150 KB PostToolBatch one (6.7 s). Sizing has to survive an AV-stalled process spawn, not just the median.

Why 60 and not the report's suggested 30

22.0 s is a floor, not a p100: the harness times the script alone, excluding the harness's own hook-launch overhead, and it never sets HOOK_TELEMETRY_SINK, so hook::emit_telemetry short-circuits and its per-invocation jq -n + sink exec are excluded too (spawns cost ~140 ms each here, per the 0.4.6 entry). 30 would leave under 8 s of margin on an already-optimistic number. 60 gives ~2.7× while staying an order of magnitude under the 600 s default, so a genuinely hung hook still cannot stall a session for ten minutes. guardrails and disk-hygiene already declare 60 in this marketplace. A timeout is a cap, not a cost.

What #1988 changed about the edit surface

.claude-plugin/plugin.json no longer carries a hooks key at all — #1988 removed the redeclaration of the default-discovered path. The manifest was re-read at HEAD rather than trusted from the report. Consequence: hooks/hooks.json is now unambiguously the single declaration site for a timeout, so this change touches one file plus the version bump. The report's four-registration table is still accurate.

Deliberately not asserted

The page says only "Seconds before canceling" — what a cancelled hook reports, whether partial output is discarded, and whether sibling hooks continue are not documented, so none of it is claimed here. Likewise the page states 30 as UserPromptSubmit's default and does not say whether it also caps a larger explicit value; 60 is declared regardless, which is harmless if clamped, since 22.0 s still clears 30.

Verification

Check Result
zone-crossing-inject.test.sh PASS=18 FAIL=0
zone-gate.test.sh PASS=24 FAIL=0
post-compact-mark.test.sh PASS=16 FAIL=0
hooks.json parses OK — timeouts [60, 60, 60, 60]
plugin.json parses OK — 0.4.8, no hooks key
markdownlint-cli2 0 issues
check-changelog-parity.sh --check-bump origin/main pass
shellcheck / shfmt / shell-portability / exec-bit N/A — no .sh changed (2 JSON + 1 MD)

check-orphaned-fixtures.sh was not run locally; it exceeds a 300s timeout on this machine. CI covers it.

context-guard 0.4.7 → 0.4.8.

Out of scope, surfaced not fixed

  • Profiling the hot path (the report's suggestions 2–3) is the durable fix for the cost; this PR fixes the cap. The Windows per-invocation cost is still real and still open.
  • A timeout-observability notice is already served: every hook emits duration_ms via hook::emit_telemetry, opt-in through HOOK_TELEMETRY_SINK.
  • Same defect class elsewhere, unmeasured: rate-limit-guard declares timeout: 10, guardrails has two entries at 10, and claude-ops declares timeout: 5 on eight registrations — the tightest in the marketplace, notable given the spawn tail measured above.
  • plugins/context-guard/CHANGELOG.md cites (#1985) for 0.4.7 and (#1978) for 0.4.6, but those landed as 6b5c1b96 (#1988) and 9b90e351 (#1979). Consistent pattern, likely issue-vs-PR numbering; unverified and not touched.

Related

All four registrations in hooks/hooks.json declared `timeout: 10`. A consuming
session on Windows/Git Bash reported zone-crossing-inject.sh overrunning that
cap on essentially every firing across a ten-session chain (10.6-21.4s), so the
zone enforcement never ran while still charging its full wall-time cost. A hook
that times out is not a slow hook, it is an absent one.

Re-measured on this host after the 0.4.6 spawn reduction, 18 samples per path:
the typical case now fits (means 2.7-10.6s) but the tail does not.
zone-crossing-inject.sh still reached 22.0s, and post-compact-mark.sh - whose
exposure the report could only infer - reached 12.4s. zone-gate.sh peaked at
2.8s with no observed overrun; it is raised for uniformity and tail-safety, not
because it was failing. The tail is environmental rather than payload-driven: a
small UserPromptSubmit payload took 22.0s while a 150KB PostToolBatch payload
took 6.7s, on a host with Defender real-time protection enabled.

60 rather than 30 because the measurement times the script alone and excludes
the harness's own hook-launch overhead, so 22.0s is a floor rather than a p100;
30 would leave under 8s of margin on an already-optimistic number. 60 stays an
order of magnitude below the harness's 600s `command` default, so a genuinely
hung hook still cannot stall a session for ten minutes, and guardrails and
disk-hygiene already declare 60 here.

Contract verified against https://code.claude.com/docs/en/hooks (fetched
2026-08-08): timeout is "Seconds before canceling. Defaults: 600 for `command`,
`http`, and `mcp_tool`; 30 for `prompt`; 60 for `agent`. `UserPromptSubmit`
lowers the `command`, `http`, and `mcp_tool` default to 30, and `MessageDisplay`
lowers it to 10." So 10 was authored here, not inherited. Whether the
UserPromptSubmit 30 also caps an explicit larger value is not documented; 60 is
declared there on the understanding that a clamp to 30 would still clear the
measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 8, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 3s —— View job


Security Review — PR #2001

  • Gather context (PR diff via gh pr diff 2001)
  • Read changed files
  • Analyze for security issues (injection, secrets, access control, GitHub Actions risks)
  • Post findings

No security issues found.

Scope reviewed

All three changed files, full diff:

No hook scripts (zone-crossing-inject.sh, zone-gate.sh, post-compact-mark.sh), no workflow files, and no manifest permissions/config keys are touched by this PR — this is a pure numeric-value change to four timeout fields plus a version bump and changelog entry.

Reasoning specific to this change

  • No new attack surface. timeout is a scalar cap on cancellation, not an input that flows into a command, path, or template — there's no injection surface here to widen.
  • Fail-open consideration, checked and found not to be a new risk. zone-gate.sh is a PreToolUse gating hook; if a timeout-cancelled hook fails open (harness behavior, undocumented per the PR body's own note), a shorter timeout would make a bypass of the gate more likely by cancelling it more often at the reported 10.6–22 s tail. Raising the cap to 60 s reduces that exposure rather than introducing it — this change is directionally hardening, not weakening, whatever the harness's cancel-time behavior turns out to be.
  • Bounded, not unbounded. 60 s stays an order of magnitude under the harness's own 600 s default for command hooks, so this doesn't turn a hook into an effectively-unbounded blocking operation.
  • No secrets, tokens, or credential handling in any of the three files.
  • No GitHub Actions workflow changespull_request_target/workflow_run/permissions:/script-injection concerns don't apply to this diff.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 2m 56s —— View job


Code review: PR #2001

Summary

This is a narrow, well-scoped fix: all four context-guard hook registrations in
hooks/hooks.json
go from timeout: 10 to timeout: 60, plugin.json bumps 0.4.70.4.8, and the CHANGELOG documents the measurement and reasoning. I independently confirmed:

  • The diff is exactly the 4 timeout values in hooks.json (verified via git diff origin/main HEAD -- plugins/context-guard/) — no .sh files touched, matching the PR's own verification table.
  • plugin.json no longer carries a hooks key (confirmed by reading the file at HEAD), consistent with the PR's claim about fix(context-guard): stop the manifest redeclaring the auto-loaded hooks file #1988's effect on the edit surface.
  • The hooks-reference contract quote is accurate: I fetched https://code.claude.com/docs/en/hooks myself this session and it confirms defaults of 600s for command/http/mcp_tool, 30s for prompt, 60s for agent, with UserPromptSubmit lowering the command default to 30s and MessageDisplay to 10s — matching the quote in the CHANGELOG and satisfying the repo's fresh-docs mandate for this hook-contract change.
  • The comparative claims about sibling plugins check out against current file state: guardrails has two hooks.json entries still at timeout: 10 (flag-commit-pr-skill-bypass.sh, workflow-resilience-check.sh), and claude-ops has exactly eight registrations at timeout: 5.
  • No test file asserts the numeric timeout value from hooks.json (the timeout occurrences in the .test.sh files are unrelated: a timeout 20 test-harness wrapper and comments about the scripts' own internal read timeouts), so no test updates were needed for this change, and none were made.
  • No contract/schema validator in scripts/ (e.g. validate-plugin-contracts.mjs) checks hook timeout values, so there's no missed validation-side update.
  • CHANGELOG format (no dated version headers) matches the convention already used in this and sibling plugins' changelogs (checked against plugins/guardrails/CHANGELOG.md).

Not independently verified (sandbox here couldn't execute shell commands beyond read-only git/ls, so these remain author-claimed): the reported Windows/Git Bash timing measurements (2.21s–22.01s range), the zone-crossing-inject.test.sh / zone-gate.test.sh / post-compact-mark.test.sh pass counts, and the check-changelog-parity.sh --check-bump result.

One suggestion posted inline on hooks.json line 21: the CHANGELOG itself already flags that the hooks page doesn't say whether UserPromptSubmit's documented 30s default can be overridden upward or silently clamps an explicit 60 — worth an empirical follow-up (via the existing duration_ms telemetry) since that's the event with the largest observed max (22.01s) and the one path where the ambiguity actually matters.

No REVIEW.md "always check" items apply here (no multi-record write needing atomicity, no high-risk security action needing an audit-log entry) — this is a config-only change with no security-lane-relevant surface.

Overall: correct, well-justified, appropriately scoped to the single declaration site, and honest about what is/isn't verified. No blocking issues found.
· Branch

Comment thread plugins/context-guard/hooks/hooks.json
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@kyle-sexton
kyle-sexton merged commit 925c325 into main Aug 8, 2026
33 checks passed
@kyle-sexton
kyle-sexton deleted the fix/context-guard-hook-timeouts branch August 8, 2026 10:13
kyle-sexton added a commit that referenced this pull request Aug 8, 2026
…/null (#2007)

No linked issue

Consumer report drained from the handoff inbox:
`20260730-182801-guardrails-hook-false-positives-and-ungated-commit-pr-hook`.
Two false positives were reproduced verbatim at HEAD before anything was
changed.

## 1a — a read-only `open()` was blocked

Reproduced first: `python3 -c "import json;
d=json.load(open('x.json'))"` → exit 2.

`_py_write` matched `open[[:space:]]*\(` with no write-mode
discrimination, so every inline Python `open()` read as a write.

**The discrimination boundary, stated because it is a design call and
not obvious.** A bare `open(` is no longer a write indicator at all.
`open(` counts as a write **only when an argument-position write-mode
literal occurs in the same command** — a quoted token built solely from
mode characters `[rwaxbtu+]`, containing at least one of
`w`/`a`/`x`/`+`, appearing immediately after a comma or after `mode=`.

The test is **co-occurrence, not positional, deliberately.** Bash ERE
has no lazy quantifier: a positional `open\([^)]*'w'` stops at the first
`)` and would **fail open** on a real `open(os.path.join(a,b),'w')`,
while a greedy `.*` reaches into unrelated text. This is the same
mangle-resistant co-occurrence shape the PowerShell lane already uses.
The **argument-position** requirement — rather than "a mode literal
anywhere" — is what keeps common read shapes clear:
`json.load(open('p'))['a']` has its `'a'` preceded by `[`, not by a
comma.

**Residual, in the fail-closed direction and pinned by a test:** a
read-only `open()` in a command that separately carries an
argument-position `'w'`/`'a'`/`'x'`/`'+'` literal (e.g.
`print(open('f').read(), 'a')`) still blocks. Accepted over the
alternative.

**Second residual, left alone:** a bare `pathlib` mention is still an
indicator on its own, so read-only inline Python that merely imports
`pathlib` still blocks. That indicator is what currently carries
`.write_text(` / `.write_bytes(` — `\.write[[:space:]]*\(` does not
match `.write_text(` — so narrowing it requires introducing an explicit
write-call set. Out of scope here, recorded in the CHANGELOG.

**Fixtures respelled, not relaxed — called out so it does not read as
test-fitting.** Four PowerShell-lane cases used a bare `open(` as their
stand-in write indicator to assert mention-over-block and here-string
inertness. Since `open(` is no longer an indicator, those inputs were
respelled to `open(f,'w')` so they keep testing their actual contract,
and a new case asserts that the same mention with a READ-mode open is
now allowed.

## 1b — `cat > /dev/null` was blocked

A discard is not a write. Added `_cat_devnull`, the exemption the
echo/printf lane already had.

It is **segment-scoped, not command-scoped**: a whole-command exemption
would let `cat > /dev/null && cat > real.txt` through, which is now a
pinned regression floor. The `cat` and echo/printf scans now share one
splitter — the segmentation block was extracted from
`producer_redirect_bypass` into `normalize_segments` (called once, sets
`NORMALIZED_SEGMENTS`) so the two lanes cannot drift on escaped
separators or the `2>&1` fd-dup sentinel. Quoted spellings (`cat >
"/dev/null"`, `cat > /dev/"null"`) fall out of the existing
redirect-operand handling in `strip_literals`; asserted.

## 2 — `flag-commit-pr-skill-bypass` timeout 10 → 60

It was the only guardrails hook not at 60, against five
`Bash|PowerShell` siblings that are. Per the hooks page fetched this
session (<https://code.claude.com/docs/en/hooks>):

> Seconds before canceling. Defaults: 600 for `command`, `http`, and
`mcp_tool`; 30 for `prompt`; 60 for `agent`.

Nothing pushes a `PreToolUse` hook to 10 — the 10 was authored here.
Same page, on why a consumer cannot work around it locally:

> Hook entries merge across settings levels rather than replacing each
other: user, project, and local settings add their own hooks without
removing managed ones, and the `disableAllHooks` setting can't disable
managed hooks from outside managed settings.

## 3a — `hook::jq_fields` adopted by `block-dangerous-git` and
`block-no-verify`

Two `jq` spawns collapsed to one per invocation; first adopters in the
fleet. Failure semantics are unchanged: rc 1 from the helper exits 0
exactly as the old empty-`COMMAND` skip did, after `hook::require_jq`
has already surfaced the degraded state. The cross-hook
`require-jq-notice-isolation` contract still passes over both adopters.

## 3b — `cli-flag-verify` global-flag false positive

**The obvious fix was a chain-fallback to top-level `--help`, and it was
measured and rejected:** `npm --help` does not list `--prefix` either
(`verify-cli-flag.sh npm --prefix` → rc 1), so it would not have closed
the repro.

Root cause is structural. `npm --help` states:

> Specify configs in the ini-formatted file … or on the command line
via: `npm <command> --key=value`

Every config key is a flag on every subcommand, so per-subcommand help
is non-exhaustive **by design**, and the authoritative list (`npm config
ls -l`) prints `prefix = "…"`, not `--prefix`, so no generic `--help`
parser can consume it. `npm` therefore joins `git` and `npx` in the
exclusion, on the rationale already recorded in that file. Consumers
re-add it via `cli_flag_verify_bins`.

## Verification

| Check | Result |
|---|---|
| `block-hook-bypass.test.sh` | PASS=233 FAIL=0 |
| `block-dangerous-git.test.sh` | PASS=329 FAIL=0 |
| `block-no-verify.test.sh` | PASS=120 FAIL=0 |
| `cli-flag-verify.test.sh` | PASS=52 FAIL=0 |
| `require-jq-notice-isolation.test.sh` | PASS=2 FAIL=0 |
| `shellcheck` (6 changed files) | clean |
| `shfmt -d` (5 of 6) | clean |
| `check-shell-portability.sh --paths` (6 files) | No unexcused GNU-only
constructs |
| `check-silent-skips.sh` | No silent prerequisite skips found |
| `markdownlint-cli2` | 0 issues |
| `check-changelog-parity.sh --check-bump origin/main` | pass |
| `git ls-files -s` changed `.sh` | all `100755` |

Suites were run strictly one at a time — their wall-clock ceilings fail
spuriously under concurrency. The first `block-hook-bypass` run surfaced
3 failures (the PowerShell `open(` fixtures); those were respelled and
the suite re-run to green twice, so the 233 tally is the shipped file.

Both directions of each fix are covered: read-only `open()` feeding
`json.load` → exit 0, `open(nested-call, 'w')` → exit 2 (the fail-open
floor), `cat > /dev/null` variants → exit 0, `cat > /dev/null && cat >
real.txt` and `cat >> real.txt 2>&1` → exit 2 (the segment-scoping
floors).

`cli-flag-verify.test.sh` fails bare `shfmt -d` — **pre-existing and
identical at `origin/main`** (the `X=$(…); RC=$?` one-liner idiom used
throughout). The diff hunks stop around line 239 and this PR's addition
starts at 264, so the added lines are shfmt-clean. `main` being green
means bare `shfmt -d` is evidently not the gate CI applies to `.test.sh`
here.

`check-orphaned-fixtures.sh` was not run locally; it exceeds a 300s
timeout on this machine. CI covers it.

`guardrails` 0.19.2 → **0.19.3**.

## Deliberately not done

- **F2 (out-of-repo plugin-data append blocked)** — not among the three
confirmed-live findings; prior triage left it unreproduced.
- **F1 suggestions 2–3 (command-gating the hook, profiling the 12–19
s)** — the report says explicitly these are *not* superseded by a
timeout fix, and that is right: a hook taking 12–19 s on every shell
call is still expensive. Scoped out here, still open.
- **`workflow-resilience-check.sh` remains at `timeout: 10`** —
`Workflow` matcher, not exercised by the report, flagged out of scope by
the item itself.
- **`cat >&2` still blocks.** An fd-dup is not a file write — the same
class as the `/dev/null` FP fixed here — but it is pre-existing and not
the reported defect. Flagged rather than folded in.

## Surfaced, not fixed

- `plugins/guardrails/README.md`'s hook table lists "PreToolUse · Bash"
for six guards whose registered matcher in `hooks.json` is
`Bash|PowerShell` — a table-wide doc/manifest mismatch, not introduced
here.
- The inbox item's front-matter title still says "guardrails 0.18.1"
while its triage audited 0.19.0 and this lands at 0.19.3; stale on
version, but its findings verified live at HEAD.

## Related

- #1979 — the shared-hook-lib work that introduced `hook::jq_fields`,
which this PR is the first to adopt.
- #2001 — the sibling timeout fix in `context-guard`, same defect class
and same fetched doc line.
- Inbox item
`20260730-182801-guardrails-hook-false-positives-and-ungated-commit-pr-hook`
— the consumer report.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…orted, not the last one seen

Zone bands are hard thresholds and the worsening-only latch had no dwell, so a
session hovering across a boundary re-injected the ~1KB (~250 token) guidance
block on every re-crossing. State was persisted regardless of direction, so an
improvement of any size silently re-armed the injection and nothing counted or
capped the flap.

A second per-session marker (<session>.armed) now holds the worst zone already
REPORTED and gates the emit. It decays only on a two-rank improvement — the full
width of the smart/acceptable/dumb ladder — because a one-rank dip at a band
edge is the oscillation that caused the defect, while dumb -> smart cannot be
edge noise. A /clear needs no margin: it starts a new session id, hence a fresh
baseline. The margin is a declared judgment default, on the same footing as the
bands themselves.

dumb -> acceptable -> dumb now injects once; dumb -> smart -> dumb still injects
twice. Both are pinned by tests on separate sessions. Against the pre-fix hook
the new cases fail 5/32 — the re-crossing re-injects — and pass 32/32 after.

A sibling state file, not a second line: the reader is tr -cd '[:lower:]', which
strips the newline, so two lines would fuse into "dumbacceptable" and rank as
smart. A pre-0.7.0 session with no .armed file seeds the armed rank from the
last-seen zone, deciding exactly as 0.6.6 would have for one call.

This is NOT the recurring 10s-timeout defect fixed in 0.4.8 / PR #2001; the
CHANGELOG says so explicitly so a triager does not conflate them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cursor Bot pushed a commit that referenced this pull request Aug 12, 2026
…orted, not the last one seen

Zone bands are hard thresholds and the worsening-only latch had no dwell, so a
session hovering across a boundary re-injected the ~1KB (~250 token) guidance
block on every re-crossing. State was persisted regardless of direction, so an
improvement of any size silently re-armed the injection and nothing counted or
capped the flap.

A second per-session marker (<session>.armed) now holds the worst zone already
REPORTED and gates the emit. It decays only on a two-rank improvement — the full
width of the smart/acceptable/dumb ladder — because a one-rank dip at a band
edge is the oscillation that caused the defect, while dumb -> smart cannot be
edge noise. A /clear needs no margin: it starts a new session id, hence a fresh
baseline. The margin is a declared judgment default, on the same footing as the
bands themselves.

dumb -> acceptable -> dumb now injects once; dumb -> smart -> dumb still injects
twice. Both are pinned by tests on separate sessions. Against the pre-fix hook
the new cases fail 5/32 — the re-crossing re-injects — and pass 32/32 after.

A sibling state file, not a second line: the reader is tr -cd '[:lower:]', which
strips the newline, so two lines would fuse into "dumbacceptable" and rank as
smart. A pre-0.7.0 session with no .armed file seeds the armed rank from the
last-seen zone, deciding exactly as 0.6.6 would have for one call.

This is NOT the recurring 10s-timeout defect fixed in 0.4.8 / PR #2001; the
CHANGELOG says so explicitly so a triager does not conflate them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cursor Bot pushed a commit that referenced this pull request Aug 12, 2026
…orted, not the last one seen

Zone bands are hard thresholds and the worsening-only latch had no dwell, so a
session hovering across a boundary re-injected the ~1KB (~250 token) guidance
block on every re-crossing. State was persisted regardless of direction, so an
improvement of any size silently re-armed the injection and nothing counted or
capped the flap.

A second per-session marker (<session>.armed) now holds the worst zone already
REPORTED and gates the emit. It decays only on a two-rank improvement — the full
width of the smart/acceptable/dumb ladder — because a one-rank dip at a band
edge is the oscillation that caused the defect, while dumb -> smart cannot be
edge noise. A /clear needs no margin: it starts a new session id, hence a fresh
baseline. The margin is a declared judgment default, on the same footing as the
bands themselves.

dumb -> acceptable -> dumb now injects once; dumb -> smart -> dumb still injects
twice. Both are pinned by tests on separate sessions. Against the pre-fix hook
the new cases fail 5/32 — the re-crossing re-injects — and pass 32/32 after.

A sibling state file, not a second line: the reader is tr -cd '[:lower:]', which
strips the newline, so two lines would fuse into "dumbacceptable" and rank as
smart. A pre-0.7.0 session with no .armed file seeds the armed rank from the
last-seen zone, deciding exactly as 0.6.6 would have for one call.

This is NOT the recurring 10s-timeout defect fixed in 0.4.8 / PR #2001; the
CHANGELOG says so explicitly so a triager does not conflate them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cursor Bot pushed a commit that referenced this pull request Aug 12, 2026
…orted, not the last one seen

Zone bands are hard thresholds and the worsening-only latch had no dwell, so a
session hovering across a boundary re-injected the ~1KB (~250 token) guidance
block on every re-crossing. State was persisted regardless of direction, so an
improvement of any size silently re-armed the injection and nothing counted or
capped the flap.

A second per-session marker (<session>.armed) now holds the worst zone already
REPORTED and gates the emit. It decays only on a two-rank improvement — the full
width of the smart/acceptable/dumb ladder — because a one-rank dip at a band
edge is the oscillation that caused the defect, while dumb -> smart cannot be
edge noise. A /clear needs no margin: it starts a new session id, hence a fresh
baseline. The margin is a declared judgment default, on the same footing as the
bands themselves.

dumb -> acceptable -> dumb now injects once; dumb -> smart -> dumb still injects
twice. Both are pinned by tests on separate sessions. Against the pre-fix hook
the new cases fail 5/32 — the re-crossing re-injects — and pass 32/32 after.

A sibling state file, not a second line: the reader is tr -cd '[:lower:]', which
strips the newline, so two lines would fuse into "dumbacceptable" and rank as
smart. A pre-0.7.0 session with no .armed file seeds the armed rank from the
last-seen zone, deciding exactly as 0.6.6 would have for one call.

This is NOT the recurring 10s-timeout defect fixed in 0.4.8 / PR #2001; the
CHANGELOG says so explicitly so a triager does not conflate them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…orted, not the last one seen (#2308)

## Summary

`context-guard`'s zone bands are hard thresholds and the worsening-only
latch had no dwell or
cooldown. Because state was persisted regardless of direction, a session
hovering across a boundary
re-injected the ~1KB (~250 token) guidance block on **every**
re-crossing: an improvement of any
size silently re-armed the injection, and nothing counted or capped the
flap.

Occupancy does not climb monotonically — tool results land and are
released — so a session sitting
near a band edge crosses it repeatedly. Comparing each observation
against the *last-seen* zone made
every one of those a fresh transition.

### The fix

A second per-session marker, `<session>.armed`, holds the worst zone
this session has already
**reported**, and that is now the emit gate. It decays only on an
improvement of at least
**two ranks** — the full width of the `smart` / `acceptable` / `dumb`
ladder.

| Path | Before | After |
|---|---|---|
| `dumb → acceptable → dumb` (the flap) | injects **twice** | injects
**once** |
| `dumb → smart → dumb` (real recovery, then relapse) | injects twice |
injects **twice** — unchanged |

### Why two ranks

The bands are declared judgment defaults, not doc- or benchmark-derived
constants
(`reference/reader-contract.md` records their provenance), and the
margin is stated on exactly the
same footing — **no citation is manufactured for it.** The reasoning,
stated rather than asserted:
a one-rank dip at a band edge *is* the oscillation that caused the
defect and says nothing new,
while `dumb → smart` spans the whole ladder and cannot be edge noise. A
`/clear` needs no margin at
all — it starts a new session id, hence a fresh state file and a fresh
baseline.

Net contract: a zone is announced at most once per session unless the
session genuinely recovers,
after which the ladder re-arms and the next worsening is reported
normally.

### This is NOT the recurring 10s-timeout defect

Called out here and in the CHANGELOG so a triager does not close it as a
duplicate. Inbox item

`20260730-182801-context-guard-zone-crossing-hook-times-out-100-percent`
was four `hooks.json`
registrations declaring `timeout: 10` against measured 10.6–21.4s
runtimes, so the hook **died
rather than ran**; it was fixed in `context-guard` 0.4.8 via PR #2001
(squash-merged `925c3259`) by
raising all four to 60. This change is about how often the hook injects
**when it does run** —
injection frequency at band edges, not hook completion. That item's
still-open residue (profile the
hot path) is also unrelated.

### State-file shape, and the trap avoided

A **sibling file**, not a second line in the existing one. The state
reader is
`tr -cd '[:lower:]' <"$STATE_FILE" | head -c 16`, which strips the
newline too, so a two-line state
file would fuse into `dumbacceptable` and rank as `smart` — silently
disabling the gate.

Live sessions already running when this lands have a `.zone` file and no
`.armed` file. The armed
rank seeds from the last-seen zone, so the first call after the upgrade
decides exactly as 0.6.6
would have, and latches from there. **No migration step and no
state-format version.** Pinned by a
test. The armed marker is written **first** of the two, so a partial
write failure can never leave
the gate open against a marker that already moved.

### Deliberately preserved

Named because they are load-bearing and easy to refactor away in
passing: the two-channel split with
the continuation menu kept out of model context (the I23 counter-steer
reference implementation),
the hook's refusal to claim an operator is present, the inert default
posture, the worsening-only
latch itself, and `zone-gate.sh`'s structural no-deadlock exemptions.
All untouched.

### Note for the reviewer on an edited test

`zone-crossing-inject.test.sh` case 4 shows in the diff as modified.
**Its assertion is byte-for-byte
unchanged** — only the comment and the `ok:` label changed, to say *why*
that path still injects
(`smart` is two ranks below the armed `dumb`, so the marker decayed and
the ladder re-armed). No
existing assertion was weakened, deleted, or rewritten; the suite grew
from 21 to 32 cases.

`context-guard` 0.6.6 → **0.7.0** (minor: injection cadence is
observable behaviour). No blocking
behaviour, no permission, no new hook registration, and no external read
or write changes — the
plugin's trust surface is untouched, so no security review note applies.

## Test plan

Before/after against the **same new test file**, with only the hook
swapped for its merge-base
version:

```
$ git show $(git merge-base origin/main HEAD):plugins/context-guard/hooks/zone-crossing-inject.sh \
    > /tmp/cg-before/context-guard/hooks/zone-crossing-inject.sh
$ bash /tmp/cg-before/context-guard/hooks/zone-crossing-inject.test.sh
...
ok: flap: the first dumb observation injects
ok: flap: the one-rank dip is silent
FAIL: armed rank decayed on a one-rank dip:
FAIL: flap re-crossing re-injected (the #2220 defect): rc=0 out={"hookSpecificOutput":{"hookEventName":"PostToolBatch","additionalContext":"context-guard: this session crossed from the acceptable into the dumb context zone ...
FAIL: second flap cycle emitted: rc=0 out={"hookSpecificOutput":{...}}
ok: recovery: first dumb injects
ok: recovery: the improvement itself is silent
FAIL: armed rank did not decay on a full recovery:
ok: recovery: relapse after a genuine recovery injects again
ok: legacy state without an .armed marker still injects on a real worsening
FAIL: armed marker not seeded:

PASS=27 FAIL=5
```

The second failure is the defect itself, printed verbatim: the
re-crossing emits the full guidance
block a second time.

After, on the same fixtures:

```
$ bash plugins/context-guard/hooks/zone-crossing-inject.test.sh
ok: first-seen dumb injects additionalContext
ok: injection is valid JSON carrying the firing event name
ok: model channel carries the counter-steer and no exit menu
ok: model channel claims ownership without claiming delivery
ok: operator channel carries the continuation menu
ok: unchanged zone is silent
ok: improvement is silent
ok: improvement still updates state
ok: relapse after a two-rank recovery injects again (UserPromptSubmit)
ok: flap: the first dumb observation injects
ok: flap: the one-rank dip is silent
ok: flap: a one-rank dip does NOT decay the armed rank
ok: flap: re-crossing into an already-reported zone does NOT re-inject
ok: flap: repeated oscillation stays silent (injections are bounded, not counted)
ok: recovery: first dumb injects
ok: recovery: the improvement itself is silent
ok: recovery: a two-rank improvement decays the armed rank
ok: recovery: relapse after a genuine recovery injects again
ok: legacy state without an .armed marker still injects on a real worsening
ok: legacy state gains an .armed marker on first write (no migration step)
ok: unknown zone is silent and stateless
ok: kill switch silences the hook
ok: hostile session id fails open
ok: empty stdin fails open
ok: injection length 1955 under 10k cap
ok: 150KB batch payload still injects (chunked stdin read)
ok: marker + smart snapshot injects the evidence-degraded notice once
ok: marker-driven dumb state stays silent on repeat
ok: state-persist failure fails open silently (no additionalContext)
ok: state-persist failure reports telemetry status=error
ok: no HOME and no CLAUDE_PLUGIN_DATA stays silent
ok: no zone state written relative to the working directory

PASS=32 FAIL=0
```

Lint:

```
$ shellcheck -x plugins/context-guard/hooks/zone-crossing-inject.sh \
    plugins/context-guard/hooks/zone-crossing-inject.test.sh
(no output)

$ bash scripts/check-silent-skips.sh
No silent prerequisite skips found in hook entry scripts.
```

## Related

- Closes #2220
- Explicitly NOT a duplicate of the 0.4.8 / PR #2001 timeout defect —
see above
- Inbox item: `2026-08-10-plugin-quality-audit-four-components`
  (ledger `I7-four-components-023241Z.md` § C-CG-A)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant