feat(acp): untrusted-input hardening — output bounds + sanitization (U6)
The agent is untrusted input and the high inactivity ceiling (KTD4) does not bound an actively-flooding agent. Adds sanitize.ts (strip ANSI/control sequences, bound strings, bound identifiers — reject path separators/NUL so an agent-supplied id can never reach a path). event-bridge.ts now caps per-turn cumulative output (5M chars, truncate-and-flag once) and per-chunk size (64k), sanitizes text/thinking/tool-title before callbacks (S7), and bounds the toolCallId correlation map with FIFO eviction (S5). sessionId passed through boundIdentifier before storage. +28 tests (134 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
createEventBridge,
|
||||
PER_TURN_OUTPUT_CAP_CHARS,
|
||||
PER_CHUNK_CAP_CHARS,
|
||||
TOOL_CALL_MAP_CAP,
|
||||
} from "../event-bridge.js";
|
||||
import type { AcpCallbacks } from "../types.js";
|
||||
|
||||
function makeCallbacks() {
|
||||
const onText = vi.fn<(text: string) => void>();
|
||||
const onThinking = vi.fn<(text: string) => void>();
|
||||
const onToolStart = vi.fn<(name: string, args?: unknown) => void>();
|
||||
const onToolEnd = vi.fn<(name: string, isError: boolean, result?: unknown) => void>();
|
||||
const callbacks: AcpCallbacks = { onText, onThinking, onToolStart, onToolEnd };
|
||||
return { callbacks, onText, onThinking, onToolStart, onToolEnd };
|
||||
}
|
||||
|
||||
function textChunk(text: string): SessionUpdate {
|
||||
return { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } as SessionUpdate;
|
||||
}
|
||||
|
||||
describe("event bridge bounds: per-turn cumulative cap (Risk S5)", () => {
|
||||
it("stops forwarding text once the per-turn cap is exceeded and flags once", () => {
|
||||
const { callbacks, onText, onThinking } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
|
||||
// Each chunk is itself within the per-chunk cap; many of them exceed the
|
||||
// per-turn cap. Total forwarded text must stay bounded.
|
||||
const chunk = "x".repeat(PER_CHUNK_CAP_CHARS);
|
||||
const chunksNeeded = Math.ceil(PER_TURN_OUTPUT_CAP_CHARS / PER_CHUNK_CAP_CHARS) + 5;
|
||||
for (let i = 0; i < chunksNeeded; i++) {
|
||||
bridge.handleSessionUpdate(textChunk(chunk));
|
||||
}
|
||||
|
||||
const totalForwarded = onText.mock.calls.reduce((sum, c) => sum + c[0].length, 0);
|
||||
// Bounded: never far beyond the cap (one chunk of slack at most).
|
||||
expect(totalForwarded).toBeLessThanOrEqual(PER_TURN_OUTPUT_CAP_CHARS + PER_CHUNK_CAP_CHARS);
|
||||
expect(totalForwarded).toBeGreaterThan(0);
|
||||
|
||||
// Exactly one truncation flag line emitted via onThinking.
|
||||
const flagCalls = onThinking.mock.calls.filter((c) =>
|
||||
String(c[0]).includes("output truncated"),
|
||||
);
|
||||
expect(flagCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("reset() clears the per-turn counter so a new turn forwards fresh", () => {
|
||||
const { callbacks, onText, onThinking } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
const chunk = "y".repeat(PER_CHUNK_CAP_CHARS);
|
||||
const chunksNeeded = Math.ceil(PER_TURN_OUTPUT_CAP_CHARS / PER_CHUNK_CAP_CHARS) + 2;
|
||||
for (let i = 0; i < chunksNeeded; i++) bridge.handleSessionUpdate(textChunk(chunk));
|
||||
onText.mockClear();
|
||||
onThinking.mockClear();
|
||||
|
||||
bridge.reset();
|
||||
bridge.handleSessionUpdate(textChunk("after reset"));
|
||||
expect(onText).toHaveBeenCalledWith("after reset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event bridge bounds: per-chunk cap (Risk S5)", () => {
|
||||
it("caps an oversized single content chunk", () => {
|
||||
const { callbacks, onText } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
bridge.handleSessionUpdate(textChunk("z".repeat(PER_CHUNK_CAP_CHARS * 4)));
|
||||
expect(onText).toHaveBeenCalledTimes(1);
|
||||
expect(onText.mock.calls[0][0].length).toBeLessThanOrEqual(PER_CHUNK_CAP_CHARS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event bridge sanitization: tool title (Risk S7)", () => {
|
||||
it("strips ANSI/control escapes from a tool title before the callback", () => {
|
||||
const { callbacks, onToolStart } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
bridge.handleSessionUpdate({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "t1",
|
||||
title: "\x1b[31mRun\x1b[0m\x07 tests\x00",
|
||||
kind: "execute",
|
||||
} as SessionUpdate);
|
||||
|
||||
expect(onToolStart).toHaveBeenCalledTimes(1);
|
||||
const name = onToolStart.mock.calls[0][0];
|
||||
expect(name).toBe("Run tests");
|
||||
expect(name).not.toContain("\x1b");
|
||||
expect(name).not.toContain("\x00");
|
||||
});
|
||||
|
||||
it("strips control escapes from agent text before onText", () => {
|
||||
const { callbacks, onText } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
bridge.handleSessionUpdate(textChunk("\x1b]0;evil\x07hello\x1b[2J"));
|
||||
expect(onText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event bridge bounds: toolCall correlation map (Risk S5)", () => {
|
||||
it("bounds the map under a flood of unique toolCallIds (evicts oldest)", () => {
|
||||
const { callbacks, onToolStart, onToolEnd } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
|
||||
const flood = TOOL_CALL_MAP_CAP * 3;
|
||||
for (let i = 0; i < flood; i++) {
|
||||
bridge.handleSessionUpdate({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: `flood-${i}`,
|
||||
title: `T${i}`,
|
||||
kind: "other",
|
||||
} as SessionUpdate);
|
||||
}
|
||||
// Every start fires (callbacks not gated), but memory (map) is bounded.
|
||||
expect(onToolStart).toHaveBeenCalledTimes(flood);
|
||||
|
||||
// A terminal update for an EVICTED early id still resolves (orphan path),
|
||||
// proving the map does not retain all ids. The newest ids remain tracked.
|
||||
const newest = flood - 1;
|
||||
bridge.handleSessionUpdate({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: `flood-${newest}`,
|
||||
status: "completed",
|
||||
} as SessionUpdate);
|
||||
expect(onToolEnd).toHaveBeenLastCalledWith(`T${newest}`, false, undefined);
|
||||
});
|
||||
|
||||
it("normalizes a path-separator toolCallId used as a map key", () => {
|
||||
const { callbacks, onToolStart, onToolEnd } = makeCallbacks();
|
||||
const bridge = createEventBridge(callbacks);
|
||||
bridge.handleSessionUpdate({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "../../evil/id",
|
||||
title: "Sneaky",
|
||||
kind: "other",
|
||||
} as SessionUpdate);
|
||||
bridge.handleSessionUpdate({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "../../evil/id",
|
||||
status: "completed",
|
||||
} as SessionUpdate);
|
||||
// Same normalized key correlates start↔end exactly once.
|
||||
expect(onToolStart).toHaveBeenCalledTimes(1);
|
||||
expect(onToolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(onToolEnd).toHaveBeenCalledWith("Sneaky", false, undefined);
|
||||
});
|
||||
});
|
||||
@@ -140,3 +140,46 @@ describe("session driving helpers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionId untrusted-input bounding (U6 / Risk S7)", () => {
|
||||
// A fake connection that returns a malicious agent-supplied sessionId so we can
|
||||
// assert the helper normalizes it before it is ever stored / path-joined —
|
||||
// without spawning a real agent.
|
||||
function fakeConn(sessionId: string, opts?: { loadSession?: boolean }): AcpConnection {
|
||||
const conn = {
|
||||
newSession: vi.fn(async () => ({ sessionId, modes: undefined })),
|
||||
loadSession: vi.fn(async () => ({ modes: undefined })),
|
||||
};
|
||||
return {
|
||||
conn: conn as unknown as AcpConnection["conn"],
|
||||
child: {} as AcpConnection["child"],
|
||||
agentCapabilities: { loadSession: opts?.loadSession === true },
|
||||
authMethods: [],
|
||||
stderr: () => "",
|
||||
dispose: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
it("normalizes a sessionId containing path separators from session/new", async () => {
|
||||
const conn = fakeConn("../../etc/passwd");
|
||||
const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() });
|
||||
expect(sessionId).not.toContain("/");
|
||||
expect(sessionId).not.toContain("..");
|
||||
});
|
||||
|
||||
it("bounds an absurdly long agent sessionId", async () => {
|
||||
const conn = fakeConn("s".repeat(100_000));
|
||||
const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() });
|
||||
expect(sessionId.length).toBeLessThanOrEqual(256);
|
||||
});
|
||||
|
||||
it("normalizes the resume id passed to loadAcpSession", async () => {
|
||||
const conn = fakeConn("ignored", { loadSession: true });
|
||||
const { sessionId } = await loadAcpSession(conn, {
|
||||
sessionId: "../../../root/.ssh/id_rsa",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
expect(sessionId).not.toContain("/");
|
||||
expect(sessionId).not.toContain("..");
|
||||
});
|
||||
});
|
||||
|
||||
113
plugins/fusion-plugin-acp-runtime/src/__tests__/sanitize.test.ts
Normal file
113
plugins/fusion-plugin-acp-runtime/src/__tests__/sanitize.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
stripControlSequences,
|
||||
boundString,
|
||||
boundIdentifier,
|
||||
DEFAULT_IDENTIFIER_MAX,
|
||||
TRUNCATION_MARKER,
|
||||
} from "../sanitize.js";
|
||||
|
||||
describe("stripControlSequences", () => {
|
||||
it("removes CSI/SGR ANSI color escapes", () => {
|
||||
const input = "\x1b[31mred\x1b[0m text";
|
||||
expect(stripControlSequences(input)).toBe("red text");
|
||||
});
|
||||
|
||||
it("removes OSC sequences (title-set injection)", () => {
|
||||
const input = "before\x1b]0;malicious title\x07after";
|
||||
expect(stripControlSequences(input)).toBe("beforeafter");
|
||||
});
|
||||
|
||||
it("removes bare ESC and cursor-move escapes", () => {
|
||||
const input = "a\x1b[2Jb\x1b[Hc";
|
||||
expect(stripControlSequences(input)).toBe("abc");
|
||||
});
|
||||
|
||||
it("drops C0/C1 control chars and DEL but keeps \\n and \\t", () => {
|
||||
const input = "line1\nline2\tend\x00\x07\x7f\x9b";
|
||||
expect(stripControlSequences(input)).toBe("line1\nline2\tend");
|
||||
});
|
||||
|
||||
it("returns empty string for non-string / empty input", () => {
|
||||
expect(stripControlSequences("")).toBe("");
|
||||
// @ts-expect-error intentionally wrong type
|
||||
expect(stripControlSequences(undefined)).toBe("");
|
||||
// @ts-expect-error intentionally wrong type
|
||||
expect(stripControlSequences(123)).toBe("");
|
||||
});
|
||||
|
||||
it("leaves clean printable text untouched", () => {
|
||||
expect(stripControlSequences("hello world 123 #$%")).toBe("hello world 123 #$%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundString", () => {
|
||||
it("returns input unchanged when within max", () => {
|
||||
expect(boundString("short", 100)).toBe("short");
|
||||
});
|
||||
|
||||
it("truncates and appends the marker when over max", () => {
|
||||
const out = boundString("a".repeat(100), 50);
|
||||
expect(out.length).toBe(50);
|
||||
expect(out.endsWith(TRUNCATION_MARKER)).toBe(true);
|
||||
});
|
||||
|
||||
it("never exceeds max length", () => {
|
||||
const out = boundString("x".repeat(1000), 20);
|
||||
expect(out.length).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
it("handles max <= marker length by hard slice", () => {
|
||||
const out = boundString("abcdefgh", 3);
|
||||
expect(out).toBe("abc");
|
||||
});
|
||||
|
||||
it("returns empty for non-positive max or empty/non-string input", () => {
|
||||
expect(boundString("abc", 0)).toBe("");
|
||||
expect(boundString("abc", -5)).toBe("");
|
||||
expect(boundString("", 10)).toBe("");
|
||||
// @ts-expect-error intentionally wrong type
|
||||
expect(boundString(undefined, 10)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundIdentifier", () => {
|
||||
it("replaces path separators so the id cannot escape into a path", () => {
|
||||
const out = boundIdentifier("../../etc/passwd");
|
||||
expect(out).not.toContain("/");
|
||||
expect(out).not.toContain("\\");
|
||||
expect(out).not.toContain("..");
|
||||
});
|
||||
|
||||
it("normalizes backslash separators and traversal", () => {
|
||||
const out = boundIdentifier("..\\..\\windows\\system32");
|
||||
expect(out).not.toContain("\\");
|
||||
expect(out).not.toContain("..");
|
||||
});
|
||||
|
||||
it("strips NUL bytes and control chars", () => {
|
||||
const out = boundIdentifier("sess\x00ion\x1b[31mid");
|
||||
expect(out).not.toContain("\x00");
|
||||
expect(out).not.toContain("\x1b");
|
||||
expect(out).toContain("session");
|
||||
});
|
||||
|
||||
it("bounds length to the default cap", () => {
|
||||
const out = boundIdentifier("s".repeat(10_000));
|
||||
expect(out.length).toBe(DEFAULT_IDENTIFIER_MAX);
|
||||
});
|
||||
|
||||
it("honors an explicit max", () => {
|
||||
expect(boundIdentifier("abcdefgh", 4)).toBe("abcd");
|
||||
});
|
||||
|
||||
it("passes a clean opaque id through unchanged", () => {
|
||||
expect(boundIdentifier("sess-1234-abcd")).toBe("sess-1234-abcd");
|
||||
});
|
||||
|
||||
it("returns empty for empty / non-string input", () => {
|
||||
expect(boundIdentifier("")).toBe("");
|
||||
// @ts-expect-error intentionally wrong type
|
||||
expect(boundIdentifier(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,29 @@ import type {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { AcpCallbacks } from "./types.js";
|
||||
import { toolDisplayName, normalizeToolArgs } from "./tool-mapping.js";
|
||||
import { stripControlSequences, boundString, boundIdentifier } from "./sanitize.js";
|
||||
|
||||
// --- U6 untrusted-input bounds (Risk S5) -----------------------------------
|
||||
//
|
||||
// The agent is untrusted input. The high inactivity ceiling (KTD4) does NOT
|
||||
// bound an *actively* flooding agent, so the bridge caps what it forwards.
|
||||
|
||||
/**
|
||||
* Per-turn cumulative cap (chars) on forwarded text+thinking. Once exceeded, the
|
||||
* bridge stops forwarding further text/thinking and emits ONE truncation flag.
|
||||
* Cleared by `reset()` at the start of each prompt turn. ~5M chars ≈ 5 MB.
|
||||
*/
|
||||
export const PER_TURN_OUTPUT_CAP_CHARS = 5_000_000;
|
||||
|
||||
/** Per-chunk cap (chars) applied to a single content chunk before forwarding. */
|
||||
export const PER_CHUNK_CAP_CHARS = 64_000;
|
||||
|
||||
/**
|
||||
* Max number of distinct `toolCallId`s tracked in the correlation map. A flooding
|
||||
* agent supplying unbounded unique ids must not grow the map without limit —
|
||||
* oldest entries are evicted once the cap is exceeded (bounded memory).
|
||||
*/
|
||||
export const TOOL_CALL_MAP_CAP = 1000;
|
||||
|
||||
/** Tracked metadata for an in-flight tool call, keyed by `toolCallId`. */
|
||||
interface TrackedToolCall {
|
||||
@@ -69,60 +92,122 @@ function normalizeStreamingDelta(previousText: string, nextDelta: string): strin
|
||||
function formatPlan(entries: PlanEntry[]): string {
|
||||
const lines = entries.map((entry) => {
|
||||
const status = typeof entry.status === "string" ? entry.status : "pending";
|
||||
const text = typeof entry.content === "string" ? entry.content : "";
|
||||
return `- [${status}] ${text}`;
|
||||
// Plan text is agent-supplied — sanitize control/ANSI before it reaches a
|
||||
// log/UI line (Risk S7) and bound its length (Risk S5).
|
||||
const rawText = typeof entry.content === "string" ? entry.content : "";
|
||||
const text = boundString(stripControlSequences(rawText), PER_CHUNK_CAP_CHARS);
|
||||
return `- [${stripControlSequences(status)}] ${text}`;
|
||||
});
|
||||
return `Plan:\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
export function createEventBridge(callbacks: AcpCallbacks): EventBridge {
|
||||
// Start/end correlation across `tool_call` → `tool_call_update`.
|
||||
// Start/end correlation across `tool_call` → `tool_call_update`. Insertion
|
||||
// order is preserved by Map, so the oldest key is the first iterator entry —
|
||||
// used for FIFO eviction once TOOL_CALL_MAP_CAP is exceeded (Risk S5).
|
||||
const toolCalls = new Map<string, TrackedToolCall>();
|
||||
// Running text/thinking accumulators for delta-space repair across chunks.
|
||||
let textSoFar = "";
|
||||
let thinkingSoFar = "";
|
||||
// Cumulative chars forwarded (text+thinking) this turn (Risk S5).
|
||||
let cumulativeOutputChars = 0;
|
||||
// Whether the per-turn cap was hit and the single flag line already emitted.
|
||||
let outputCapFlagged = false;
|
||||
|
||||
function reset(): void {
|
||||
toolCalls.clear();
|
||||
textSoFar = "";
|
||||
thinkingSoFar = "";
|
||||
cumulativeOutputChars = 0;
|
||||
outputCapFlagged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a bounded toolCallId for use as a Map key, evicting the oldest entry
|
||||
* when the cap is exceeded so a flood of unique ids cannot grow memory without
|
||||
* limit. Returns the normalized id, or `undefined` when the id is empty.
|
||||
*/
|
||||
function setTracked(rawId: string, tracked: TrackedToolCall): string | undefined {
|
||||
const id = boundIdentifier(rawId);
|
||||
if (id === "") return undefined;
|
||||
// Re-insert moves an existing key to the tail (refresh recency); for a new
|
||||
// key, evict the oldest first so size stays bounded.
|
||||
if (!toolCalls.has(id) && toolCalls.size >= TOOL_CALL_MAP_CAP) {
|
||||
const oldest = toolCalls.keys().next().value;
|
||||
if (oldest !== undefined) toolCalls.delete(oldest);
|
||||
}
|
||||
toolCalls.set(id, tracked);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward one sanitized + bounded delta through `emit`, honoring the per-turn
|
||||
* cumulative cap. Once the cap is exceeded, forwarding stops and a single
|
||||
* truncation flag line is emitted via `onThinking`.
|
||||
*/
|
||||
function forwardBounded(
|
||||
raw: string,
|
||||
prior: string,
|
||||
emit: (delta: string) => void,
|
||||
): string {
|
||||
if (outputCapFlagged) return prior;
|
||||
if (cumulativeOutputChars >= PER_TURN_OUTPUT_CAP_CHARS) {
|
||||
outputCapFlagged = true;
|
||||
callbacks.onThinking?.(
|
||||
"[output truncated: per-turn limit reached — further agent output suppressed]",
|
||||
);
|
||||
return prior;
|
||||
}
|
||||
// Sanitize control/ANSI (Risk S7) and bound the single chunk (Risk S5).
|
||||
const sanitized = boundString(stripControlSequences(raw), PER_CHUNK_CAP_CHARS);
|
||||
if (sanitized === "") return prior;
|
||||
const delta = normalizeStreamingDelta(prior, sanitized);
|
||||
cumulativeOutputChars += delta.length;
|
||||
emit(delta);
|
||||
return prior + delta;
|
||||
}
|
||||
|
||||
function emitText(content: ContentBlock | undefined): void {
|
||||
const raw = extractText(content);
|
||||
if (raw === undefined || raw === "") return;
|
||||
const delta = normalizeStreamingDelta(textSoFar, raw);
|
||||
textSoFar += delta;
|
||||
callbacks.onText?.(delta);
|
||||
textSoFar = forwardBounded(raw, textSoFar, (delta) => callbacks.onText?.(delta));
|
||||
}
|
||||
|
||||
function emitThinking(content: ContentBlock | undefined): void {
|
||||
const raw = extractText(content);
|
||||
if (raw === undefined || raw === "") return;
|
||||
const delta = normalizeStreamingDelta(thinkingSoFar, raw);
|
||||
thinkingSoFar += delta;
|
||||
callbacks.onThinking?.(delta);
|
||||
thinkingSoFar = forwardBounded(raw, thinkingSoFar, (delta) =>
|
||||
callbacks.onThinking?.(delta),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sanitize an agent-supplied tool title before it reaches a callback/log (S7). */
|
||||
function safeTitle(title: string | null | undefined): string | null | undefined {
|
||||
if (typeof title !== "string") return title;
|
||||
return boundString(stripControlSequences(title), PER_CHUNK_CAP_CHARS);
|
||||
}
|
||||
|
||||
function handleToolCall(update: Extract<SessionUpdate, { sessionUpdate: "tool_call" }>): void {
|
||||
const id = update.toolCallId;
|
||||
if (typeof id !== "string" || id === "") return;
|
||||
toolCalls.set(id, { title: update.title, kind: update.kind, ended: false });
|
||||
const name = toolDisplayName({ title: update.title, kind: update.kind });
|
||||
if (typeof update.toolCallId !== "string") return;
|
||||
const title = safeTitle(update.title);
|
||||
const id = setTracked(update.toolCallId, { title, kind: update.kind, ended: false });
|
||||
if (id === undefined) return;
|
||||
const name = toolDisplayName({ title, kind: update.kind });
|
||||
callbacks.onToolStart?.(name, normalizeToolArgs(update.rawInput));
|
||||
}
|
||||
|
||||
function handleToolCallUpdate(
|
||||
update: Extract<SessionUpdate, { sessionUpdate: "tool_call_update" }>,
|
||||
): void {
|
||||
const id = update.toolCallId;
|
||||
if (typeof id !== "string" || id === "") return;
|
||||
if (typeof update.toolCallId !== "string") return;
|
||||
const id = boundIdentifier(update.toolCallId);
|
||||
if (id === "") return;
|
||||
const tracked = toolCalls.get(id) ?? { ended: false };
|
||||
// Carry forward title/kind from the prior `tool_call` when this update omits
|
||||
// them (a partial update may only set status/output).
|
||||
if (update.title != null) tracked.title = update.title;
|
||||
if (update.title != null) tracked.title = safeTitle(update.title);
|
||||
if (update.kind != null) tracked.kind = update.kind;
|
||||
toolCalls.set(id, tracked);
|
||||
setTracked(update.toolCallId, tracked);
|
||||
|
||||
const status = update.status;
|
||||
if (status !== "completed" && status !== "failed") {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { spawnAgent, captureStderr, forceKill, unregisterProcess } from "./process-manager.js";
|
||||
import { createEventBridge } from "./event-bridge.js";
|
||||
import { resolvePermission } from "./control-handler.js";
|
||||
import { boundIdentifier } from "./sanitize.js";
|
||||
import type { AcpCallbacks, PermissionGate } from "./types.js";
|
||||
|
||||
/** Default bound for the `initialize` handshake. */
|
||||
@@ -317,7 +318,10 @@ export async function newAcpSession(
|
||||
opts: { cwd: string },
|
||||
): Promise<NewAcpSessionResult> {
|
||||
const res = await connection.conn.newSession({ cwd: opts.cwd, mcpServers: [] });
|
||||
return { sessionId: res.sessionId, modes: res.modes ?? undefined };
|
||||
// `sessionId` is agent-supplied/untrusted (U6/Risk S7): bound its length and
|
||||
// strip path separators / NUL bytes before it is stored on the session or
|
||||
// could ever touch a resume-file path.
|
||||
return { sessionId: boundIdentifier(res.sessionId), modes: res.modes ?? undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,12 +369,15 @@ export async function loadAcpSession(
|
||||
opts: { sessionId: string; cwd: string },
|
||||
): Promise<NewAcpSessionResult> {
|
||||
if (readsLoadSession(connection)) {
|
||||
// Bound the (agent-originated) resume id before it is used as a protocol /
|
||||
// potential path component (U6/Risk S7).
|
||||
const safeId = boundIdentifier(opts.sessionId);
|
||||
const res = await connection.conn.loadSession({
|
||||
sessionId: opts.sessionId,
|
||||
sessionId: safeId,
|
||||
cwd: opts.cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
return { sessionId: opts.sessionId, modes: res.modes ?? undefined };
|
||||
return { sessionId: safeId, modes: res.modes ?? undefined };
|
||||
}
|
||||
return newAcpSession(connection, { cwd: opts.cwd });
|
||||
}
|
||||
|
||||
80
plugins/fusion-plugin-acp-runtime/src/sanitize.ts
Normal file
80
plugins/fusion-plugin-acp-runtime/src/sanitize.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Untrusted-input sanitization helpers (U6 / Risk S7).
|
||||
//
|
||||
// Every string an ACP agent emits — text/thinking deltas, tool `title`, plan
|
||||
// text, `sessionId`, `toolCallId` — is untrusted input. Before any such string
|
||||
// reaches a Fusion callback, a log, the UI, or (worst) a filesystem path, it must
|
||||
// be neutralized:
|
||||
//
|
||||
// - `stripControlSequences` removes ANSI/OSC escapes and C0/C1 control chars so
|
||||
// a crafted string cannot inject terminal escapes / rewrite log lines.
|
||||
// - `boundString` truncates oversized content (Risk S5) with a visible marker.
|
||||
// - `boundIdentifier` bounds an agent-supplied id and strips path separators /
|
||||
// NUL bytes so the id can never be interpolated into a filesystem path
|
||||
// unsanitized.
|
||||
|
||||
/** Default cap for an agent-supplied identifier (sessionId, toolCallId). */
|
||||
export const DEFAULT_IDENTIFIER_MAX = 256;
|
||||
|
||||
/** Marker appended when `boundString` truncates its input. */
|
||||
export const TRUNCATION_MARKER = "…[truncated]";
|
||||
|
||||
// ANSI escape sequences:
|
||||
// CSI / SGR: ESC [ ... <final byte>
|
||||
// OSC: ESC ] ... (BEL | ST)
|
||||
// other ESC-prefixed two-char sequences (e.g. ESC ( B)
|
||||
const ANSI_PATTERN =
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]/g;
|
||||
|
||||
// Non-printable control chars to drop. C0 = \x00–\x1F, DEL = \x7F, C1 = \x80–\x9F.
|
||||
// We KEEP \n (\x0A) and \t (\x09) — they are legitimate whitespace in agent text.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARS_PATTERN = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
|
||||
|
||||
/**
|
||||
* Remove ANSI escape sequences (CSI/SGR/OSC) and non-printable C0/C1 control
|
||||
* characters from an untrusted string. Preserves `\n` and `\t`. Never throws —
|
||||
* a non-string input yields an empty string.
|
||||
*/
|
||||
export function stripControlSequences(text: string): string {
|
||||
if (typeof text !== "string" || text === "") return "";
|
||||
return text.replace(ANSI_PATTERN, "").replace(CONTROL_CHARS_PATTERN, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate `text` to at most `max` characters, appending a short truncation
|
||||
* marker when the input is cut. A non-positive `max` yields an empty string; a
|
||||
* non-string input yields an empty string. The returned string is never longer
|
||||
* than `max` (the marker replaces the tail of the budget, it is not added on
|
||||
* top).
|
||||
*/
|
||||
export function boundString(text: string, max: number): string {
|
||||
if (typeof text !== "string" || text === "") return "";
|
||||
if (!Number.isFinite(max) || max <= 0) return "";
|
||||
if (text.length <= max) return text;
|
||||
if (max <= TRUNCATION_MARKER.length) {
|
||||
return text.slice(0, max);
|
||||
}
|
||||
return text.slice(0, max - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound an agent-supplied identifier to a sane length and strip anything that
|
||||
* could let it escape into a filesystem path: path separators (`/`, `\`), NUL
|
||||
* bytes, control chars, and `..` traversal segments are removed. The result is
|
||||
* a flat, length-bounded token safe to use as a Map key or a single path
|
||||
* component. A non-string / empty input yields `""`.
|
||||
*/
|
||||
export function boundIdentifier(id: string, max: number = DEFAULT_IDENTIFIER_MAX): string {
|
||||
if (typeof id !== "string" || id === "") return "";
|
||||
const cap = Number.isFinite(max) && max > 0 ? max : DEFAULT_IDENTIFIER_MAX;
|
||||
// Drop ANSI/control first, then path-dangerous characters, then traversal.
|
||||
let cleaned = stripControlSequences(id)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x00/g, "")
|
||||
.replace(/[/\\]/g, "_");
|
||||
// Collapse any remaining `..` traversal tokens (after separators were removed
|
||||
// a `..` cannot point anywhere, but normalize it away for defense in depth).
|
||||
cleaned = cleaned.replace(/\.\.+/g, "_");
|
||||
return cleaned.slice(0, cap);
|
||||
}
|
||||
Reference in New Issue
Block a user