OpenClaw: Unsanitized CWD path injection into LLM prompts
Description
OpenClaw is a personal AI assistant. Prior to version 2026.2.15, OpenClaw embedded the current working directory (workspace path) into the agent system prompt without sanitization. If an attacker can cause OpenClaw to run inside a directory whose name contains control/format characters (for example newlines or Unicode bidi/zero-width markers), those characters could break the prompt structure and inject attacker-controlled instructions. Starting in version 2026.2.15, the workspace path is sanitized before it is embedded into any LLM prompt output, stripping Unicode control/format characters and explicit line/paragraph separators. Workspace path resolution also applies the same sanitization as defense-in-depth.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.15 | 2026.2.15 |
Affected products
1Patches
16254e96acf16fix(security): harden prompt path sanitization
4 files changed · +97 −11
src/agents/sanitize-for-prompt.test.ts+53 −0 added@@ -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"); + }); +});
src/agents/sanitize-for-prompt.ts+18 −0 added@@ -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, ""); +}
src/agents/system-prompt.ts+14 −9 modified@@ -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."
src/agents/workspace-run.ts+12 −2 modified@@ -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,
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-2qj5-gwg2-xwc4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27001ghsaADVISORY
- github.com/openclaw/openclaw/commit/6254e96acf16e70ceccc8f9b2abecee44d606f79ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.15ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-2qj5-gwg2-xwc4ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.