ZB Field Notes

The Claude Code security-guidance plugin, pushed to its limits

The Claude Code security-guidance plugin, pushed to its limits

In the last post I pointed Anthropic's claude-security plugin at this blog's codebase: 82 agents, 21 minutes, one real bug. That plugin runs on demand. It has a quieter sibling, security-guidance, that is supposed to run all the time: on every edit, every turn, every commit Claude makes. I had it installed since August. I assumed it was reviewing me. This post is about finding out what it actually did, and about pushing its configuration as far as the source code allows.

Everything below happened in one live session, one prompt per step, on a small Python webhook relay I built for the purpose. The repo is on GitHub: zakariahere/advancedclaudesecurity. The slides in this post are from the deck I built from the same notes.

Three hooks, none of them blocks

The plugin is not a scanner. It is four hooks in Claude Code's loop, and three of them review something:

Table of the plugin's three layers (per-edit regex on PostToolUse, per-turn diff review on Stop, per-commit agentic review on git commit) plus a fourth row for a self-written PreToolUse guard that denies
Every plugin finding is text injected into Claude's context. The only row with a veto is the one you write yourself.

The per-edit layer is a regex pass with no model call. The per-turn layer diffs the working tree against a baseline captured when you submit a prompt and sends the diff, plus your guidance file, to a separate Claude with a "find problems" prompt. The per-commit layer is agentic: it reads callers and sanitisers before it reports. None of them stops a write. Findings come back as instructions and the writing Claude is expected to act on them. The docs say so plainly, and say to add a blocking hook yourself if you need a guarantee.

The twist: two of the three had never run

The plugin writes one line per decision to ~/.claude/security/log.txt. Nothing else. I wrote a 100-line script, tools/sg_log.py, that turns that file into a table of ran / skipped and why, and ran it over a month of daily use:

per-turn   skipped: no credentials               730
per-turn   skipped: not a git repo               395
commit     skipped: no credentials                85
review     (model calls)                            0
Three stat tiles: 730 per-turn reviews skipped, 85 commit reviews skipped, 0 model calls ever, above the one-line Python gate from hooks/llm.py
One month of the plugin's own log. Only the regex layer had been working.

The gate is one line in hooks/llm.py:

HAS_API_CREDENTIALS = bool(ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN or _HAS_3P_PROVIDER_AT_LOAD)

Hooks inherit the shell environment and nothing else. A subscription login keeps its OAuth token in ~/.claude/.credentials.json, which no hook reads. I verified it with a throwaway hook that printed presence-only of both variables, from the desktop app and from a scrubbed-environment claude -p: unset in both. So on a subscription-only machine, which is mine, the two model-backed layers skip every single turn, and the only trace is a line in a file nobody opens.

The fix is a real decision, not a config tweak. ANTHROPIC_API_KEY in your shell makes the reviews run, and also switches the whole session to API billing instead of the subscription. A gateway via ANTHROPIC_BASE_URL plus ANTHROPIC_AUTH_TOKEN works too, as does a Bedrock or Vertex flag. I have none of those, so this post is about the layer that does run, and about what I could bolt on around it.

The setup: one directory, three levels of trust

Everything lives in .claude/, and the important skill is knowing which file reminds, which advises, and which refuses.

  • security-patterns.yaml reminds. Nine custom rules for the per-edit layer, scoped with paths globs: literal secrets, == on a signature, outbound HTTP anywhere except fetch.py, SQL built with an f-string, subprocess argv built by formatting, and a meta-rule that fires when you edit the rules file itself.
  • claude-security-guidance.md advises. The threat model of the service in plain English. The plugin concatenates the user, project and local copies, caps the result at 8 KB, and puts it in the reviewer's user turn inside a block that says it may add checks but must not suppress findings. A repo cannot silence the reviewer by committing "ignore SQL injection".
  • hooks/guard.py refuses. Eighty lines of stdlib Python on PreToolUse. Edits to auth.py, fetch.py, the guard itself and any GitHub workflow need RELAY_ALLOW_PROTECTED_EDITS=1 in the shell; content matching five credential shapes is refused everywhere.

Two gotchas from the YAML file alone, both silent. Write regexes in single-quoted scalars: "\s" in double quotes is an invalid YAML escape and the plugin skips the whole file with one log line. And PyYAML has to be importable by whichever Python the hook picked, not your venv. JSON always works.

Step 2: one Write, five sins, six warnings

I asked Claude to write a file with a shell-interpolated subprocess call, pickle.loads, an f-string SQL query, a raw urlopen, and == on a signature. One Write, and the tool result came back with six warnings attached.

Table mapping each vulnerable line to the rule that fired and whether it was built-in or custom: two built-in, four custom
Built-ins say what is wrong. Custom rules say what this repo does instead.

The difference in voice is the whole argument for custom rules. The built-in pickle reminder is a paragraph listing eight libraries and three alternatives. Mine are one sentence each and name the project's own fix: "call relay.fetch.forward()", "look it up in the JOBS dict". Zero model calls, zero cost, and the check ran after the file landed on disk, which is the design: PostToolUse, not PreToolUse.

Also the first false positive arrived within minutes. My logging rule matched the word "signature" inside a log message rather than as an argument. Regex has no semantics. I tightened it to argument position and, more usefully, wrote tools/sg_patterns_check.py, which imports the plugin's own patterns.py and extensibility.py and runs them offline against any file. Run your new rule over the existing tree before you trust it.

Steps 3 and 4: two kinds of silence

Then I added a second shell=True to the same file. Nothing came back. The log shows the engine matched again; the emit was suppressed, because warnings fire once per rule per file per session, tracked in a state file under ~/.claude/security/. The tenth shell=True in that file is as dangerous as the first and just as silent.

Then I wrote the same sins through a Bash heredoc. Also nothing, for a different reason: the per-edit layer is wired to Edit|Write|MultiEdit|NotebookEdit, and the Bash handler only looks for git commit and git push. No pattern check ran at all. My guard has the identical blind spot.

Two log excerpts: step 3 shows the pattern matched but was de-duplicated; step 4 shows the Bash hook loading guidance files and running no pattern check
The only net under both gaps is the per-turn diff review, which reads git state rather than tool calls. That is the layer a subscription login never runs.

Step 5: the one layer that says no

I asked for an Edit on auth.py replacing hmac.compare_digest with ==. The Edit tool returned an error instead of a result, git diff stayed empty, and the plugin's hook never ran: a PreToolUse deny short-circuits the chain, so there is nothing to post-process.

The settings.json PreToolUse entry and the guard's decision code on the left; the refusal message the Edit tool returned on the right
Same JSON on stdin as the plugin reads. Any per-edit pattern can be promoted from remind to refuse by moving it here. The price is that a false positive now stops work.

That is the whole reason the guard is narrow: two protected paths and five high-confidence credential regexes. Anything fuzzier stays in the YAML where a miss costs a sentence of context, not a blocked edit. And it hot-loaded: the settings file did not exist when the session started, and the next Write was already refused. A repo you cd into can add hooks to your session without ceremony, which cuts both ways.

Step 6: a valid regex, silently dropped

Last, I added a rule shaped like (a+)+. It compiles. It never loads. The plugin screens custom regexes statically for catastrophic-backtracking shapes (nested quantifiers, (.*)*, overlapping alternation under repetition), and drops matches with one log line. The other nine rules keep working, so nothing looks broken.

Left: match timings for (a+)+ growing from 1 ms to 348 ms as input grows by 8 characters. Right: the log line skipping the rule and the self-test output marking it DROPPED
Right call by the plugin: that regex runs on the full content of every edit, in a subprocess. Telling you is left to the log, which is what the self-test is for.

Scorecard

Scorecard table: per-edit built-in and custom rules warned, de-dupe and Bash were silent, the guard refused, rule validation dropped silently, model reviews skipped for lack of credentials
Two reminders, one silence, one refusal, one silent drop, and two layers that never ran.

I still think the plugin is worth installing. The regex layer is free, immediate, and with ten minutes of custom rules it speaks your codebase's language. The guidance file is a genuinely good place to keep a threat model, because the reviewer that reads it cannot be talked out of a finding by the repo it is reviewing. The guard hook is the pattern I would copy into any repo an agent touches.

But install it, then run something like sg_log.py before you trust it. The plugin is honest about every skip. It just only talks to a file.

Repo, tools, notes and the deck: github.com/zakariahere/advancedclaudesecurity. Official docs: security-guidance, hooks, claude-security.