diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index b143ab4fa63..b8c1eb73cc9 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -2349,7 +2349,7 @@ fn safe_acpx_location(value: Option<&Value>, allow_create_target: bool) -> Value .unwrap_or_default(); if raw_path.is_empty() || raw_path.starts_with('/') - || raw_path.contains('\0') + || !crate::tool_file_changes::is_single_line_path(raw_path) || raw_path.split('/').any(|segment| segment == "..") { return Value::Null; @@ -2868,6 +2868,48 @@ mod tests { } } + #[test] + fn drops_a_location_whose_path_breaks_a_line() { + for location in [ + "packages/shared/src/chaos/slug.ts\nexport function chaosSlug() {}", + "src/a.ts\rsrc/b.ts", + "src/a.ts\u{2028}tail", + "src/a.ts\u{2029}tail", + "src/a\tb.ts", + ] { + assert_eq!( + safe_acpx_location( + Some(&json!({ + "path": location, + "pathBoundary": "paperclip.workspace_relative_display.v2", + "pathAttestation": "paperclip.workspace_create_target.v1" + })), + true, + ), + Value::Null, + "location {location:?}", + ); + } + let events = normalize_acpx_runtime_event( + AcpxRuntimeEventKind::ToolCall, + &json!({ + "type": "tool_call", "toolCallId": "toolu_write", "tag": "tool_call_update", + "status": "in_progress", "title": "Write", + "locations": [{ + "path": "packages/shared/src/chaos/slug.ts\nexport function chaosSlug() {}", + "pathBoundary": "paperclip.workspace_relative_display.v2" + }], + "fileChanges": [{"path": "src/a.ts\nsrc/b.ts", "operation": "modify"}], + }), + Some("edit"), + "fallback", + "turn-1", + 0, + ); + assert_eq!(events[0].payload["target"], Value::Null); + assert!(events[0].payload.get("fileChanges").is_none()); + } + #[test] fn preserves_valid_posix_display_characters() { for location in [ diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/tool_file_changes.rs b/packages/paperclip-runner/runner/crates/runner-core/src/tool_file_changes.rs index 4477ca7dd02..636c8c9c394 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/tool_file_changes.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/tool_file_changes.rs @@ -49,7 +49,16 @@ fn within_call_budget(mut files: Vec) -> Vec { static TURN_DIFF_REVISION: AtomicU64 = AtomicU64::new(0); +pub(crate) fn is_single_line_path(value: &str) -> bool { + !value + .chars() + .any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')) +} + fn normalize_relative(candidate: &str) -> Option { + if !is_single_line_path(candidate) { + return None; + } let mut parts: Vec<&str> = Vec::new(); for part in candidate.split('/') { match part { diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.test.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.test.ts index 605dc7cb10c..229be36cfdb 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.test.ts @@ -13,6 +13,28 @@ import { describe, expect, it } from "vitest"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; describe("ACPX sidecar locations", () => { + it("drops a path that breaks a line, so the runner never emits a target the controller rejects", () => { + const workspace = mkdtempSync(join(tmpdir(), "paperclip-acpx-locations-")); + try { + expect( + safeAcpxLocations( + [ + { path: 'packages/shared/src/slug.ts\nexport const a = 1;' }, + { path: "src/a.ts\rsrc/b.ts" }, + { path: "src/a.ts\u2028tail" }, + { path: "src/a.ts\u2029tail" }, + { path: "src/ok.ts" }, + ], + workspace, + "edit", + "Write", + ).map((location) => location.path), + ).toEqual(["src/ok.ts"]); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + it.runIf(sep === "/")( "preserves valid host-relative display names without admitting escape", () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts b/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts index 75897b6eb82..67fb705b9ac 100644 --- a/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts +++ b/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts @@ -42,7 +42,7 @@ export function safeAcpxLocations( return (locations ?? []).slice(0, 2_000).flatMap((location) => { const candidate = record(location); const rawPath = typeof candidate.path === "string" ? candidate.path : ""; - if (!rawPath || rawPath.includes("\0")) return []; + if (!rawPath || /[\p{Cc}\u2028\u2029]/u.test(rawPath)) return []; const absolute = isAbsolute(rawPath) ? resolve(rawPath) : resolve(cwd, rawPath);