From 54dfc1bc8e8265c426e21e26ff6a5e27682e20e5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 16:03:41 -0500 Subject: [PATCH] fix(runner): preserve Copilot evidence in the direct driver Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 3 +- .../src/cli/acpx-runtime-sidecar.ts | 2 +- .../src/cli/acpx-sidecar-locations.ts | 145 +----------------- .../drivers/acpx/codex-acpx-driver.test.ts | 29 +++- .../src/drivers/acpx/codex-acpx-driver.ts | 23 ++- .../acpx}/copilot-tool-evidence.test.ts | 4 +- .../acpx}/copilot-tool-evidence.ts | 6 +- .../acpx}/fixtures/copilot-tool-evidence.json | 0 .../src/drivers/acpx/safe-locations.ts | 139 +++++++++++++++++ .../copilot-protection-flow.test.ts | 4 +- 10 files changed, 202 insertions(+), 153 deletions(-) rename packages/paperclip-runner/src/{cli => drivers/acpx}/copilot-tool-evidence.test.ts (98%) rename packages/paperclip-runner/src/{cli => drivers/acpx}/copilot-tool-evidence.ts (97%) rename packages/paperclip-runner/src/{cli => drivers/acpx}/fixtures/copilot-tool-evidence.json (100%) create mode 100644 packages/paperclip-runner/src/drivers/acpx/safe-locations.ts diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index 47ea6c9414c..18c6740a9d4 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -754,7 +754,8 @@ model qualification. The profile remains pending. ### Bounded native tool observations and protection evaluations -The sidecar now projects an allowlist of active-turn ACP tool/permission fields into +The sidecar and direct TypeScript driver share one projector under `drivers/acpx`, +which projects an allowlist of active-turn ACP tool/permission fields into existing `provider.notice.recorded` details and provenance: validated relative target, request/tool identities, command SHA-256, explicit mode/detach, and linked shell start/completion with provider-reported exit code. Session passthrough notifications diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index c8d436639a8..5fead66cd9c 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -61,7 +61,7 @@ import { type AcpxSidecarResponse, } from "../drivers/acpx/sidecar-protocol.js"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; -import { createCopilotToolEvidence, type CopilotToolEvidence } from "./copilot-tool-evidence.js"; +import { createCopilotToolEvidence, type CopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js"; import { persistedAcpxTurnUsage, acpxUsageEstimateNotice, diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts index c0ee70d297d..784654c57e2 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-locations.ts @@ -1,139 +1,6 @@ -import { lstatSync, realpathSync } from "node:fs"; -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; - -import { classifyGeneratedAcpxToolOperation } from "../drivers/acpx/generated-sidecar-contract.js"; - -export const ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY = - "paperclip.workspace_relative_display.v2"; -export const ACPX_WORKSPACE_ENTRY_ATTESTATION = "paperclip.workspace_entry.v1"; -export const ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION = - "paperclip.workspace_create_target.v1"; -const RFC_URI_SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/u; -const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u; - -function record(value: unknown): Record { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function permitsCreateTarget(toolKind: unknown, toolTitle: unknown): boolean { - return classifyGeneratedAcpxToolOperation(toolKind, toolTitle) === "edit"; -} - -/** - * Converts provider paths to workspace-relative display targets using the - * sidecar host's path semantics. URI-scheme and Windows drive-shaped values - * are ambiguous on POSIX, so they require a real, in-workspace filesystem - * entry before the sidecar may attest them as filename data. Leading - * backslashes likewise require native-host attestation because they are rooted - * syntax on Windows but valid filename data on POSIX. Consumers must - * treat the result as display data, never as file-access authorization. - */ -export function safeAcpxLocations( - locations: readonly unknown[] | null | undefined, - workingDirectory: string | null | undefined, - toolKind?: unknown, - toolTitle?: unknown, -): Array> { - if (!workingDirectory) return []; - const cwd = resolve(workingDirectory); - let canonicalCwd: string | null | undefined; - 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 []; - const absolute = isAbsolute(rawPath) - ? resolve(rawPath) - : resolve(cwd, rawPath); - const local = relative(cwd, absolute); - if (!local || isAbsolute(local)) return []; - const portable = sep === "\\" ? local.replaceAll("\\", "/") : local; - if ( - portable.startsWith("/") || - portable.split("/").some((segment) => segment === "..") - ) { - return []; - } - // Classify the value we actually emit as well as the provider's spelling. - // Dot-relative and absolute paths can normalize to an ambiguous display - // target even though their raw forms did not begin with an ambiguous shape. - const requiresEntryAttestation = - rawPath.startsWith("\\") || - portable.startsWith("\\") || - WINDOWS_DRIVE_PREFIX.test(rawPath) || - RFC_URI_SCHEME_PREFIX.test(rawPath) || - WINDOWS_DRIVE_PREFIX.test(portable) || - RFC_URI_SCHEME_PREFIX.test(portable); - let pathAttestation: string | undefined; - if (requiresEntryAttestation) { - if (canonicalCwd === undefined) { - try { - canonicalCwd = realpathSync(cwd); - } catch { - canonicalCwd = null; - } - } - if (canonicalCwd === null) return []; - - let entryExists = true; - try { - lstatSync(absolute); - } catch (error) { - entryExists = false; - if ( - !permitsCreateTarget(toolKind, toolTitle) || - !( - error !== null && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) - ) { - return []; - } - } - - if (entryExists) { - try { - const canonicalEntry = realpathSync(absolute); - const entryRelative = relative(canonicalCwd, canonicalEntry); - if ( - !entryRelative || - isAbsolute(entryRelative) || - entryRelative.split(sep).some((segment) => segment === "..") - ) { - return []; - } - pathAttestation = ACPX_WORKSPACE_ENTRY_ATTESTATION; - } catch { - // An existing but unresolved entry includes dangling links. It must - // not be downgraded to a create target by an ENOENT from realpath. - return []; - } - } else { - try { - const canonicalParent = realpathSync(dirname(absolute)); - const parentRelative = relative(canonicalCwd, canonicalParent); - if ( - isAbsolute(parentRelative) || - parentRelative.split(sep).some((segment) => segment === "..") - ) { - return []; - } - pathAttestation = ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION; - } catch { - return []; - } - } - } - return [ - { - path: [...portable].slice(0, 4_000).join(""), - line: candidate.line ?? null, - pathBoundary: ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY, - ...(pathAttestation ? { pathAttestation } : {}), - }, - ]; - }); -} +export { + ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY, + ACPX_WORKSPACE_ENTRY_ATTESTATION, + ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION, + safeAcpxLocations, +} from "../drivers/acpx/safe-locations.js"; diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index 8bdc507d8a1..a4b0b75b408 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; import type { AcpRuntimeEvent } from "acpx/runtime"; @@ -1543,10 +1544,11 @@ describe("Codex ACPX harness driver", () => { const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!; const receipt = deferred(); const providerResponse = callback({ inferredKind: "execute", raw: { - sessionId: "agent-session-1", toolCall: { toolCallId: "receipt-tool", title: "Run validation" }, + sessionId: "backend-1", toolCall: { toolCallId: "receipt-tool", title: "Run validation", kind: "execute", rawInput: { command: "printf safe", mode: "async", detach: false } }, options: [{ optionId: "session", kind: "allow_always", name: "Allow for session" }], } } as Parameters[0], { signal: new AbortController().signal, responseDelivery: receipt.promise }); - await created; + const requested = await created; + expect(requested.filter(event => event.eventType === "provider.notice.recorded" && event.payload.category === "copilot_tool_evidence_v1").map(event => Object.fromEntries((event.payload.details as Array<{ name: string; value: string }>).map(x => [x.name, x.value])))).toEqual([expect.objectContaining({ stage: "permission_requested", toolCallId: "receipt-tool", mode: "async", detach: "false" })]); const request = session.pendingRuntimeRequests!()[0]!; expect(request.details).toMatchObject({ choices: [{ key: "accept_for_session" }, { key: "cancel" }] }); let acknowledged = false; @@ -1561,6 +1563,9 @@ describe("Codex ACPX harness driver", () => { receipt.reject(new Error("pipe failed")); await rejected; } const events = await emitted; + const evidence = events.filter(event => event.eventType === "provider.notice.recorded" && event.payload.category === "copilot_tool_evidence_v1" && (event.payload.details as Array<{ name: string; value: string }>).some(x => x.name === "stage" && x.value === "permission_delivered")); + expect(evidence).toHaveLength(outcome === "written" ? 1 : 0); + if (outcome === "written") expect(evidence[0]!.payload.details).toEqual(expect.arrayContaining([{ name: "stage", value: "permission_delivered" }, { name: "outcome", value: "allow_always" }])); expect(events.filter(event => event.eventType === "runtime_request.resolved")).toHaveLength(outcome === "written" ? 1 : 0); if (outcome === "failed") expect(events.at(-1)?.payload).toMatchObject({ replayAllowed: false, reason: "response_delivery_failed" }); expect(session.pendingRuntimeRequests!()).toHaveLength(0); @@ -1568,6 +1573,26 @@ describe("Codex ACPX harness driver", () => { await session.close({ reason: "receipt checked" }); }); + it.each(["copilot", "codex"] as const)("preserves pinned attached-shell evidence only on the Copilot direct driver: %s", async agent => { + const wire = JSON.parse(readFileSync(new URL("./fixtures/copilot-tool-evidence.json", import.meta.url), "utf8"))["attached-shell"]; + const runtimeEvents = wire.filter((frame: any) => frame.method === "session/update").map((frame: any) => ({ ...frame.params.update, type: "tool_call", tag: frame.params.update.sessionUpdate })) as AcpRuntimeEvent[]; + const fixture = driverFixture({ agent, providerPolicy: { readOnly: false } }, { runtimeEvents }); + const session = await fixture.driver.openSession({ runId: "run-attached", normalizedSessionId: "session-1", workingDirectory: "/fixture/workspace" }); + const completed = collectUntil(session.events(), "turn.completed"); + const { turnId } = await session.startTurn({ message: { text: "Start attached work." } }); + fixture.finishTurn({ status: "completed", stopReason: "end_turn" }); + const events = await completed; + const evidence = events.filter(event => event.eventType === "provider.notice.recorded" && event.payload.category === "copilot_tool_evidence_v1"); + if (agent === "copilot") { + expect(evidence.length).toBeGreaterThan(2); + expect(evidence.every(event => event.turnId === turnId && event.runId === "run-attached")).toBe(true); + const fields = evidence.map(event => Object.fromEntries((event.payload.details as Array<{ name: string; value: string }>).map(x => [x.name, x.value]))); + expect(fields).toEqual(expect.arrayContaining([expect.objectContaining({ operation: "execute", mode: "async", detach: "false", commandSha256: expect.stringMatching(/^sha256:/u) }), expect.objectContaining({ shellState: "started", shellId: "0" }), expect.objectContaining({ shellState: "completed", shellId: "0", exitCode: "0", commandToolCallId: expect.any(String) })])); + expect(JSON.stringify(evidence)).not.toContain("sleep 2"); + } else expect(evidence).toEqual([]); + await session.close({ reason: "projection verified" }); + }); + it("round-trips a provider-neutral ACP form through the runtime request boundary", async () => { const fixture = driverFixture(); const session = await fixture.driver.openSession({ diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 1c901df0691..7f8da29c6b2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1,3 +1,4 @@ +import { createCopilotToolEvidence, type CopilotToolEvidence } from "./copilot-tool-evidence.js"; import { requireAcpxResponseDelivery } from "./response-delivery.js"; import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "./profile-extensions.js"; import { createHash, randomBytes } from "node:crypto"; @@ -119,6 +120,7 @@ const QUARANTINED_HOST_ADMISSION_GRACE_MS = interface PendingAcpxRuntimeRequest { request: HarnessRuntimeRequest; responseDelivery: Promise; + deliveredEvidence?: () => void; prepareResolution(resolution: HarnessRuntimeRequestResolution): () => void; cancel(): void; cleanup(): void; @@ -896,6 +898,14 @@ class CodexAcpxSession implements HarnessSession { } }, }); + const toolEvidence = this.#agent === "copilot" ? createCopilotToolEvidence({ + sessionId: this.#host.identity().backendSessionId, turnId, workingDirectory: this.#input.workingDirectory, + active: () => this.#activeTurnId === turnId && !this.#closingStarted, + emit: event => { + validateAcpxRichEvent(event); + if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("Copilot activity could not be retained"); + }, + }) : undefined; let turn: AcpxRuntimeTurn; const usageBefore = await readUsageStatus(this.#host); try { @@ -907,7 +917,7 @@ class CodexAcpxSession implements HarnessSession { onExtensionRequest: extensions.onExtensionRequest, onExtensionNotification: extensions.onExtensionNotification, onPermissionRequest: (request, context) => - this.#handlePermission(turnId, request, context), + this.#handlePermission(turnId, request, context, toolEvidence), onElicitation: (request, context) => this.#handleElicitation(turnId, request, context), }); @@ -920,7 +930,7 @@ class CodexAcpxSession implements HarnessSession { ); throw error; } - const pump = this.#pumpTurn(turnId, turn, extensions.drain, usageBefore); + const pump = this.#pumpTurn(turnId, turn, extensions.drain, usageBefore, toolEvidence); this.#activePump = pump; void pump .finally(() => { @@ -1014,6 +1024,7 @@ class CodexAcpxSession implements HarnessSession { dispatched = true; deliver(); await pending.responseDelivery; + pending.deliveredEvidence?.(); this.#emit( "runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { @@ -1423,7 +1434,7 @@ class CodexAcpxSession implements HarnessSession { .catch(() => undefined); } - async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn, drainExtensions: () => Promise, usageBefore: unknown): Promise { + async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn, drainExtensions: () => Promise, usageBefore: unknown, toolEvidence?: CopilotToolEvidence): Promise { try { let index = 0; const normalizeToolEvent = @@ -1431,6 +1442,7 @@ class CodexAcpxSession implements HarnessSession { const normalizeMessage = this.#agent === "grok" ? createGrokMessageNormalizer() : (event: AcpRuntimeEvent) => event; for await (const event of turn.events) { + toolEvidence?.tool(event); this.#mapRuntimeEvent(normalizeMessage(normalizeToolEvent(event)), turnId, ++index); } const result = await turn.result; @@ -1709,6 +1721,7 @@ class CodexAcpxSession implements HarnessSession { turnId: string, request: AcpPermissionRequest, context: { signal: AbortSignal; responseDelivery?: Promise }, + toolEvidence?: CopilotToolEvidence, ): Promise { const { signal } = context; if (this.#closed || this.#activeTurnId !== turnId || signal.aborted @@ -1724,6 +1737,8 @@ class CodexAcpxSession implements HarnessSession { details: { choices: normalized.choices, toolCallId: normalized.toolCallId, kind: normalized.kind }, origin: { adapter: "acpx-runtime", provider: this.#agent, method: "session/request_permission" }, }; + const deliveredEvidence = toolEvidence?.permission(request, requestId, normalized.choices.map(choice => choice.key)); + let deliveredOutcome: AcpPermissionDecision["outcome"] | undefined; return await new Promise((settle) => { const cancel = () => { const pending = this.#pendingRuntimeRequests.get(requestId); @@ -1736,8 +1751,10 @@ class CodexAcpxSession implements HarnessSession { }; this.#pendingRuntimeRequests.set(requestId, { request: runtimeRequest, responseDelivery, + deliveredEvidence: () => { if (deliveredOutcome) deliveredEvidence?.(deliveredOutcome); }, prepareResolution: (resolution) => { const response = normalized.resolve(resolution); + deliveredOutcome = response.outcome; return () => settle(response); }, cancel: () => settle({ outcome: "cancel" }), diff --git a/packages/paperclip-runner/src/cli/copilot-tool-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts similarity index 98% rename from packages/paperclip-runner/src/cli/copilot-tool-evidence.test.ts rename to packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts index 09fbfe9f6d0..a421ffa73ca 100644 --- a/packages/paperclip-runner/src/cli/copilot-tool-evidence.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; -import { validateAcpxRichEvent } from "../drivers/acpx/profile-extensions.js"; -import type { CanonicalProviderEvent } from "../provider-events.js"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; import { createCopilotToolEvidence } from "./copilot-tool-evidence.js"; const fixture = JSON.parse(readFileSync(new URL("./fixtures/copilot-tool-evidence.json", import.meta.url), "utf8")); const details = (e: CanonicalProviderEvent) => Object.fromEntries((e.payload.details as Array<{ name: string; value: string }>).map(d => [d.name, d.value])); diff --git a/packages/paperclip-runner/src/cli/copilot-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts similarity index 97% rename from packages/paperclip-runner/src/cli/copilot-tool-evidence.ts rename to packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts index a544b0e5796..9e173dc5102 100644 --- a/packages/paperclip-runner/src/cli/copilot-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; -import { redactPaperclipSemanticValue } from "../semantic-tools/redaction.js"; -import type { CanonicalProviderEvent } from "../provider-events.js"; -import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; +import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; +import { safeAcpxLocations } from "./safe-locations.js"; const LIMIT = 256; const CATEGORY = "copilot_tool_evidence_v1"; diff --git a/packages/paperclip-runner/src/cli/fixtures/copilot-tool-evidence.json b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-tool-evidence.json similarity index 100% rename from packages/paperclip-runner/src/cli/fixtures/copilot-tool-evidence.json rename to packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-tool-evidence.json diff --git a/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts b/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts new file mode 100644 index 00000000000..75897b6eb82 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/safe-locations.ts @@ -0,0 +1,139 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +import { classifyGeneratedAcpxToolOperation } from "./generated-sidecar-contract.js"; + +export const ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY = + "paperclip.workspace_relative_display.v2"; +export const ACPX_WORKSPACE_ENTRY_ATTESTATION = "paperclip.workspace_entry.v1"; +export const ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION = + "paperclip.workspace_create_target.v1"; +const RFC_URI_SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/u; +const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u; + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function permitsCreateTarget(toolKind: unknown, toolTitle: unknown): boolean { + return classifyGeneratedAcpxToolOperation(toolKind, toolTitle) === "edit"; +} + +/** + * Converts provider paths to workspace-relative display targets using the + * sidecar host's path semantics. URI-scheme and Windows drive-shaped values + * are ambiguous on POSIX, so they require a real, in-workspace filesystem + * entry before the sidecar may attest them as filename data. Leading + * backslashes likewise require native-host attestation because they are rooted + * syntax on Windows but valid filename data on POSIX. Consumers must + * treat the result as display data, never as file-access authorization. + */ +export function safeAcpxLocations( + locations: readonly unknown[] | null | undefined, + workingDirectory: string | null | undefined, + toolKind?: unknown, + toolTitle?: unknown, +): Array> { + if (!workingDirectory) return []; + const cwd = resolve(workingDirectory); + let canonicalCwd: string | null | undefined; + 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 []; + const absolute = isAbsolute(rawPath) + ? resolve(rawPath) + : resolve(cwd, rawPath); + const local = relative(cwd, absolute); + if (!local || isAbsolute(local)) return []; + const portable = sep === "\\" ? local.replaceAll("\\", "/") : local; + if ( + portable.startsWith("/") || + portable.split("/").some((segment) => segment === "..") + ) { + return []; + } + // Classify the value we actually emit as well as the provider's spelling. + // Dot-relative and absolute paths can normalize to an ambiguous display + // target even though their raw forms did not begin with an ambiguous shape. + const requiresEntryAttestation = + rawPath.startsWith("\\") || + portable.startsWith("\\") || + WINDOWS_DRIVE_PREFIX.test(rawPath) || + RFC_URI_SCHEME_PREFIX.test(rawPath) || + WINDOWS_DRIVE_PREFIX.test(portable) || + RFC_URI_SCHEME_PREFIX.test(portable); + let pathAttestation: string | undefined; + if (requiresEntryAttestation) { + if (canonicalCwd === undefined) { + try { + canonicalCwd = realpathSync(cwd); + } catch { + canonicalCwd = null; + } + } + if (canonicalCwd === null) return []; + + let entryExists = true; + try { + lstatSync(absolute); + } catch (error) { + entryExists = false; + if ( + !permitsCreateTarget(toolKind, toolTitle) || + !( + error !== null && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) + ) { + return []; + } + } + + if (entryExists) { + try { + const canonicalEntry = realpathSync(absolute); + const entryRelative = relative(canonicalCwd, canonicalEntry); + if ( + !entryRelative || + isAbsolute(entryRelative) || + entryRelative.split(sep).some((segment) => segment === "..") + ) { + return []; + } + pathAttestation = ACPX_WORKSPACE_ENTRY_ATTESTATION; + } catch { + // An existing but unresolved entry includes dangling links. It must + // not be downgraded to a create target by an ENOENT from realpath. + return []; + } + } else { + try { + const canonicalParent = realpathSync(dirname(absolute)); + const parentRelative = relative(canonicalCwd, canonicalParent); + if ( + isAbsolute(parentRelative) || + parentRelative.split(sep).some((segment) => segment === "..") + ) { + return []; + } + pathAttestation = ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION; + } catch { + return []; + } + } + } + return [ + { + path: [...portable].slice(0, 4_000).join(""), + line: candidate.line ?? null, + pathBoundary: ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY, + ...(pathAttestation ? { pathAttestation } : {}), + }, + ]; + }); +} diff --git a/tests/runner-e2e/copilot-protection-flow.test.ts b/tests/runner-e2e/copilot-protection-flow.test.ts index fd3c1303d73..2ca4193cfc2 100644 --- a/tests/runner-e2e/copilot-protection-flow.test.ts +++ b/tests/runner-e2e/copilot-protection-flow.test.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { readFile, mkdtemp, rm, writeFile, unlink, rename, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createCopilotToolEvidence } from "../../packages/paperclip-runner/src/cli/copilot-tool-evidence.js"; +import { createCopilotToolEvidence } from "../../packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.js"; import { validateAcpxRichEvent } from "../../packages/paperclip-runner/src/drivers/acpx/profile-extensions.js"; import { copilotOrigin, readCopilotToolEvidence } from "./copilot-evidence.js"; import { gradeCopilotAttachedSettlement, gradeCopilotDeniedWrite } from "./copilot-protection-cases.js"; @@ -10,7 +10,7 @@ import { createAttachedCommandFixture, watchDeniedTarget, exists, isPerTurnRunPr import { runnerMatrix } from "./catalog.js"; import { selectRunnerExecutions, parseRunnerSelectors } from "./selectors.js"; -const fixture = JSON.parse(await readFile(new URL("../../packages/paperclip-runner/src/cli/fixtures/copilot-tool-evidence.json", import.meta.url), "utf8")); +const fixture = JSON.parse(await readFile(new URL("../../packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-tool-evidence.json", import.meta.url), "utf8")); function projected(name: string) { const frames = fixture[name], rows: any[] = []; let clock = 10; const projector = createCopilotToolEvidence({ sessionId: frames[0].params.sessionId, turnId: "turn", workingDirectory: "/fixture/workspace", active: () => true,