Skip to content

Commit bc9e02c

Browse files
Pretty-spill oversized tool results into session files (CL-7055) (#675)
* Pretty-spill oversized tool results into session files Over the truncation gate, leisure-materialize content before spilling to the session blob store so the model gets a tool-output URI and an absolute on-disk path to the pretty (or preserved NDJSON) full result. * Harden spilled tool result materialization
1 parent 8a62bff commit bc9e02c

13 files changed

Lines changed: 857 additions & 101 deletions

docs/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ tool call
347347

348348
**Rejection behavior:** Any plugin can short-circuit by returning a `ToolResult` with `isError: true`; the error propagates to the agent and downstream plugins/execution are skipped.
349349

350+
- **Result truncation / leisure materialization** (`result-truncation-plugin.ts`, `tool-result-materialize.ts`) — Caps model-facing tool results at 10,000 chars (aligned with the reactor size-cap). Over the gate, content is leisure-materialized first (minified JSON → pretty `application/json`; NDJSON preserved; else `text/plain`), then the formatted bytes are spilled to the session blob store under `{callId}:full` and truncated inline with a `tool-output:///` URI plus absolute `contextDir/tool-output/…` path when plumbed. Under-gate results are unchanged (no pretty, no spill). MCP tools apply the same scrub-then-truncate path via `mcpClientToAgentTools` since they skip the posix middleware chain.
350351
- **Path Escape** (`path-escape-plugin.ts`) — Canonicalizes path-like arguments against `cwd` and blocks `..` escapes, except into a root the permission layer's worktree-roots provider allowlists (e.g. a sibling git worktree of the same repo). Runs first so later plugins see resolved paths.
351352
- **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched).
352353
- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here: they require operator approval via the permission gate, and auto mode forces an ask through the auto-shell policy (`sensitive-path` rule). Once the operator approves, the command runs. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output.

src/agent/posix-tool-plugins.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface CorePosixToolPluginsArgs {
3333
// Session blob-store writer oversized tool results spill their full content
3434
// into. See result-truncation-plugin.ts.
3535
getBlobWriter?: () => SpillBlobWriter | undefined;
36+
// Absolute session context dir for the truncation notice's on-disk path.
37+
getContextDir?: () => string | undefined;
3638
// Per-project settings.env, merged into the run_shell spawn environment.
3739
shellEnv?: Record<string, string>;
3840
}
@@ -67,6 +69,7 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
6769
extraToolPlugins = [],
6870
readFileGuard = {},
6971
getBlobWriter,
72+
getContextDir,
7073
shellEnv,
7174
} = args;
7275
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
@@ -75,8 +78,15 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
7578
// rebuilding the plugin stack. Secret-guard and authz still hard-deny
7679
// regardless.
7780
const allowOutside = (): boolean => permissionGate.getSkipPermissions();
81+
const truncationOptions =
82+
getBlobWriter !== undefined || getContextDir !== undefined
83+
? {
84+
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
85+
...(getContextDir !== undefined ? { getContextDir } : {}),
86+
}
87+
: {};
7888
return [
79-
resultTruncationPlugin(getBlobWriter !== undefined ? { getBlobWriter } : {}),
89+
resultTruncationPlugin(truncationOptions),
8090
toolResultSecretScrubPlugin(),
8191
pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }),
8292
deleteFilePlugin(cwd, { allowOutside }),

src/agent/tools.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ export interface AgentToolsetArgs {
126126
// write into (tests). Persists with the rest of the session's committed
127127
// history — no separate cleanup.
128128
getBlobWriter?: () => SpillBlobWriter | undefined;
129+
// Absolute session context dir (`…/context`) for the truncation notice's
130+
// on-disk path. Re-read live like getBlobWriter across session rotation.
131+
getContextDir?: () => string | undefined;
129132
// Per-project settings.env, merged into the run_shell tool's spawn environment.
130133
shellEnv?: Record<string, string>;
131134
// Whether a workflow is currently running. advance_workflow rides the wire
@@ -216,6 +219,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
216219
toolWatchdog,
217220
getBlobReader,
218221
getBlobWriter,
222+
getContextDir,
219223
sessionMode = "orchestrator",
220224
shellEnv,
221225
toolAvailability = { languageServerAvailable: true },
@@ -239,6 +243,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
239243
? { readFileGuard: { blobReader: sessionBlobReader } }
240244
: {}),
241245
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
246+
...(getContextDir !== undefined ? { getContextDir } : {}),
242247
...(shellEnv !== undefined ? { shellEnv } : {}),
243248
}),
244249
});
@@ -514,7 +519,10 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
514519
}
515520
connectedClients.push(result.client);
516521
permissionGate.registerMcpClient(result.client);
517-
const mcpTools = mcpClientToAgentTools(result.client, permissionGate, getBlobWriter);
522+
const mcpTools = mcpClientToAgentTools(result.client, permissionGate, {
523+
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
524+
...(getContextDir !== undefined ? { getContextDir } : {}),
525+
});
518526
inheritedMcpTools.push(...mcpTools);
519527
dynamicRunner.addTools(mcpTools);
520528
callbacks.onStatus({

src/exec/runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
403403
...(toolWatchdog !== undefined ? { toolWatchdog } : {}),
404404
...(localSettingsForMode?.env !== undefined ? { shellEnv: localSettingsForMode.env } : {}),
405405
getBlobWriter: () => currentStorage?.writeBlob,
406+
getContextDir: () => workdir,
406407
getBlobReader: () => {
407408
if (currentAgent === null) {
408409
throw new Error("blob reader requested before agent init");

src/mcp/plugin.test.ts

Lines changed: 120 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, test, expect } from "bun:test";
22
import { mcpClientToAgentTools } from "./plugin.js";
33
import { createPermissionGate } from "../permission/gate.js";
4+
import { MAX_RESULT_CHARS, spillBlobKey } from "../plugins/result-truncation-plugin.js";
5+
import { toolOutputAbsolutePath } from "../plugins/tool-result-materialize.js";
46
import { CREDENTIAL_REDACTION } from "../plugins/tool-result-secret-scrub.js";
57
import type { MCPClient } from "./client.js";
68

@@ -19,15 +21,29 @@ function fakeClient(reply: string): MCPClient {
1921
};
2022
}
2123

24+
function fakeBlobStore() {
25+
const blobs = new Map<string, { bytes: Uint8Array; contentType: string }>();
26+
return {
27+
blobs,
28+
writeBlob: async (key: string, bytes: Uint8Array, contentType: string) => {
29+
blobs.set(key, { bytes, contentType });
30+
},
31+
};
32+
}
33+
34+
function skipGate() {
35+
return createPermissionGate({
36+
approvals: [],
37+
interactive: false,
38+
skipPermissions: true,
39+
cwd: process.cwd(),
40+
});
41+
}
42+
2243
describe("mcpClientToAgentTools", () => {
2344
test("scrubs a credential-shaped MCP result the same as built-in tools", async () => {
24-
const gate = createPermissionGate({
25-
approvals: [],
26-
interactive: false,
27-
skipPermissions: true,
28-
cwd: process.cwd(),
29-
});
30-
const client = fakeClient("here is the key: sk-live-abc123xyz789012345678");
45+
const gate = skipGate();
46+
const client = fakeClient("here is the key: sk-live-abc123abcdefghijklmnopqrst");
3147
const [tool] = mcpClientToAgentTools(client, gate);
3248
expect(tool?.kind).toBe("full");
3349
if (tool?.kind !== "full") throw new Error("expected full tool");
@@ -42,12 +58,7 @@ describe("mcpClientToAgentTools", () => {
4258
});
4359

4460
test("truncates an oversized MCP result the same as built-in tools", async () => {
45-
const gate = createPermissionGate({
46-
approvals: [],
47-
interactive: false,
48-
skipPermissions: true,
49-
cwd: process.cwd(),
50-
});
61+
const gate = skipGate();
5162
const huge = "x".repeat(90_000);
5263
const client = fakeClient(huge);
5364
const [tool] = mcpClientToAgentTools(client, gate);
@@ -62,4 +73,100 @@ describe("mcpClientToAgentTools", () => {
6273
expect((result.content as string).length).toBeLessThan(huge.length);
6374
expect(result.content).toContain("output truncated");
6475
});
76+
77+
test("pretty-spills oversized minified JSON with contextDir in the notice", async () => {
78+
const gate = skipGate();
79+
const store = fakeBlobStore();
80+
const contextDir = "/tmp/session/context";
81+
82+
const obj: Record<string, string> = {};
83+
for (let i = 0; i < 400; i++) {
84+
obj[`key_${i}`] = `value_${i}_${"x".repeat(20)}`;
85+
}
86+
const minified = JSON.stringify(obj);
87+
expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS);
88+
const pretty = JSON.stringify(obj, null, 2);
89+
90+
const client = fakeClient(minified);
91+
const [tool] = mcpClientToAgentTools(client, gate, {
92+
getBlobWriter: () => store.writeBlob,
93+
getContextDir: () => contextDir,
94+
});
95+
if (tool?.kind !== "full") throw new Error("expected full tool");
96+
97+
const result = await tool.handler(
98+
{ id: "c-mcp-json", name: "mcp__acme__fetch_secret", arguments: {} },
99+
new AbortController().signal,
100+
);
101+
102+
const key = spillBlobKey("c-mcp-json");
103+
const entry = store.blobs.get(key);
104+
expect(entry).toBeDefined();
105+
expect(entry?.contentType).toBe("application/json");
106+
expect(new TextDecoder().decode(entry!.bytes)).toBe(pretty);
107+
108+
const uri = `tool-output:///${key}`;
109+
const abs = toolOutputAbsolutePath(contextDir, key, "application/json");
110+
expect(result.content).toContain(uri);
111+
expect(result.content).toContain(abs);
112+
expect(result.content).toContain("application/json");
113+
expect(result.content).toContain("output truncated");
114+
});
115+
116+
test("scrubs escaped secrets after oversized JSON pretty materialization", async () => {
117+
const gate = skipGate();
118+
const store = fakeBlobStore();
119+
const escapedSecret = `sk-\\u006cive-${"b".repeat(24)}`;
120+
const minified = `{"secret":"${escapedSecret}","pad":"${"x".repeat(MAX_RESULT_CHARS)}"}`;
121+
expect(minified).not.toContain("sk-live-");
122+
123+
const client = fakeClient(minified);
124+
const [tool] = mcpClientToAgentTools(client, gate, {
125+
getBlobWriter: () => store.writeBlob,
126+
});
127+
if (tool?.kind !== "full") throw new Error("expected full tool");
128+
129+
const result = await tool.handler(
130+
{
131+
id: "c-mcp-json-secret",
132+
name: "mcp__acme__fetch_secret",
133+
arguments: {},
134+
},
135+
new AbortController().signal,
136+
);
137+
138+
const spilled = new TextDecoder().decode(
139+
store.blobs.get(spillBlobKey("c-mcp-json-secret"))!.bytes,
140+
);
141+
expect(result.content).toContain(CREDENTIAL_REDACTION);
142+
expect(result.content).not.toContain("sk-live-");
143+
expect(spilled).toContain(CREDENTIAL_REDACTION);
144+
expect(spilled).not.toContain("sk-live-");
145+
expect(spilled).not.toContain(escapedSecret);
146+
});
147+
148+
test("spills oversized plain text under :full and names contextDir path", async () => {
149+
const gate = skipGate();
150+
const store = fakeBlobStore();
151+
const contextDir = "/session/context";
152+
const huge = "z".repeat(MAX_RESULT_CHARS + 500);
153+
const client = fakeClient(huge);
154+
const [tool] = mcpClientToAgentTools(client, gate, {
155+
getBlobWriter: () => store.writeBlob,
156+
getContextDir: () => contextDir,
157+
});
158+
if (tool?.kind !== "full") throw new Error("expected full tool");
159+
160+
const result = await tool.handler(
161+
{ id: "c-mcp-txt", name: "mcp__acme__fetch_secret", arguments: {} },
162+
new AbortController().signal,
163+
);
164+
165+
const key = spillBlobKey("c-mcp-txt");
166+
const entry = store.blobs.get(key);
167+
expect(entry?.contentType).toBe("text/plain");
168+
expect(new TextDecoder().decode(entry!.bytes)).toBe(huge);
169+
expect(result.content).toContain(`tool-output:///${key}`);
170+
expect(result.content).toContain(toolOutputAbsolutePath(contextDir, key, "text/plain"));
171+
});
65172
});

src/mcp/plugin.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ import {
1010
import type { MCPClient } from "./client.js";
1111
import { mcpToolName } from "./tool-name.js";
1212

13+
export interface McpSpillOptions {
14+
getBlobWriter?: () => SpillBlobWriter | undefined;
15+
getContextDir?: () => string | undefined;
16+
}
17+
1318
// MCP results never reach the posix runner, so the secret-scrub and truncation
1419
// middleware in src/plugins never see them. Apply the same scrub-then-truncate
1520
// order here directly (see buildCorePosixToolPlugins) so a compromised MCP
1621
// server cannot leak credential-shaped strings or flood the transcript.
1722
function sanitizeMcpResultContent(
1823
content: string,
19-
spill?: { callId: string; writeBlob: SpillBlobWriter },
24+
spill?: { callId: string; writeBlob: SpillBlobWriter; contextDir?: string },
2025
): Promise<string> {
2126
return truncateToolResultContent(scrubSecretShapedToolResultContent(content), undefined, spill);
2227
}
@@ -27,8 +32,10 @@ function sanitizeMcpResultContent(
2732
export function mcpClientToAgentTools(
2833
client: MCPClient,
2934
gate: PermissionGate,
30-
getBlobWriter?: () => SpillBlobWriter | undefined,
35+
spillOptions: McpSpillOptions = {},
3136
): AgentTool[] {
37+
const { getBlobWriter, getContextDir } = spillOptions;
38+
3239
return client.tools.map((tool) => ({
3340
kind: "full" as const,
3441
definition: {
@@ -41,7 +48,15 @@ export function mcpClientToAgentTools(
4148
try {
4249
const content = await client.call(tool.name, call.arguments, signal);
4350
const writeBlob = getBlobWriter?.();
44-
const spill = writeBlob !== undefined ? { callId: call.id, writeBlob } : undefined;
51+
const contextDir = getContextDir?.();
52+
const spill =
53+
writeBlob !== undefined
54+
? {
55+
callId: call.id,
56+
writeBlob,
57+
...(contextDir !== undefined ? { contextDir } : {}),
58+
}
59+
: undefined;
4560
return { callId: call.id, content: await sanitizeMcpResultContent(content, spill) };
4661
} catch (err) {
4762
return {

0 commit comments

Comments
 (0)