Skip to content

Grep results bypass the secret scrub entirely (CL-5717) - #426

Merged
TheGreatAxios merged 5 commits into
mainfrom
cl-5717-grep-secret-scrub
Aug 8, 2026
Merged

Grep results bypass the secret scrub entirely (CL-5717)#426
TheGreatAxios merged 5 commits into
mainfrom
cl-5717-grep-secret-scrub

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

ripgrepPlugin (position 9 in the posix tool plugin chain) answers grep and search_files calls directly, without calling next(). toolResultSecretScrubPlugin sat at position 14, so it never saw grep output — the one tool most likely to surface an API key, a .env value, or a private key. Pre-existing on main, not introduced by any recent PR; found during CL-5717 review.

Fix

Do not patch the ripgrepPlugin return sites. composeMiddleware wraps outer-to-inner in array order, so a plugin positioned earlier in buildCorePosixToolPlugins still observes the final result even when a later plugin short-circuits. resultTruncationPlugin and toolResultSecretScrubPlugin are now prepended as the first two entries of the array — resultTruncationPlugin outermost (index 0), toolResultSecretScrubPlugin inner to it (index 1) — making both unconditional wrappers around the entire chain, mirroring vendor/intx-inference/src/assembly.ts's sizeCapTransform.

The scrub must sit closer to the base handler than the cap, so it runs on the full, untruncated content. Getting this backwards is exploitable: a secret straddling the character-cap boundary gets cut mid-pattern (e.g. AKIA[0-9A-Z]{16} losing its tail), the scrub's regex no longer matches the fragment, and a bare, unredacted piece of the credential reaches the model with no redaction marker. Scrub-then-truncate is always safe — truncating already-redacted text loses nothing sensitive — so that's the only safe direction. A permanent regression test (a secret straddling the character-cap boundary is still fully redacted) covers this.

bounded() and its six call sites in ripgrep-plugin.ts, added by a previous PR to hand-reapply the char cap, are deleted — they're now redundant and were the wrong shape (six chances to forget a seventh call site).

Commits

  1. Add a failing test for grep output bypassing the secret scrub — RED, against unmodified main wiring.
  2. Prepend the secret scrub and result cap so short-circuiting plugins can't skip them — the wiring fix (initial ordering revised in commit 4 below).
  3. Delete ripgrepPlugin's own char-cap helper now that the wiring caps unconditionally — old path removed, not left alongside.
  4. Fix the exploitable prepend order and make the short-circuit test cover the real wiring — corrects the scrub/cap order (scrub must see untruncated content) and rewrites the generic short-circuit test to compose the actual buildCorePosixToolPlugins array output, with a stand-in spliced into ripgrepPlugin's own slot, so moving both terminal concerns away from the front of the real array fails the test.
  5. Correct the short-circuit test's comment to match what it actually guards — that test proves the two concerns are prepended, not that their relative order is correct; only the boundary-straddle test guards the relative order. The original comment overclaimed the former test also covered the latter.

Short-circuit audit

Essentially every content-bearing tool in the posix set was bypassing both concerns before this fix, not just grep:

  • run_shellshell-guard-plugin.ts handles it entirely without delegating. This is the biggest exposure: run_shell is the highest-volume secret-bearing surface in the product.
  • read_fileread-file-guard-plugin.ts handles it without delegating in nearly every branch; it does fall through to next() in two narrow non-content branches, so "entirely without delegating" would overstate it slightly.
  • search_files — bypassed by the same ripgrepPlugin short-circuit as grep.
  • grep — the originally diagnosed case.

Other short-circuits in the chain (path-escape-plugin.ts, tool-output-uri-plugin.ts, secret-guard-plugin.ts, authz-plugin.ts, permission-plugin.ts) only return directly on error/deny paths, which both concerns already skip via their isError check — no exposure there. edit-file-line-range-plugin.ts also short-circuits on its success path, but harmlessly: edit_file is in neither SCRUBBABLE_TOOLS nor TRUNCATABLE_TOOLS, not because it's an error path. verify-plugin.ts, edit-file-diagnostics-plugin.ts, and lsp-hint-plugin.ts always delegate first.

The new wiring closes all of the above structurally, since it no longer depends on any plugin calling next().

What this PR does not fix, by design: a secret riding out on an error result is still not redacted, because both the cap and the scrub skip result.isError. shellGuardPlugin returns command failures carrying stderr, and stderr is a classic place for a credential to leak — a curl error echoing a URL with an embedded token, a failed cloud CLI call. Do not describe this fix as "all shell output is now scrubbed" — failed shell output still is not.

Follow-up, not fixed here: toolResultSecretScrubPlugin stringifies object-shaped tool result content before scrubbing, which flattens structured results. Dead code today (no scrubbable tool currently returns object content), but worth a tracked follow-up before the first one does.

Out of scope — do not overclaim in the release note

  • MCP tool results never enter the posix chain at all, so this fix does not scrub or cap them. The release note must not imply MCP tool output is covered — it isn't, by either concern, before or after this PR.
  • ripgrepPlugin's own line and byte caps still run upstream of the scrub, inside ripgrepPlugin itself, before the result even reaches the prepended wrapper. A match cut off by the match-count cap is dropped wholesale, not fragmented, so that path is safe. But a byte-cap cut mid-way through a very large single-line match could theoretically split a key the same way the character cap could — narrow, not release-blocking, worth its own ticket.

Both filed as follow-ups, not fixed in this PR.

Test plan

  • bun install (fresh) then bun run typecheck — clean
  • bun run build — clean
  • bun run test — 4233 pass, 0 fail

ripgrepPlugin answers grep and search_files without calling next(),
so a secret-shaped string in grep output never reaches
toolResultSecretScrubPlugin, which sits later in the plugin array
(CL-5717). This test proves it: a grep hit on an AWS key and an
OpenAI-style key comes back unredacted through the real posix tool
chain.
…an't skip them

composeMiddleware wraps outer-to-inner in array order, so a plugin
positioned earlier in buildCorePosixToolPlugins still sees a call's
final result even when a later plugin (ripgrepPlugin) answers
directly without invoking its own next(). Move
toolResultSecretScrubPlugin and resultTruncationPlugin to the front
of the array so both are unconditional outer wrappers around the
entire chain, mirroring how vendor/intx-inference/src/assembly.ts
hardcodes its size-cap transform as the first, mandatory element
rather than trusting every middleware author to call next().

This closes CL-5717: grep output (and anything else a future plugin
answers without delegating) is now capped and scrubbed regardless of
where in the chain it short-circuits.
…nconditionally

bounded() and its six call sites reapplied truncateToolResultContent
by hand because ripgrepPlugin answers grep/search_files without
calling next(), so the old in-chain result-truncation plugin never
saw its output. Now that resultTruncationPlugin (and the secret
scrub) wrap the whole chain unconditionally, this duplicate
application is dead weight — six call sites are six places to forget
a future change to the cap. Deleted rather than left alongside the
new wiring.
@linear-code

linear-code Bot commented Aug 8, 2026

Copy link
Copy Markdown

CL-5717

…er the real wiring

Scrub was prepended outermost so it ran on already-truncated content:
a secret straddling the character-cap boundary got cut mid-pattern,
the scrub's regex no longer matched the fragment, and a bare,
unredacted piece of the credential reached the model with no
redaction marker. Truncation now sits outermost (index 0) and the
scrub sits at index 1, so the scrub always sees the full,
untruncated content — truncating already-redacted text loses nothing
sensitive, so this direction is safe in both orders where the
reverse is not. Added a permanent regression test for a secret
straddling the boundary.

Also rewrote the 'short-circuiting plugin still gets capped and
scrubbed' test: it previously hand-composed the scrub and cap
middleware in a hardcoded order, so it passed unchanged against the
pre-fix wiring and gave no protection against a real reordering of
buildCorePosixToolPlugins's output. It now takes the actual array the
builder returns, splices a short-circuiting stand-in into
ripgrepPlugin's own slot, and composes that — so reordering the real
array fails the test.
…ards

The comment claimed swapping the cap and scrub back would fail this
test. It does not: the secret sits at the front of a 90KB payload,
nowhere near the cap boundary, so cap-then-scrub still leaves the
whole key intact for the scrub to catch. This test guards the
PREPENDED POSITION of both terminal concerns (moving them away from
the front of the array fails it); the RELATIVE order between them is
guarded only by the boundary-straddle test. Leaving the old comment
in place risked a future engineer reading this test as redundant
coverage and deleting the straddle test, silently reopening the
exploit.
@TheGreatAxios
TheGreatAxios merged commit 553a3d0 into main Aug 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant