Grep results bypass the secret scrub entirely (CL-5717) - #426
Merged
Conversation
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.
…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.
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.
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
buildCorePosixToolPluginsstill observes the final result even when a later plugin short-circuits.resultTruncationPluginandtoolResultSecretScrubPluginare now prepended as the first two entries of the array —resultTruncationPluginoutermost (index 0),toolResultSecretScrubPlugininner to it (index 1) — making both unconditional wrappers around the entire chain, mirroringvendor/intx-inference/src/assembly.ts'ssizeCapTransform.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 inripgrep-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
Add a failing test for grep output bypassing the secret scrub— RED, against unmodified main wiring.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).Delete ripgrepPlugin's own char-cap helper now that the wiring caps unconditionally— old path removed, not left alongside.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 actualbuildCorePosixToolPluginsarray 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.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_shell—shell-guard-plugin.tshandles it entirely without delegating. This is the biggest exposure:run_shellis the highest-volume secret-bearing surface in the product.read_file—read-file-guard-plugin.tshandles it without delegating in nearly every branch; it does fall through tonext()in two narrow non-content branches, so "entirely without delegating" would overstate it slightly.search_files— bypassed by the sameripgrepPluginshort-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 theirisErrorcheck — no exposure there.edit-file-line-range-plugin.tsalso short-circuits on its success path, but harmlessly:edit_fileis in neitherSCRUBBABLE_TOOLSnorTRUNCATABLE_TOOLS, not because it's an error path.verify-plugin.ts,edit-file-diagnostics-plugin.ts, andlsp-hint-plugin.tsalways 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.shellGuardPluginreturns 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:
toolResultSecretScrubPluginstringifies 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
ripgrepPlugin's own line and byte caps still run upstream of the scrub, insideripgrepPluginitself, 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) thenbun run typecheck— cleanbun run build— cleanbun run test— 4233 pass, 0 fail