Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ tool call

**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.

- **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.
- **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.
- **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched).
- **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.
Expand Down
12 changes: 11 additions & 1 deletion src/agent/posix-tool-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface CorePosixToolPluginsArgs {
// Session blob-store writer oversized tool results spill their full content
// into. See result-truncation-plugin.ts.
getBlobWriter?: () => SpillBlobWriter | undefined;
// Absolute session context dir for the truncation notice's on-disk path.
getContextDir?: () => string | undefined;
// Per-project settings.env, merged into the run_shell spawn environment.
shellEnv?: Record<string, string>;
}
Expand Down Expand Up @@ -67,6 +69,7 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
extraToolPlugins = [],
readFileGuard = {},
getBlobWriter,
getContextDir,
shellEnv,
} = args;
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
Expand All @@ -75,8 +78,15 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP
// rebuilding the plugin stack. Secret-guard and authz still hard-deny
// regardless.
const allowOutside = (): boolean => permissionGate.getSkipPermissions();
const truncationOptions =
getBlobWriter !== undefined || getContextDir !== undefined
? {
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
...(getContextDir !== undefined ? { getContextDir } : {}),
}
: {};
return [
resultTruncationPlugin(getBlobWriter !== undefined ? { getBlobWriter } : {}),
resultTruncationPlugin(truncationOptions),
toolResultSecretScrubPlugin(),
pathEscapePlugin(cwd, createWorktreeRootsProvider(cwd), { allowOutside }),
deleteFilePlugin(cwd, { allowOutside }),
Expand Down
10 changes: 9 additions & 1 deletion src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export interface AgentToolsetArgs {
// write into (tests). Persists with the rest of the session's committed
// history — no separate cleanup.
getBlobWriter?: () => SpillBlobWriter | undefined;
// Absolute session context dir (`…/context`) for the truncation notice's
// on-disk path. Re-read live like getBlobWriter across session rotation.
getContextDir?: () => string | undefined;
// Per-project settings.env, merged into the run_shell tool's spawn environment.
shellEnv?: Record<string, string>;
// Whether a workflow is currently running. advance_workflow rides the wire
Expand Down Expand Up @@ -216,6 +219,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
toolWatchdog,
getBlobReader,
getBlobWriter,
getContextDir,
sessionMode = "orchestrator",
shellEnv,
toolAvailability = { languageServerAvailable: true },
Expand All @@ -239,6 +243,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
? { readFileGuard: { blobReader: sessionBlobReader } }
: {}),
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
...(getContextDir !== undefined ? { getContextDir } : {}),
...(shellEnv !== undefined ? { shellEnv } : {}),
}),
});
Expand Down Expand Up @@ -514,7 +519,10 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
}
connectedClients.push(result.client);
permissionGate.registerMcpClient(result.client);
const mcpTools = mcpClientToAgentTools(result.client, permissionGate, getBlobWriter);
const mcpTools = mcpClientToAgentTools(result.client, permissionGate, {
...(getBlobWriter !== undefined ? { getBlobWriter } : {}),
...(getContextDir !== undefined ? { getContextDir } : {}),
});
inheritedMcpTools.push(...mcpTools);
dynamicRunner.addTools(mcpTools);
callbacks.onStatus({
Expand Down
1 change: 1 addition & 0 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
...(toolWatchdog !== undefined ? { toolWatchdog } : {}),
...(localSettingsForMode?.env !== undefined ? { shellEnv: localSettingsForMode.env } : {}),
getBlobWriter: () => currentStorage?.writeBlob,
getContextDir: () => workdir,
getBlobReader: () => {
if (currentAgent === null) {
throw new Error("blob reader requested before agent init");
Expand Down
133 changes: 120 additions & 13 deletions src/mcp/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, test, expect } from "bun:test";
import { mcpClientToAgentTools } from "./plugin.js";
import { createPermissionGate } from "../permission/gate.js";
import { MAX_RESULT_CHARS, spillBlobKey } from "../plugins/result-truncation-plugin.js";
import { toolOutputAbsolutePath } from "../plugins/tool-result-materialize.js";
import { CREDENTIAL_REDACTION } from "../plugins/tool-result-secret-scrub.js";
import type { MCPClient } from "./client.js";

Expand All @@ -19,15 +21,29 @@ function fakeClient(reply: string): MCPClient {
};
}

function fakeBlobStore() {
const blobs = new Map<string, { bytes: Uint8Array; contentType: string }>();
return {
blobs,
writeBlob: async (key: string, bytes: Uint8Array, contentType: string) => {
blobs.set(key, { bytes, contentType });
},
};
}

function skipGate() {
return createPermissionGate({
approvals: [],
interactive: false,
skipPermissions: true,
cwd: process.cwd(),
});
}

describe("mcpClientToAgentTools", () => {
test("scrubs a credential-shaped MCP result the same as built-in tools", async () => {
const gate = createPermissionGate({
approvals: [],
interactive: false,
skipPermissions: true,
cwd: process.cwd(),
});
const client = fakeClient("here is the key: sk-live-abc123xyz789012345678");
const gate = skipGate();
const client = fakeClient("here is the key: sk-live-abc123abcdefghijklmnopqrst");
const [tool] = mcpClientToAgentTools(client, gate);
expect(tool?.kind).toBe("full");
if (tool?.kind !== "full") throw new Error("expected full tool");
Expand All @@ -42,12 +58,7 @@ describe("mcpClientToAgentTools", () => {
});

test("truncates an oversized MCP result the same as built-in tools", async () => {
const gate = createPermissionGate({
approvals: [],
interactive: false,
skipPermissions: true,
cwd: process.cwd(),
});
const gate = skipGate();
const huge = "x".repeat(90_000);
const client = fakeClient(huge);
const [tool] = mcpClientToAgentTools(client, gate);
Expand All @@ -62,4 +73,100 @@ describe("mcpClientToAgentTools", () => {
expect((result.content as string).length).toBeLessThan(huge.length);
expect(result.content).toContain("output truncated");
});

test("pretty-spills oversized minified JSON with contextDir in the notice", async () => {
const gate = skipGate();
const store = fakeBlobStore();
const contextDir = "/tmp/session/context";

const obj: Record<string, string> = {};
for (let i = 0; i < 400; i++) {
obj[`key_${i}`] = `value_${i}_${"x".repeat(20)}`;
}
const minified = JSON.stringify(obj);
expect(minified.length).toBeGreaterThan(MAX_RESULT_CHARS);
const pretty = JSON.stringify(obj, null, 2);

const client = fakeClient(minified);
const [tool] = mcpClientToAgentTools(client, gate, {
getBlobWriter: () => store.writeBlob,
getContextDir: () => contextDir,
});
if (tool?.kind !== "full") throw new Error("expected full tool");

const result = await tool.handler(
{ id: "c-mcp-json", name: "mcp__acme__fetch_secret", arguments: {} },
new AbortController().signal,
);

const key = spillBlobKey("c-mcp-json");
const entry = store.blobs.get(key);
expect(entry).toBeDefined();
expect(entry?.contentType).toBe("application/json");
expect(new TextDecoder().decode(entry!.bytes)).toBe(pretty);

const uri = `tool-output:///${key}`;
const abs = toolOutputAbsolutePath(contextDir, key, "application/json");
expect(result.content).toContain(uri);
expect(result.content).toContain(abs);
expect(result.content).toContain("application/json");
expect(result.content).toContain("output truncated");
});

test("scrubs escaped secrets after oversized JSON pretty materialization", async () => {
const gate = skipGate();
const store = fakeBlobStore();
const escapedSecret = `sk-\\u006cive-${"b".repeat(24)}`;
const minified = `{"secret":"${escapedSecret}","pad":"${"x".repeat(MAX_RESULT_CHARS)}"}`;
expect(minified).not.toContain("sk-live-");

const client = fakeClient(minified);
const [tool] = mcpClientToAgentTools(client, gate, {
getBlobWriter: () => store.writeBlob,
});
if (tool?.kind !== "full") throw new Error("expected full tool");

const result = await tool.handler(
{
id: "c-mcp-json-secret",
name: "mcp__acme__fetch_secret",
arguments: {},
},
new AbortController().signal,
);

const spilled = new TextDecoder().decode(
store.blobs.get(spillBlobKey("c-mcp-json-secret"))!.bytes,
);
expect(result.content).toContain(CREDENTIAL_REDACTION);
expect(result.content).not.toContain("sk-live-");
expect(spilled).toContain(CREDENTIAL_REDACTION);
expect(spilled).not.toContain("sk-live-");
expect(spilled).not.toContain(escapedSecret);
});

test("spills oversized plain text under :full and names contextDir path", async () => {
const gate = skipGate();
const store = fakeBlobStore();
const contextDir = "/session/context";
const huge = "z".repeat(MAX_RESULT_CHARS + 500);
const client = fakeClient(huge);
const [tool] = mcpClientToAgentTools(client, gate, {
getBlobWriter: () => store.writeBlob,
getContextDir: () => contextDir,
});
if (tool?.kind !== "full") throw new Error("expected full tool");

const result = await tool.handler(
{ id: "c-mcp-txt", name: "mcp__acme__fetch_secret", arguments: {} },
new AbortController().signal,
);

const key = spillBlobKey("c-mcp-txt");
const entry = store.blobs.get(key);
expect(entry?.contentType).toBe("text/plain");
expect(new TextDecoder().decode(entry!.bytes)).toBe(huge);
expect(result.content).toContain(`tool-output:///${key}`);
expect(result.content).toContain(toolOutputAbsolutePath(contextDir, key, "text/plain"));
});
});
21 changes: 18 additions & 3 deletions src/mcp/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ import {
import type { MCPClient } from "./client.js";
import { mcpToolName } from "./tool-name.js";

export interface McpSpillOptions {
getBlobWriter?: () => SpillBlobWriter | undefined;
getContextDir?: () => string | undefined;
}

// MCP results never reach the posix runner, so the secret-scrub and truncation
// middleware in src/plugins never see them. Apply the same scrub-then-truncate
// order here directly (see buildCorePosixToolPlugins) so a compromised MCP
// server cannot leak credential-shaped strings or flood the transcript.
function sanitizeMcpResultContent(
content: string,
spill?: { callId: string; writeBlob: SpillBlobWriter },
spill?: { callId: string; writeBlob: SpillBlobWriter; contextDir?: string },
): Promise<string> {
return truncateToolResultContent(scrubSecretShapedToolResultContent(content), undefined, spill);
}
Expand All @@ -27,8 +32,10 @@ function sanitizeMcpResultContent(
export function mcpClientToAgentTools(
client: MCPClient,
gate: PermissionGate,
getBlobWriter?: () => SpillBlobWriter | undefined,
spillOptions: McpSpillOptions = {},
): AgentTool[] {
const { getBlobWriter, getContextDir } = spillOptions;

return client.tools.map((tool) => ({
kind: "full" as const,
definition: {
Expand All @@ -41,7 +48,15 @@ export function mcpClientToAgentTools(
try {
const content = await client.call(tool.name, call.arguments, signal);
const writeBlob = getBlobWriter?.();
const spill = writeBlob !== undefined ? { callId: call.id, writeBlob } : undefined;
const contextDir = getContextDir?.();
const spill =
writeBlob !== undefined
? {
callId: call.id,
writeBlob,
...(contextDir !== undefined ? { contextDir } : {}),
}
: undefined;
return { callId: call.id, content: await sanitizeMcpResultContent(content, spill) };
} catch (err) {
return {
Expand Down
Loading
Loading