Skip to content

Commit 553a3d0

Browse files
Grep results bypass the secret scrub entirely (CL-5717) (#426)
* Add a failing test for grep output bypassing the secret scrub 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. * Prepend the secret scrub and result cap so short-circuiting plugins can'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. * Delete ripgrepPlugin's own char-cap helper now that the wiring caps unconditionally 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. * Fix the exploitable prepend order and make the short-circuit test cover 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. * Correct the short-circuit test's comment to match what it actually guards 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.
1 parent 1fb2a4f commit 553a3d0

4 files changed

Lines changed: 171 additions & 23 deletions

File tree

src/agent/posix-tool-plugins.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from "node:path";
44
import { tmpdir } from "node:os";
55
import { createBlobReader } from "@intx/types/runtime";
66
import { createPosixTools, composeMiddleware } from "@intx/tools-posix";
7+
import type { ToolPlugin } from "@intx/tools-posix";
78
import type { ToolCall, ToolResult } from "@intx/types/runtime";
89
import { createPermissionGate } from "../permission/gate.js";
910
import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js";
@@ -295,4 +296,141 @@ describe("buildCorePosixToolPlugins", () => {
295296
await rm(dir, { recursive: true, force: true });
296297
}
297298
});
299+
300+
test("a grep result containing a secret-shaped string is redacted before reaching the model (CL-5717)", async () => {
301+
const cwd = await mkdtemp(join(tmpdir(), "ic-posix-grep-scrub-"));
302+
try {
303+
await writeFile(
304+
join(cwd, "leaky.env"),
305+
"AWS_KEY=AKIAABCDEFGHIJKLMNOP\nOPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456\n",
306+
);
307+
308+
const gate = createPermissionGate({
309+
approvals: [],
310+
interactive: false,
311+
skipPermissions: true,
312+
cwd,
313+
});
314+
const runner = createPosixTools({
315+
cwd,
316+
plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }),
317+
});
318+
319+
const result = await runner.run(
320+
{ id: "grep-1", name: "grep", arguments: { pattern: "AKIA|sk-", path: cwd } },
321+
new AbortController().signal,
322+
);
323+
324+
expect(result.isError).not.toBe(true);
325+
const content = String(result.content);
326+
expect(content).not.toContain("AKIAABCDEFGHIJKLMNOP");
327+
expect(content).not.toContain("sk-abcdefghijklmnopqrstuvwxyz123456");
328+
expect(content).toContain("[redacted: looks like a credential]");
329+
} finally {
330+
await rm(cwd, { recursive: true, force: true });
331+
}
332+
});
333+
334+
test("a plugin that returns without calling next() still gets capped and scrubbed (CL-5717)", async () => {
335+
// Generic, plugin-shape-agnostic version of the grep case above: any
336+
// plugin that answers a scrubbable/truncatable tool directly instead of
337+
// delegating to `next` must still be capped and scrubbed, because it is
338+
// wrapped by the unconditional outer plugins in buildCorePosixToolPlugins.
339+
// This composes the REAL production array from the builder — not a
340+
// hand-picked middleware order — with a short-circuiting stand-in spliced
341+
// in at ripgrepPlugin's own position, so moving both terminal concerns
342+
// away from the front of the real array fails this test.
343+
//
344+
// This guards their PREPENDED POSITION only, not the RELATIVE order
345+
// between the two of them: the secret here sits at the very front of the
346+
// payload, nowhere near the cap boundary, so it survives even under the
347+
// exploitable cap-then-scrub order. The relative order is guarded solely
348+
// by the boundary-straddle test below — do not treat this test as
349+
// redundant with it.
350+
const secretShapedContent = `AKIAABCDEFGHIJKLMNOP\n${"x".repeat(90_000)}`;
351+
const shortCircuitingPlugin: ToolPlugin = {
352+
middleware: () => async (call: ToolCall): Promise<ToolResult> => ({
353+
callId: call.id,
354+
content: secretShapedContent,
355+
}),
356+
};
357+
358+
const gate = createPermissionGate({
359+
approvals: [],
360+
interactive: false,
361+
skipPermissions: true,
362+
cwd: "/tmp",
363+
});
364+
const plugins = buildCorePosixToolPlugins({ cwd: "/tmp", permissionGate: gate });
365+
const ripgrepIndex = findMiddlewareIndex(plugins, "no matches for /");
366+
expect(ripgrepIndex).toBeGreaterThanOrEqual(0);
367+
plugins[ripgrepIndex] = shortCircuitingPlugin;
368+
369+
const composed = composeMiddleware(
370+
plugins.map((plugin) => plugin.middleware).filter((mw): mw is NonNullable<typeof mw> => mw !== undefined),
371+
async (call) => ({ callId: call.id, content: "unreachable: short-circuiting plugin never delegates" }),
372+
);
373+
374+
const result = await composed(
375+
{ id: "short-1", name: "grep", arguments: {} },
376+
new AbortController().signal,
377+
);
378+
379+
expect(result.isError).not.toBe(true);
380+
const content = String(result.content);
381+
expect(content).not.toContain("AKIAABCDEFGHIJKLMNOP");
382+
expect(content).toContain("[redacted: looks like a credential]");
383+
expect(content.length).toBeLessThan(secretShapedContent.length);
384+
expect(content).toContain("[output truncated");
385+
});
386+
387+
test("a secret straddling the character-cap boundary is still fully redacted, not left as a bare fragment (CL-5717)", async () => {
388+
// Regression guard for the exploitable ordering: if truncation ran before
389+
// the scrub, a secret split mid-pattern at the cap boundary would no
390+
// longer match the scrub's regex, and a bare, unredacted fragment of the
391+
// credential would reach the model with no redaction marker at all.
392+
const { MAX_RESULT_CHARS } = await import("../plugins/result-truncation-plugin.js");
393+
// A newline immediately ahead of the key gives the scrub regex's `\b` a
394+
// real word boundary; the padding length puts the cap boundary partway
395+
// through the 20-char key that follows, so a truncate-then-scrub bug
396+
// would cut the key down to an unmatchable, unredacted fragment.
397+
const padding = `${"x".repeat(MAX_RESULT_CHARS - 10)}\n`;
398+
const straddlingSecret = "AKIAABCDEFGHIJKLMNOP"; // 20 chars, cap lands mid-key
399+
const secretShapedContent = `${padding}${straddlingSecret}`;
400+
const shortCircuitingPlugin: ToolPlugin = {
401+
middleware: () => async (call: ToolCall): Promise<ToolResult> => ({
402+
callId: call.id,
403+
content: secretShapedContent,
404+
}),
405+
};
406+
407+
const gate = createPermissionGate({
408+
approvals: [],
409+
interactive: false,
410+
skipPermissions: true,
411+
cwd: "/tmp",
412+
});
413+
const plugins = buildCorePosixToolPlugins({ cwd: "/tmp", permissionGate: gate });
414+
const ripgrepIndex = findMiddlewareIndex(plugins, "no matches for /");
415+
expect(ripgrepIndex).toBeGreaterThanOrEqual(0);
416+
plugins[ripgrepIndex] = shortCircuitingPlugin;
417+
418+
const composed = composeMiddleware(
419+
plugins.map((plugin) => plugin.middleware).filter((mw): mw is NonNullable<typeof mw> => mw !== undefined),
420+
async (call) => ({ callId: call.id, content: "unreachable: short-circuiting plugin never delegates" }),
421+
);
422+
423+
const result = await composed(
424+
{ id: "straddle-1", name: "grep", arguments: {} },
425+
new AbortController().signal,
426+
);
427+
428+
// The redaction marker is longer than the key it replaces, so the cap can
429+
// still trim its tail — that's fine, it's already-redacted text. The
430+
// security property under test is narrower: no bare, matchable-or-partial
431+
// fragment of the raw key survives into the result.
432+
const content = String(result.content);
433+
expect(content).not.toContain(straddlingSecret);
434+
expect(content).not.toMatch(/AKIA[0-9A-Z]*/);
435+
});
298436
});

src/agent/posix-tool-plugins.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,27 @@ export type CorePosixToolPluginsArgs = {
3535
};
3636

3737
// Middleware order matches docs/ARCHITECTURE.md: path escape through truncation,
38-
// with shell-guard after permission so blocked commands never spawn. Secret-shaped
39-
// result scrub runs immediately before truncation so credentials are redacted first.
38+
// with shell-guard after permission so blocked commands never spawn.
39+
//
40+
// The secret scrub and the character cap are prepended unconditionally, ahead
41+
// of every other plugin, rather than left in call order. composeMiddleware
42+
// wraps outer-to-inner in array order, so a plugin earlier in this array
43+
// still observes the final result even when a later plugin (ripgrepPlugin,
44+
// notably) answers a call directly without invoking its own `next()` and so
45+
// never reaches whatever sits after it. A mandatory terminal concern like
46+
// redacting a credential cannot depend on every middleware author remembering
47+
// to call `next()` — see vendor/intx-inference/src/assembly.ts's
48+
// sizeCapTransform for the same reasoning upstream.
49+
//
50+
// Truncation must run outermost, ahead of (i.e. after "seeing the result of")
51+
// the scrub — meaning the scrub sits closer to the base handler, at index 1,
52+
// so it runs on the FULL, untruncated content and truncation only trims what
53+
// the scrub already produced. The reverse order is exploitable: a secret
54+
// straddling the character-cap boundary gets cut mid-pattern (e.g.
55+
// `AKIA[0-9A-Z]{16}` losing its tail), the scrub's regex no longer matches
56+
// the fragment, and a bare, unredacted piece of the credential reaches the
57+
// model with no redaction marker. Scrub-then-truncate is always safe, since
58+
// truncating already-redacted text loses nothing sensitive.
4059
export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolPlugin[] {
4160
const {
4261
cwd,
@@ -47,6 +66,8 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
4766
shellEnv,
4867
} = args;
4968
return [
69+
resultTruncationPlugin(),
70+
toolResultSecretScrubPlugin(),
5071
pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd)),
5172
deleteFilePlugin(cwd),
5273
toolOutputUriPlugin(),
@@ -65,8 +86,6 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
6586
editFileDiagnosticsPlugin(),
6687
lspHintPlugin(),
6788
createLSPPlugin({ cwd, minSeverity: 1 }),
68-
toolResultSecretScrubPlugin(),
69-
resultTruncationPlugin(),
7089
...extraToolPlugins,
7190
];
7291
}

src/plugins/result-truncation-plugin.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ export const MAX_RESULT_CHARS = 80_000;
99
// The single primitive for size truncation: callers may pass their own
1010
// threshold but never invent their own wording, so a result can never carry
1111
// two differently-worded "truncated" notices. Called directly by runners this
12-
// middleware does not wrap — the MCP tool runner (src/mcp/plugin.ts), and
13-
// ripgrep-plugin.ts, which answers grep without calling next and so never
14-
// reaches this middleware despite sitting earlier in the same plugin array.
12+
// middleware does not wrap — the MCP tool runner (src/mcp/plugin.ts). The
13+
// posix chain gets this middleware prepended unconditionally in
14+
// posix-tool-plugins.ts, so plugins like ripgrepPlugin that answer without
15+
// calling next() no longer need to apply the cap themselves.
1516
export function truncateToolResultContent(
1617
content: string,
1718
maxChars: number = MAX_RESULT_CHARS,

src/plugins/ripgrep-plugin.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import { statSync } from "node:fs";
22
import { dirname, basename } from "node:path";
33
import type { ToolPlugin } from "@intx/tools-posix";
4-
import type { ToolResult } from "@intx/types/runtime";
54

65
import {
76
runBoundedGrep,
87
runBoundedSearchFiles,
98
type BoundedGrepArgs,
109
} from "./bounded-grep-fallback.js";
1110
import { createRgCollector } from "./rg-output.js";
12-
import { truncateToolResultContent } from "./result-truncation-plugin.js";
1311
import { MAX_OUTPUT_BYTES, runRg, type RgLimits, type SpawnRg } from "./rg-run.js";
1412

1513
// A grep over a large tree with the pure-TypeScript walker enumerates the whole
@@ -33,14 +31,6 @@ function capLines(text: string, max: number): string {
3331
return `${lines.slice(0, max).join("\n")}\n... (showing first ${max} of ${lines.length}+ matches; narrow path/glob)`;
3432
}
3533

36-
// ripgrepPlugin answers grep and search_files without calling next, so the
37-
// result-truncation middleware sitting later in the chain never sees these
38-
// results. The shared primitive is applied here instead, keeping one wording
39-
// for size truncation on a path that would otherwise return uncapped.
40-
function bounded(callId: string, content: string): ToolResult {
41-
return { callId, content: truncateToolResultContent(content) };
42-
}
43-
4434
// Mirrors read_file's truncate-and-offer behavior: a cap or timeout still
4535
// surfaces whatever matches were collected before it fired, instead of
4636
// discarding them behind a bare error. `notice` is only set for conditions
@@ -114,7 +104,7 @@ export function ripgrepPlugin(cwd: string, limits: RgLimits = {}, spawnChild?: S
114104
};
115105
if (glob !== undefined) boundedArgs.glob = glob;
116106
const content = await runBoundedGrep(boundedArgs, signal, rgCwd);
117-
return bounded(call.id, boundedContent(content, maxResults, maxBytes));
107+
return { callId: call.id, content: boundedContent(content, maxResults, maxBytes) };
118108
} catch (err) {
119109
return {
120110
callId: call.id,
@@ -130,9 +120,9 @@ export function ripgrepPlugin(cwd: string, limits: RgLimits = {}, spawnChild?: S
130120
return { callId: call.id, content: result.message, isError: true };
131121
}
132122
if (result.kind === "partial") {
133-
return bounded(call.id, partialContent(result.stdout, maxResults, result.notice));
123+
return { callId: call.id, content: partialContent(result.stdout, maxResults, result.notice) };
134124
}
135-
return bounded(call.id, capLines(result.stdout, maxResults));
125+
return { callId: call.id, content: capLines(result.stdout, maxResults) };
136126
}
137127

138128
if (call.name === "search_files") {
@@ -150,7 +140,7 @@ export function ripgrepPlugin(cwd: string, limits: RgLimits = {}, spawnChild?: S
150140
signal,
151141
rgCwd,
152142
);
153-
return bounded(call.id, boundedContent(content, maxResults, maxBytes));
143+
return { callId: call.id, content: boundedContent(content, maxResults, maxBytes) };
154144
} catch (err) {
155145
return {
156146
callId: call.id,
@@ -166,9 +156,9 @@ export function ripgrepPlugin(cwd: string, limits: RgLimits = {}, spawnChild?: S
166156
return { callId: call.id, content: result.message, isError: true };
167157
}
168158
if (result.kind === "partial") {
169-
return bounded(call.id, partialContent(result.stdout, maxResults, result.notice));
159+
return { callId: call.id, content: partialContent(result.stdout, maxResults, result.notice) };
170160
}
171-
return bounded(call.id, capLines(result.stdout, maxResults));
161+
return { callId: call.id, content: capLines(result.stdout, maxResults) };
172162
}
173163

174164
return next(call, signal);

0 commit comments

Comments
 (0)