anthropics / anthropics/claude-plugins-official

security-guidance: uncapped untracked_at_baseline bloats session state, causing 30s UserPromptSubmit timeouts under concurrent hooks

Offen
#4,727 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
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

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.