From 0f3cec0f54db2b290d216aafdd60cd2f01e2d74c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 09:10:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(acp):=20untrusted-input=20hardening=20?= =?UTF-8?q?=E2=80=94=20output=20bounds=20+=20sanitization=20(U6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/__tests__/event-bridge-bounds.test.ts | 147 ++++++++++++++++++ .../src/__tests__/provider-session.test.ts | 43 +++++ .../src/__tests__/sanitize.test.ts | 113 ++++++++++++++ .../src/event-bridge.ts | 119 ++++++++++++-- .../fusion-plugin-acp-runtime/src/provider.ts | 13 +- .../fusion-plugin-acp-runtime/src/sanitize.ts | 80 ++++++++++ 6 files changed, 495 insertions(+), 20 deletions(-) create mode 100644 plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts create mode 100644 plugins/fusion-plugin-acp-runtime/src/__tests__/sanitize.test.ts create mode 100644 plugins/fusion-plugin-acp-runtime/src/sanitize.ts diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts new file mode 100644 index 0000000000..796f0d30a2 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts @@ -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); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts index b16498a9d2..cfb82f46eb 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts @@ -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(".."); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/sanitize.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/sanitize.test.ts new file mode 100644 index 0000000000..f19202dcf6 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/sanitize.test.ts @@ -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(""); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts b/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts index f278adfaf0..9b5949eb50 100644 --- a/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts +++ b/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts @@ -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(); // 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): 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, ): 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") { diff --git a/plugins/fusion-plugin-acp-runtime/src/provider.ts b/plugins/fusion-plugin-acp-runtime/src/provider.ts index 7055d96454..655a3a9baf 100644 --- a/plugins/fusion-plugin-acp-runtime/src/provider.ts +++ b/plugins/fusion-plugin-acp-runtime/src/provider.ts @@ -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 { 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 { 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 }); } diff --git a/plugins/fusion-plugin-acp-runtime/src/sanitize.ts b/plugins/fusion-plugin-acp-runtime/src/sanitize.ts new file mode 100644 index 0000000000..75adfbe8b0 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/sanitize.ts @@ -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 [ ... +// 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); +}