fix(context-guard): raise the four hook timeouts to 60s from a measured tail - #2001
Conversation
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>
|
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 finished @kyle-sexton's task in 1m 3s —— View job Security Review — PR #2001
|
|
Claude finished @kyle-sexton's task in 2m 56s —— View job Code review: PR #2001
SummaryThis is a narrow, well-scoped fix: all four
Not independently verified (sandbox here couldn't execute shell commands beyond read-only git/ One suggestion posted inline on 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. |
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…/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>
…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>
…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>
…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>
…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>
…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>

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 attimeout: 10.The number was authored here, not inherited
Per the hooks page fetched this session (https://code.claude.com/docs/en/hooks):
Unit is seconds. The applicable defaults are 600 (
PostToolBatch,PreToolUse,PostCompact) and 30 (UserPromptSubmit). So10was 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 onmain, 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_ROOTset, snapshot present so the resolver does real work. Two runs, 18 samples per path:zone-crossing-inject.sh— PostToolBatch, 150 KB payloadzone-crossing-inject.sh— UserPromptSubmit, small payloadzone-gate.sh— PreToolUsepost-compact-mark.sh— PostCompactThese 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:
post-compact-mark.shreached 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.zone-gate.shpeaked 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.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, sohook::emit_telemetryshort-circuits and its per-invocationjq -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.guardrailsanddisk-hygienealready declare 60 in this marketplace. A timeout is a cap, not a cost.What #1988 changed about the edit surface
.claude-plugin/plugin.jsonno longer carries ahookskey 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.jsonis 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
zone-crossing-inject.test.shzone-gate.test.shpost-compact-mark.test.shhooks.jsonparses[60, 60, 60, 60]plugin.jsonparseshookskeymarkdownlint-cli2check-changelog-parity.sh --check-bump origin/main.shchanged (2 JSON + 1 MD)check-orphaned-fixtures.shwas not run locally; it exceeds a 300s timeout on this machine. CI covers it.context-guard0.4.7 → 0.4.8.Out of scope, surfaced not fixed
duration_msviahook::emit_telemetry, opt-in throughHOOK_TELEMETRY_SINK.rate-limit-guarddeclarestimeout: 10,guardrailshas two entries at10, andclaude-opsdeclarestimeout: 5on eight registrations — the tightest in the marketplace, notable given the spawn tail measured above.plugins/context-guard/CHANGELOG.mdcites(#1985)for 0.4.7 and(#1978)for 0.4.6, but those landed as6b5c1b96 (#1988)and9b90e351 (#1979). Consistent pattern, likely issue-vs-PR numbering; unverified and not touched.Related
20260730-182801-context-guard-zone-crossing-hook-times-out-100-percent— the consumer report.