[Bug] security-guidance plugin reads unencrypted secrets into context, bypassing guardrails

Status Open
Reported on v2.1.280
Maintainer reply None cached
Activity 3 comments · opened Sep 23, 2026

Bug Description
security-guidance read secret files in to the context while I was editing them and before I was able to encrypt them.
Claude should never read secrets in clear into its context. I have several guardrails for that, however the plugin bypassed them all.

Environment Info

  • Platform: darwin
  • Terminal: Apple_Terminal
  • Version: 2.1.280
  • Feedback ID: a968c68d-fb33-4e2b-95e8-f19bcaad581c

View original on GitHub ↗

3 Comments

7487 · 21 days ago

I traced the current public implementation to narrow down which path needs fixing. The per-edit handler runs local pattern checks; the Stop handler reviews a Git diff, while the commit reviewer can use Read/Grep/Glob over the repository. These are different paths, so the report alone does not yet establish which one bypassed your guardrails.

For reference, the public commit reviewer sets setting_sources=[]: review options. That is a lead to investigate, not confirmation that it caused this incident.

Could you share the following using dummy values only, with no real secrets or private file contents?

  • The installed security-guidance version and marketplace.
  • Whether the read happened after an Edit/Write, when the turn stopped, or after a Git commit; a redacted hook/event name would help.
  • The guardrail type: a permissions.deny rule, a hook, or an instruction. A sanitized rule using a dummy path is enough.
  • A minimal temporary-repository example with a fake marker, plus where that marker appeared (main conversation, review output, or logs).

This should distinguish direct diff collection from the separate review agent's file access before choosing a fix. No need to upload your original files. Source inspection prepared with AI assistance; I have not reproduced your reported incident.

Frisch12 · 21 days ago

Thanks for the pointers @7487. I reproduced it with a throwaway repository and a fake marker. Everything below uses dummy values only.

Environment

  • security-guidance 2.0.8 from claude-plugins-official (commit 2cd88e79), user scope
  • Claude Code 2.1.280, macOS
  • In this install the Agent SDK import fails (agentic_review: SDK unavailable (No module named 'pydantic_core._pydantic_core'); falling back), so the reviews below ran on the single-shot fallback path, not the agentic Read/Grep/Glob reviewer.

Guardrail type
No permissions.deny rule. The guardrail is a user-level PreToolUse, PostToolUse hook matched on Read, Write and Bash that blocks reading files containing secrets using keywords, Shannon entropy and heuristics, together with instructions not to read secrets. None of these get a chance to fire here: the secret never goes through a tool call in the main session. It arrives as Stop-hook feedback.

Minimal repro

git init repro && cd repro
printf '# repro\n' > README.md
cat > mk.sh <<'EOF'
#!/usr/bin/env bash
# Writes a dummy secret; the value never appears on the command line.
printf 'db:\n  host: db.example.invalid\n  password: %s\n' "SG_FAKE_MARKER_${1}_$(printf 7f3a91)" > secrets.yml
EOF
chmod +x mk.sh && git add . && git commit -qm init

echo "Run exactly: ./mk.sh A2   Then reply: done" \
  | claude -p --model haiku --output-format stream-json --verbose --allowedTools "Bash(./mk.sh:*)"

mk.sh stands in for "the user is editing a plaintext secrets file in the working tree before encrypting it". The main agent only ever sees the command ./mk.sh A2, never the file content.

Where the marker appeared

| Location | Marker present? |
|---|---|
| Main conversation (transcript and stream-json), as a Stop hook hook_blocking_error / "Stop hook feedback" user message | Yes: 1. [HIGH] [Hardcoded Secrets] password: SG_FAKE_MARKER_A2_7f3a91 |
| Main agent's following turn (thinking and reply) | Yes, it repeats the value |
| Plugin debug log (SECURITY_GUIDANCE_DEBUG_LOG) | No |
| Reviewer prompt | Not captured directly (fallback path). The debug log shows Stop hook: review_set=1 … reviewing 1 changed files (standard diff), and the finding quotes the value, so the file content was in the review input. |

Relevant debug log lines:

Processing: hook_event=Stop, tool=
Stop hook: review_set=1 base=HEAD dirty_now=1 changed_since=1
Stop hook: reviewing 1 changed files (standard diff)
LLM code review found 1 high/critical vulnerabilities

Observations

  • Path: the Stop hook. An untracked secrets.yml modified during the turn becomes part of the review set (.yml is in SOURCE_CODE_EXTENSIONS). Its full content goes to the review model, and the finding text quotes the secret value back into the main conversation.
  • The leak is non-deterministic: an identical run before it reported "no vulnerabilities found", and then nothing reached the main conversation. The value is still sent to the review model in both cases.
  • With .env instead of .yml there was no review at all (no source code files in diff).
  • A commit variant (./mk.sh B && git add -A && git commit) ran the commit review and then the Stop review (both fallback). Neither reported a finding, so the marker did not appear in the main conversation in that run.

Suggested fix direction

  • Never echo matched secret values in findings. Redact them (e.g. password: ****91) before building the Stop-hook feedback.
  • Consider excluding untracked files that look like secret stores (*secret*, *.tfvars, SOPS-candidate YAML) from the diff sent to the reviewer, or redacting high-entropy values before sending.
  • Independently of the fallback path: the agentic reviewer runs with setting_sources=[], so a user's PreToolUse Read guards would not apply to its Read/Grep/Glob either. I could not exercise that path here because the SDK import fails.
7487 · 21 days ago

Thanks for reducing this to a throwaway repo and dummy values. This clarifies that your observed Stop case uses custom tool hooks, not a permissions.deny rule. The earlier setting_sources=[] concern is a separate agentic-review path. In the current public code, Stop is deliberately single-shot, so an SDK import failure is not required for this Stop path.

I checked public main d78be948 and draft #96434 at 3bd80115, using isolated Git repositories with untracked files and fake markers. I ran the real get_git_diff → parse_diff_into_files → analyze_code_security functions, intercepting the model-call boundary; no real model or user hook was run. Results:

| Case | secrets.yml marker in review prompt | runtime-config.yml marker in review prompt |
|---|---|---|
| Current main, no Read deny rules | yes | yes |
| #96434 defaults, no Read deny rules | no | yes |
| #96434 with SG_SKIP_SECRET_FILES=0 | yes | yes |
| #96434 defaults + Read(./runtime-config.yml) deny | no | no |

The .env control was excluded in all four cases. I also ran the PR's 14 unittest tests successfully.

This means the PR's default secret-filename filter covers the secrets.yml input in your repro, independently of permissions.deny. It does not replay arbitrary content-based user hooks, and the finding formatter still emits vulnerableCode/fix without new redaction. A synthetic finding quoting a marker in the remaining ordinary-named config file still appears in the formatted feedback; that checks the formatting boundary, not whether a real model would produce that finding.

For #96434, the no-deny-rule Stop case and this custom-hook/output-redaction boundary are worth keeping explicit in the tests and documentation. The PR is still a draft; I have not verified native CLI/model end-to-end behavior or the reporter's marketplace build. These checks were prepared with AI assistance.