protect-secrets 1.1.0: delegation sinks (secrets and files fed to external model CLIs and APIs) - #58
Merged
Merged
Conversation
Five new BASH_PATTERNS for shell commands that feed secrets or file contents into an external model. High: a secrets file or secret env var reaching a model CLI (gemini, codex, llm, sgpt, aichat, openai, mods, fabric) or a model API host through curl/wget/http/xh. Strict: any file contents reaching the same sinks. Local models (ollama) stay out of scope. Regexes are built from shared fragments (SINK_CMD, SECRET_FILE, SECRET_VAR, SINK_HOST, FILE_BODY) so each pattern stays readable. Heredocs and herestrings are not treated as file input. A key in an auth header with an inline body stays allowed. guard-pack/lib copy refreshed (pinned byte-identical by test). Tests cover every probe from the empirical map, hit and non-hit.
README gains a Delegation sinks section: why (shunt-style token savers route file contents to a worker model with no redaction; unpaid Gemini API tier terms allow training and human review), what is caught at high vs strict, the sink CLI and API host lists with links, how to allow a specific flow, and a Native pairing subsection with a settings.json snippet (Read deny rules cover cat and redirects since 2.1.257; sandbox network allowlist keys). plugin.json 1.0.0 -> 1.1.0 with the description updated; guard-pack 1.0.0 -> 1.0.1 because its lib copy changed. Root README plugin row mentions the new catch. marketplace.json left as is: it lists targets, not command families, and its line belongs to PR #56.
…s (review F1 to F8, F11 to F13) F1: a sink CLI is matched after leading env assignments, wrappers with their own options (sudo, npx, uvx, bunx, pnpm dlx, command, time, exec, nice, nohup, env, timeout, xargs), quotes, do/then/else and newlines, by path or npm scope (npx @google/gemini-cli). Backslash-newline continuations are joined before matching. F2: a secret var anywhere inside one body token is seen; a var in a later header is not. F3: secret file names are derived from the critical and high entries of SENSITIVE_FILES (plus hidden-file globs), so the lists cannot drift. .git-credentials joins SENSITIVE_FILES at high. F4: curl -T and --upload-file count as a body, in the sink patterns and in curl-upload-env (which also gains --data-binary @file with a space). F5, F13: secret-var names match on whole underscore segments with a small qualifier tail; AUTH and PRIVATE only as the last segment; PASS, PAT, PASSPHRASE and ${!VAR} added. echo-secret-var is unchanged. F6: a redirect glued to the command (gemini<.env) is accepted. F7: file flags are per CLI from each tool's flag table; gemini -i, gemini -a and mods -f are not file flags. gemini/openai @path references count. F8: env-dump also closes on ) and covers set, export, declare -x. F11: six more model API hosts; https (httpie alias) and curlie as clients; httpie name=@file and name=value body items; --data-urlencode name@file. F12: heredocs, herestrings, <( ), /dev/*, filters without a file operand, curl -w/-H @file and a < inside a quoted prompt or body are not file input. Quoted prompts are opaque to the file-name check, so prose such as 'what is a .env file' passes; a secret used by an earlier command in a pipeline is no longer attributed to the sink.
160 tests: every isolation probe from the review, the 13 secret-var false positives and 20 hot names, one sample path per critical/high SENSITIVE_FILES entry reachable through the sink, per-CLI file flags, curl upload flags and their look-alikes, extra hosts and clients, strict false positives.
…ed list Wrapper list matches the regex; six new hosts with vendor doc links; httpie alias and curlie; per-CLI file flags; what stays out (variable indirection, credential emitters, scripts, query-string keys, names outside the Read-side list, fd tricks) and the strict-only false positives.
Script comment lines 114 to 66 on 235 code lines (main: 52 on 170, ratio 0.31; now 0.28). One line stays above each fragment whose regex is not obvious; the per-pattern reasoning and the per-CLI flag sources live in the PR. File header restored to its main wording. README 128 to 84 lines: one Delegation sinks section (19 lines) and one Native pairing subsection (12 lines); the sink and host lists stay, the rest moved to the PR body.
karanb192
added a commit
that referenced
this pull request
Sep 13, 2026
karanb192
added a commit
that referenced
this pull request
Sep 13, 2026
…rd-pack (#59) * subagent-spawn-cap: new PreToolUse guard with a per-session spawn budget Counts every Agent/Task tool call per session_id in an append-only JSONL ledger, asks at SPAWN_CAP_ASK (default 20) and denies at SPAWN_CAP_DENY (default 60). SPAWN_CAP_ALLOW=true lets one call through. Nested spawns from inside subagents share the session budget. Fails open, prunes ledgers older than 7 days, returns {} for any other tool with no filesystem work. Hermetic node --test suite, 46 tests. * guard-pack: add subagent-spawn-cap as the seventh guard, bump to 1.1.0 Byte-identical lib copy (pinned by the drift test), Agent|Task added to the matcher and the main() tool filter, evaluate() now receives the full event so the spawn cap can key on session_id. Spawn cap runs first: one string compare for every other tool, and the only applicable guard on a spawn. README, header, and plugin.json describe seven guards and the SPAWN_CAP_* env vars. * Register subagent-spawn-cap: marketplace, README, site, OWASP LLM06 row, bench Marketplace entry (safety), root README rows and 21-plugin count, site card 22 with counts, LLM06 Unbounded Consumption moves from none shipped to Partial, bench harness gains the spawn cap with per-hook env so the samples never cross the ask threshold. Test badge updated to the current suite count. * subagent-spawn-cap: ask cadence, audience-correct reasons, shared audit logging Ask at SPAWN_CAP_ASK and then every SPAWN_CAP_ASK_STEP spawns (new env, default 10) until SPAWN_CAP_DENY: defaults prompt at 20, 30, 40, 50 and deny at 60 instead of prompting on every spawn from 20 to 59. The ask reason now addresses the user and the deny reason addresses Claude (per the hooks reference), both name the mid-session reset (delete the session ledger) and drop the 'one call' description of SPAWN_CAP_ALLOW. ALLOW_OVERRIDE, clamp WARN, and missing-session_id WARN move into the shared module path so guard-pack logs them too; the pack verdict log carries count, thresholds, and subagent_type. guard-pack plugin.json drops the redundant hooks key. Tests: 56 standalone, 37 pack. * subagent-spawn-cap docs: cadence, unattended runs, audience of each reason, counts to 22 Plugin README: tagline and intro describe the 20/30/40/50/60 cadence, the ask text is labelled as what the user sees and the deny text as what Claude sees (hooks reference), a declined ask still consumes a ledger line, an Unattended runs section explains that -p and dontAsk turn asks into denies so SPAWN_CAP_ASK is the effective stop there, release span corrected to 2.1.212 through 2.1.223, session_id sharing labelled as observed rather than documented. plugin.json, marketplace, root README rows and guard-pack README follow. Root README and site now say 22 plugins, matching the 22 marketplace entries. * Test count 1677 in README badge and site tile * subagent-spawn-cap: trim comments and README to repo density Script header in the shape of the other guards, one-line comments only; rationale lives in the README and PR. guard-pack comments back to one line per touched spot. README compressed to what a first-time user needs. Lib copy byte-identical. * subagent-spawn-cap README to 70 lines; test count 1691 after rebase onto main * Test count 1922 after rebase onto main (#58 merged)
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A wave of "token saver" hook plugins for Claude Code hands file reads to a cheaper worker model. Spotify's shunt started it: a PreToolUse hook blocks
cat/head/tailon large files and steers Claude to a script that wraps each file in XML tags and sends the corpus to a worker model from a Bash call (write-up, Gemini 2.5 Flash as the worker). Neither the plugin nor the post mentions exclusions or redaction. The copycat version is a one-liner:cat src/*.ts | gemini -p "summarize".A consumer-tier model API is not a private channel. Google's Gemini API terms say for Unpaid Services that "Google uses the content you submit to the Services and any generated responses to provide, improve, and develop Google products and services and machine learning technologies" and that "human reviewers may read, annotate, and process your API input and output". protect-secrets already had an exfiltration tier for curl/scp/rsync uploads of secret files. This PR adds the delegation sinks: shell commands that feed file contents or secret env vars into an external model CLI or model API host.
What changed
Five new
BASH_PATTERNS, built from shared fragments (SINK_CMD,SECRET_NAME,SECRET_VAR,SINK_HOST,API_BODY,SEG_QA) so each stays readable:model-cli-secret-file<,$(cat),$(<), any command inside$( ), a file flag, or a pipe from a reader. File names are derived from the critical and high rows ofSENSITIVE_FILESmodel-cli-secret-var_segments:SECRET,KEY,TOKEN,PASSWORD,PASS,PAT,CREDENTIALS,AUTH/PRIVATElast)model-api-secret-body-d @,-F x=@,-T,--upload-file,< file, httpiex=@) or a secret var anywhere in a body tokenmodel-cli-file-input<,$(cat), per-CLI file flag, gemini@path)model-api-file-bodyStrict is opt-in and documented as such: many users pipe code to a second model on purpose. Local models (
ollama) are out of scope: no egress.env-dumpalso closes on)and coversset,export,declare -x;curl-upload-envgains-T,--upload-fileand--data-binary @file;.git-credentialsjoinsSENSITIVE_FILESat high.Review fix pass
An independent review of the first push found 7 should-fix and 8 nit items, all regex reach or false positives. Fixed: F1 (invocation shapes: env assignments,
npx @google/gemini-cli, exec/nice/nohup/env/timeout/xargs, quotes, newlines, continuations), F2 (secret var anywhere in a body token), F3 (names derived fromSENSITIVE_FILES), F4 (-T/--upload-file), F5 and F13 (segment-anchored var names,PASS/PAT/${!VAR}), F6 (gemini<.env), F7 (per-CLI file flags), F8 ($(env),set), F11 (six hosts,https/curlie, httpie items), F12 (strict false positives), F14 (merge order below), F15 (README wrapper list). Skipped with reasons in the README's "not covered" list: F9 loops and variable indirection (a regex cannot follow$f), F10 credential-emitting commands ($(gh auth token): no file name, no var name; follow-up pattern), secret in a URL query (?key=is Gemini's own documented auth form),echo-secret-varvocabulary (left as is on the reviewer's advice).Reviewer's harness (484 attack, 266 benign, 28 isolation probes), before and after this pass:
codex -i screenshot.png: an image is a file, per F7)Of the 92 remaining misses, 65 are file names outside the Read-side
SENSITIVE_FILESvocabulary (app credential stores such as~/.codex/auth.json,secrets.txt,token.json) and 17 are credential-emitting commands; both are documented follow-ups, not sink questions. The remaining 10 are variable indirection and loops (4), opaque clients such as python, node and openssl (2), the query-string form,$DATABASE_URL, a strict-only-H @headers.txtrow and a herestring body.Empirical map (brief probes, before / after)
Probed with
check('Bash', {command})againstorigin/mainand this branch. "-" is allowed.cat .env | gemini -p "summarize"gemini -p "review" < .envgemini -p "$(cat .env)"cat src/a.ts src/b.ts | gemini -p "summarize"gemini -p "$(cat src/app.ts)"codex exec "$(cat file.py)"llm -m gpt-4o < notes.mdsgpt "$(cat config.yaml)"aichat -f secrets.json "explain"curl https://generativelanguage.googleapis.com/.../gemini-2.5-flash:generateContent -d @payload.jsoncurl https://api.openai.com/v1/chat/completions --data-binary @body.jsoncurl -X POST https://openrouter.ai/api/v1/chat/completions -d "$(cat req.json)"gemini -p "$GEMINI_API_KEY"echo "$OPENAI_API_KEY" | llmgemini --version,codex --help,llm models,git log | head,curl https://api.openai.com/v1/models,cat README.md | wc -l,grep -r gemini src/,npm run codex-lint,ollama run llama3 < file.txtFiles
plugins/protect-secrets/protect-secrets.js: fragments + 5 patterns,env-dumpandcurl-upload-envwidened,.git-credentialsinSENSITIVE_FILES, continuation join incheckBashCommand, header level linesplugins/guard-pack/lib/protect-secrets.js: byte-identical copy (pinned byguard-pack.test.js)plugins/protect-secrets/tests/protect-secrets.test.js: 231 new tests (71 first push, 160 fix pass)plugins/protect-secrets/README.md: one "Delegation sinks" section (why in three sentences, the five-row catch table, what is not caught, sink CLIs with links and the API host list, how to allow a flow) and a "Native pairing" subsection with one settings.json snippet. Kept to 29 lines; the longer reasoning is in this PR bodyplugins/protect-secrets/.claude-plugin/plugin.json: 1.0.0 -> 1.1.0, description mentions the sinksplugins/guard-pack/.claude-plugin/plugin.json: 1.0.0 -> 1.0.1 (lib copy changed)README.md: protect-secrets plugin-table row mentions the new catchComment density
Karan's review asked for fewer comments. The script now carries 66 comment lines on 235 code lines (ratio 0.28; main is 52 on 170, 0.31). One line stays above each regex fragment whose shape is not obvious (quote-opaque segment, -T flag boundaries, segment-anchored var names, the derived file-name list); the per-pattern reasoning below and the per-CLI flag sources are the record. For the record, the file flags come from: llm
docs/help.md(-a/--attachment,-f/--fragment,--sf), aichatsrc/cli.rs(-f/--file), codexcodex-rs/cli/src/main.rs(-i/--image), fabric README Application Options (-a/--attachment), openai-cli README (--file,@path); gemini-clipackages/cli/src/config/config.tshas-ias--prompt-interactiveand no file flag; mods README has-fas--format.Tests
env -u CCH_SLA_WEBHOOK npm test: 1841 tests, 1841 pass, 0 fail (main: 1610).claude plugin validatepasses for protect-secrets and guard-pack. Garbage stdin still prints{}.cmpconfirms the guard-pack lib copy is byte-identical.Latency:
bench/run.mjshas no single-hook flag, so the full harness was not run. An in-process microbench (18 benign commands x 20000 iterations, old vs newcheckBashCommand) measured +0.6 us per call at high and +1.5 us at strict, against a ~33 ms Node startup per hook call (bench/RESULTS.md). Not measurable at the hook level.Overlap with PR #56 and PR #59
#56 (Windows paths, Grep matcher) touches protect-secrets too. This PR does not duplicate that work. Hunks were kept apart where possible: patterns sit in the
BASH_PATTERNSblock, tests sit inside the existingcheckBashCommand()describe rather than at the file tail, README edits avoid its lines, andmarketplace.jsonis untouched. Unavoidable overlap: both PRs bump the twoplugin.jsonversions and edit the protect-secretsplugin.jsondescription. The fix pass also touchedcheckBashCommand(one added line), near but not inside #56's helper hunk.#59 (subagent-spawn-cap) bumps guard-pack to 1.1.0 on the same line this PR sets to 1.0.1.
Merge order that costs the fewest rebases (from the reviewer's six-order merge simulation): #58, then #56 (protect-secrets to 1.1.1, guard-pack to 1.0.2), then #59 (guard-pack 1.1.0 stands). If #59 lands first, guard-pack here goes to 1.1.1.
Limitations
A PreToolUse Bash hook sees the command string only. A script that opens files itself (shunt's own
bulk-read, a Python helper,xargs cat) is opaque to any regex here; shunt's actual flow is therefore not caught by this hook, only the one-liner copies of it. That is why the README documents the native pairing:permissions.denyonReadcovers cat, reader commands, and< fileredirects since Claude Code 2.1.257, and the sandbox network allowlist (sandbox.network.allowedDomains,strictAllowlist, per the sandboxing docs) makes an unlisted model host unreachable regardless of command shape. Known false positive at high:llm keys set openai --value "$OPENAI_API_KEY"(ask mode is the way through; documented). Quoted|inside an argument ends a segment, same as the existing patterns.modsis archived upstream but kept in the list.Not in this PR
ollama,llama.cpp,lm-studio): no egress, documented as out of scope.SENSITIVE_FILES(app credential stores,secrets.txt, custom SSH key names) and a credential-emitter pattern ($(gh auth token)): both would lift the sink coverage further; separate PRs.echo-secret-varwith the segment-anchored var vocabulary.site/index.htmlcopy for protect-secrets: the card does not enumerate pattern families.