mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(security): harden prompt path sanitization
This commit is contained in:
53
src/agents/sanitize-for-prompt.test.ts
Normal file
53
src/agents/sanitize-for-prompt.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
18
src/agents/sanitize-for-prompt.ts
Normal file
18
src/agents/sanitize-for-prompt.ts
Normal file
@@ -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, "");
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user