anthropics / anthropics/claude-plugins-official
security-guidance: uncapped untracked_at_baseline bloats session state, causing 30s UserPromptSubmit timeouts under concurrent hooks
- Vorherrschende Sprache
- Python
- Sterne
- 36.3k
- Forks
- 4.1k
- Ø Merge
- 2 T. 14 Std.
- Gemergte PRs (30 T.)
- 539
Beschreibung
## Summary
In `security-guidance`, `handle_user_prompt_submit` writes an **uncapped** `untracked_at_baseline` snapshot into the per-session state file on every UserPromptSubmit. In a repo with many untracked files this makes the state file hundreds of KB, and since every PostToolUse hook reads and rewrites that whole file under a lock, concurrent hook processes serialize behind it and UserPromptSubmit hits its 30s timeout.
I recorded **16 UserPromptSubmit timeouts at ~30s**, all clustered on days with heavy parallel-subagent use.
## Evidence
`touched_paths` is explicitly capped (`diffstate.py:58` — "deduped, capped at 200"). `untracked_at_baseline` has no equivalent cap. In `security_reminder_hook.py:568`:
```python
state["untracked_at_baseline"] = untracked_now
```
`untracked_now` is `_list_untracked(cwd)` — a `path -> mtime_ns` dict of every untracked, non-ignored file in the worktree.
Breakdown of my largest live state file (126,249 bytes total):
| field | bytes | share |
|---|---|---|
| `untracked_at_baseline` | 122,701 | **97.2%** |
| `head_at_capture` | 42 | — |
| `baseline_sha` | 42 | — |
| `touched_paths` | 2 (0 items) | — |
| `shown_warnings` | 2 (0 items) | — |
That worktree has 1,677 untracked-but-not-ignored files. The dict is rewritten on every prompt and read in full by every PostToolUse invocation.
## Why it becomes a timeout
Every individual step inside the hook is fast — I instrumented the plugin's own `log.txt` across 289 UserPromptSubmit invocations spanning 9 days:
- `UserPromptSubmit` → `Captured git baseline`: **median 149ms, p90 ~430ms, max 1,025ms, zero runs over 5s**
- `git ls-files --others --exclude-standard`: 70–75ms
- `git stash create`: 73–126ms
- interpreter probe in `sg-python.sh`: 69–402ms
So the 30 seconds is not spent in any logged step. During the timeout windows the log shows **5–6 hook processes writing within the same millisecond** (parallel subagents, each firing PostToolUse). Each of those reads + JSON-parses + rewrites the ~123KB state under `with_locked_state`. That is roughly 750KB of parse/serialize per tool call funneled through a single lock, and UserPromptSubmit contends with all of it.
Ruled out as causes, with measurements: git performance, interpreter probing, and the `agent-sdk-venv` (`claude_agent_sdk` and `pydantic_core` both import cleanly; the 147 historical `SDK unavailable` fallbacks self-resolved).
## Reproduction shape
1. Run Claude Code with a cwd whose git worktree contains a large number of untracked-but-not-ignored files (mine: ~1,700).
2. Run a workflow that fans out several subagents so multiple PostToolUse hooks fire concurrently.
3. Watch `/security_warnings_state_.json` grow to >100KB, and UserPromptSubmit begin timing out at 30s.
Timeouts in my transcripts landed only on heavy-fan-out days (4 separate days over a 23-day window, 16 occurrences); light days stay at ~130–230ms.
## Suggested fixes
1. **Cap or compress `untracked_at_baseline`**, mirroring the existing 200-entry cap on `touched_paths`. A count + hash, or a capped list with an overflow flag, would preserve the "exclude pre-existing untracked from review" behavior without the payload.
2. **Store it out of the hot state file** — the baseline snapshot is written once per prompt but read only by Stop, whereas PostToolUse pays to parse it on every tool call. A sibling file would take it off the contended path.
3. **Skip the snapshot past a threshold** (e.g. >500 untracked) and degrade to "review everything untracked", which is the current behavior in the empty-snapshot case anyway.
## Secondary observation (not the bug)
`cleanup_old_state_files()` works as designed (30-day retention, called at `security_reminder_hook.py:2166`), but the file *count* tracks session IDs rather than user sessions — subagents each get one. Over 30 days that left **2,734 state files / 51.7MB** against only 130 lifetime app startups. Retention is behaving correctly; the ratio is just worth knowing when sizing the directory.
## Environment
- `security-guidance` plugin **2.0.6** (from `claude-plugins-official`)
- Claude Code **2.1.220**, native install
- Windows 11, hooks running under Git Bash
Beitragsleitfaden
Für dieses Repository ist kein Beitragsleitfaden indexiert
Rechercherichtung
Start with security_reminder_hook.py:568 and the state handling around with_locked_state, then compare untracked_at_baseline with the capped touched_paths logic at diffstate.py:58. Reproduce with a worktree containing many untracked files and concurrent PostToolUse hooks; done means the state payload no longer causes UserPromptSubmit contention while preserving the baseline exclusion behavior.
Vom Indexierungsmodell aus dem Issue-Text verfasst.
Bewertung
- Tech-Stack
- python
- Bereich
- devtools, performance
- Issue-Typ
- Bug
- Schwierigkeit
- 4/5
- Geschätzter Aufwand
- 3-5 Tage
- Aktivitätsstatus
- Ruhig
- Klarheit
- Größtenteils klar
- Anfängerfreundlichkeit
- 48/100