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:
gsxdsm
2026-06-03 09:10:31 -07:00
parent 7c8a3d4075
commit 0f3cec0f54
6 changed files with 495 additions and 20 deletions

View File

@@ -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);
});
});

View File

@@ -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("..");
});
});

View 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("");
});
});

View File

@@ -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") {

View File

@@ -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 });
}

View 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);
}