diff --git a/src/agents/sanitize-for-prompt.test.ts b/src/agents/sanitize-for-prompt.test.ts new file mode 100644 index 0000000000..b79a1250ba --- /dev/null +++ b/src/agents/sanitize-for-prompt.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; +import { buildAgentSystemPrompt } from "./system-prompt.js"; + +describe("sanitizeForPromptLiteral (OC-19 hardening)", () => { + it("strips ASCII control chars (CR/LF/NUL/tab)", () => { + expect(sanitizeForPromptLiteral("/tmp/a\nb\rc\x00d\te")).toBe("/tmp/abcde"); + }); + + it("strips Unicode line/paragraph separators", () => { + expect(sanitizeForPromptLiteral(`/tmp/a\u2028b\u2029c`)).toBe("/tmp/abc"); + }); + + it("strips Unicode format chars (bidi override)", () => { + // U+202E RIGHT-TO-LEFT OVERRIDE (Cf) can spoof rendered text. + expect(sanitizeForPromptLiteral(`/tmp/a\u202Eb`)).toBe("/tmp/ab"); + }); + + it("preserves ordinary Unicode + spaces", () => { + const value = "/tmp/my project/日本語-folder.v2"; + expect(sanitizeForPromptLiteral(value)).toBe(value); + }); +}); + +describe("buildAgentSystemPrompt uses sanitized workspace/sandbox strings", () => { + it("sanitizes workspaceDir (no newlines / separators)", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/project\nINJECT\u2028MORE", + }); + expect(prompt).toContain("Your working directory is: /tmp/projectINJECTMORE"); + expect(prompt).not.toContain("Your working directory is: /tmp/project\n"); + expect(prompt).not.toContain("\u2028"); + }); + + it("sanitizes sandbox workspace/mount/url strings", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/test", + sandboxInfo: { + enabled: true, + containerWorkspaceDir: "/work\u2029space", + workspaceDir: "/host\nspace", + workspaceAccess: "read-write", + agentWorkspaceMount: "/mnt\u2028mount", + browserNoVncUrl: "http://example.test/\nui", + }, + }); + expect(prompt).toContain("Sandbox container workdir: /workspace"); + expect(prompt).toContain("Sandbox host workspace: /hostspace"); + expect(prompt).toContain("(mounted at /mntmount)"); + expect(prompt).toContain("Sandbox browser observer (noVNC): http://example.test/ui"); + expect(prompt).not.toContain("\nui"); + }); +}); diff --git a/src/agents/sanitize-for-prompt.ts b/src/agents/sanitize-for-prompt.ts new file mode 100644 index 0000000000..7692cf306d --- /dev/null +++ b/src/agents/sanitize-for-prompt.ts @@ -0,0 +1,18 @@ +/** + * Sanitize untrusted strings before embedding them into an LLM prompt. + * + * Threat model (OC-19): attacker-controlled directory names (or other runtime strings) + * that contain newline/control characters can break prompt structure and inject + * arbitrary instructions. + * + * Strategy (Option 3 hardening): + * - Strip Unicode "control" (Cc) + "format" (Cf) characters (includes CR/LF/NUL, bidi marks, zero-width chars). + * - Strip explicit line/paragraph separators (Zl/Zp): U+2028/U+2029. + * + * Notes: + * - This is intentionally lossy; it trades edge-case path fidelity for prompt integrity. + * - If you need lossless representation, escape instead of stripping. + */ +export function sanitizeForPromptLiteral(value: string): string { + return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, ""); +} diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 21a176ac8c..f6b4c0625f 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -4,6 +4,7 @@ import type { ResolvedTimeFormat } from "./date-time.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; /** * Controls which hardcoded sections are included in the system prompt. @@ -355,13 +356,17 @@ export function buildAgentSystemPrompt(params: { const promptMode = params.promptMode ?? "full"; const isMinimal = promptMode === "minimal" || promptMode === "none"; const sandboxContainerWorkspace = params.sandboxInfo?.containerWorkspaceDir?.trim(); + const sanitizedWorkspaceDir = sanitizeForPromptLiteral(params.workspaceDir); + const sanitizedSandboxContainerWorkspace = sandboxContainerWorkspace + ? sanitizeForPromptLiteral(sandboxContainerWorkspace) + : ""; const displayWorkspaceDir = - params.sandboxInfo?.enabled && sandboxContainerWorkspace - ? sandboxContainerWorkspace - : params.workspaceDir; + params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace + ? sanitizedSandboxContainerWorkspace + : sanitizedWorkspaceDir; const workspaceGuidance = - params.sandboxInfo?.enabled && sandboxContainerWorkspace - ? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${params.workspaceDir}. Prefer relative paths so both sandboxed exec and file tools work consistently.` + params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace + ? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${sanitizedWorkspaceDir}. Prefer relative paths so both sandboxed exec and file tools work consistently.` : "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise."; const safetySection = [ "## Safety", @@ -480,21 +485,21 @@ export function buildAgentSystemPrompt(params: { "Some tools may be unavailable due to sandbox policy.", "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", params.sandboxInfo.containerWorkspaceDir - ? `Sandbox container workdir: ${params.sandboxInfo.containerWorkspaceDir}` + ? `Sandbox container workdir: ${sanitizeForPromptLiteral(params.sandboxInfo.containerWorkspaceDir)}` : "", params.sandboxInfo.workspaceDir - ? `Sandbox host workspace: ${params.sandboxInfo.workspaceDir}` + ? `Sandbox host workspace: ${sanitizeForPromptLiteral(params.sandboxInfo.workspaceDir)}` : "", params.sandboxInfo.workspaceAccess ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ params.sandboxInfo.agentWorkspaceMount - ? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})` + ? ` (mounted at ${sanitizeForPromptLiteral(params.sandboxInfo.agentWorkspaceMount)})` : "" }` : "", params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "", params.sandboxInfo.browserNoVncUrl - ? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}` + ? `Sandbox browser observer (noVNC): ${sanitizeForPromptLiteral(params.sandboxInfo.browserNoVncUrl)}` : "", params.sandboxInfo.hostBrowserAllowed === true ? "Host browser control: allowed." diff --git a/src/agents/workspace-run.ts b/src/agents/workspace-run.ts index 1061a0344e..8ba281c485 100644 --- a/src/agents/workspace-run.ts +++ b/src/agents/workspace-run.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; +import { logWarn } from "../logger.js"; import { redactIdentifier } from "../logging/redact-identifier.js"; import { classifySessionKeyShape, @@ -8,6 +9,7 @@ import { } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; +import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; export type WorkspaceFallbackReason = "missing" | "blank" | "invalid_type"; type AgentIdSource = "explicit" | "session_key" | "default"; @@ -84,8 +86,12 @@ export function resolveRunWorkspaceDir(params: { if (typeof requested === "string") { const trimmed = requested.trim(); if (trimmed) { + const sanitized = sanitizeForPromptLiteral(trimmed); + if (sanitized !== trimmed) { + logWarn("Control/format characters stripped from workspaceDir (OC-19 hardening)."); + } return { - workspaceDir: resolveUserPath(trimmed), + workspaceDir: resolveUserPath(sanitized), usedFallback: false, agentId, agentIdSource, @@ -96,8 +102,12 @@ export function resolveRunWorkspaceDir(params: { const fallbackReason: WorkspaceFallbackReason = requested == null ? "missing" : typeof requested === "string" ? "blank" : "invalid_type"; const fallbackWorkspace = resolveAgentWorkspaceDir(params.config ?? {}, agentId); + const sanitizedFallback = sanitizeForPromptLiteral(fallbackWorkspace); + if (sanitizedFallback !== fallbackWorkspace) { + logWarn("Control/format characters stripped from fallback workspaceDir (OC-19 hardening)."); + } return { - workspaceDir: resolveUserPath(fallbackWorkspace), + workspaceDir: resolveUserPath(sanitizedFallback), usedFallback: true, fallbackReason, agentId,