diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index 2dfa6252f28..eceb2de84de 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -1,6 +1,33 @@ # Copilot 1.0.88 rich ACP capability audit -Current qualification checkpoint (2026-09-30): **Copilot profile v8 remains unqualified.** The a8df warm attempt passed all nine Product matchers and daemon invariants, but the supervisor failed final-pack cleanup on an extra executable; the exact orphan was later retired safely and the original failure remains. The earlier 5c69 warm-PID failure is a separate unchanged historical grade. Sidecar rotation per `attach_run` is ACPX authority rotation, separate from daemon continuity. The Daytona denial observed no write but failed its original cancellation fixture after receiving normal provider completion; later checks were not graded. Its database timestamp does not establish that completion was durably observable before Stop, so the earlier ordering inference is withdrawn. +Current source candidate (2026-09-30): **Copilot profile v9 is unqualified**. +Typed edit permission callbacks now put a single bounded workspace-relative target +in the first canonical prompt (`Change file: path`). The same pure projector is +used by both runner paths and the bounded diagnostic notice. It checks `path`, +`fileName`, and every supplied location for agreement; it never displays file +contents, diffs, commands, or arbitrary raw input. This is display context, not a +filesystem authorization or symlink-containment guarantee. + +If the target is missing, outside the workspace, conflicting, malformed, too +large, contains control/direction-format characters, or would be redacted, the +request offers only the provider's one-time denial (when present) and cancellation. +Forged once/session grants are rejected. Raw `kind: edit` is required; titles do +not infer operation type. Non-edit permissions retain their existing behavior. +Default/full-auto selection is unchanged: callbacks handled by Paperclip receive +this check before any permission-mode fallback; no new automatic grant is added. +The existing transport policy still applies before permission handling. + +The declaration binds this context contract and its source hashes. Copilot's +native executable, patched inner distribution, and shared ACPX patch are unchanged; +the sidecar assets and runner's embedded admission digest change. Older profile +identities cannot be admitted as v9 or reused as matching warm sessions. Fresh +runtime packs/images and exact-revision local/Daytona qualification are pending. +Earlier denial cases establish their own no-write behavior; their generic +`Create file` card did not prove complete decision context. Deterministic adapter, +sidecar and direct-driver tests cover safe and denied context; no new live claim +or native USD/accounting capability is implied. + +Historical v8 qualification checkpoint (2026-09-30): **Copilot profile v8 remains unqualified.** The a8df warm attempt passed all nine Product matchers and daemon invariants, but the supervisor failed final-pack cleanup on an extra executable; the exact orphan was later retired safely and the original failure remains. The earlier 5c69 warm-PID failure is a separate unchanged historical grade. Sidecar rotation per `attach_run` is ACPX authority rotation, separate from daemon continuity. The Daytona denial observed no write but failed its original cancellation fixture after receiving normal provider completion; later checks were not graded. Its database timestamp does not establish that completion was durably observable before Stop, so the earlier ordering inference is withdrawn. Shutdown ownership [#14730](https://github.com/paperclipai/paperclip/pull/14730), head `d09ed62b6`, has Apex 5/5 and all 52 checks green. Denial settlement [#14733](https://github.com/paperclipai/paperclip/pull/14733), head `1b47b30be`, passes 220 focused tests and E2E typecheck; fresh review and CI are pending. Suite v4 requires a retained pre-dispatch API observation for the normal-completion branch. Neither accepted branch proves Stop reached active work. Neither change has a live qualification pass; final-source verification remains pending. V8 binds the shared ACPX patch under a new digest, rejects v7 sessions, and adds no usage/cost capability. Native per-run USD is unknown. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 1e1001ca368..c2eecce910a 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -223,7 +223,7 @@ impl AcpxProviderDescriptor { "1.0.88", None, None, - "sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a", + "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6", ), "grok" => ( "grok-4.7", @@ -2812,7 +2812,7 @@ mod tests { "copilot", "@github/copilot", "1.0.88", - "sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a", + "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6", None, None, "explicit-model", diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs index b9dc6bf9017..91445c06514 100644 --- a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; -const PROFILE_DIGEST = "sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a"; +const PROFILE_DIGEST = "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6"; /** Build-time only; outputRoot is the exact runner-owned platform asset directory. */ export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) { diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index 2a222bc706c..858be8e5115 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -188,6 +188,38 @@ describe("qualified ACPX runtime sidecar", () => { await expect(pending).resolves.toEqual({ outcome: "cancel" }); expect(permissions.size).toBe(0); }); + it.each(["safe", "missing", "conflicting", "outside"])("puts Copilot target context in the first permission frame (%s)", async scenario => { + const safe = scenario === "safe"; + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(" const { signal } = context;", source.indexOf("async function waitForPermission")); + const end = source.indexOf("\nasync function waitForInput", start); + const permissions = new Map }>(); + const emitted: Array<{ title: string; choices: Array<{ key: string }> }> = []; + const wait = new Function("permissions", "openParams", "normalizeAcpxPermission", "emit", + `let turnId = "turn-1", requestSequence = 0; const MAX_PENDING_INPUTS = 512; + const stableRequestId = () => "request-1"; const requireAcpxResponseDelivery = c => c.responseDelivery; + return async function(activeTurnId, agent, request, context, toolEvidence) { ${source.slice(start, end)}`)( + permissions, { agent: "copilot", workingDirectory: "/workspace" }, normalizeAcpxPermission, + (_event: string, payload: typeof emitted[number]) => emitted.push(payload), + ); + const abort = new AbortController(); + const pending = wait("turn-1", "copilot", { inferredKind: "edit", raw: { + sessionId: "native-session", toolCall: { toolCallId: "call", title: "Create file", kind: "edit", + rawInput: { ...(scenario === "missing" ? {} : { path: scenario === "outside" ? "../outside.txt" : "/workspace/new.txt" }), + ...(scenario === "conflicting" ? { fileName: "other.txt" } : {}), content: "PRIVATE_CONTENT" } }, + options: ["allow_once", "allow_always", "reject_once"].map(kind => ({ kind, optionId: kind, name: kind })), + } }, { signal: abort.signal, responseDelivery: Promise.resolve() }); + expect(emitted).toHaveLength(1); + expect(emitted[0]!.title).toBe(safe ? "Change file: new.txt" : "File change requested; target unavailable. Deny or cancel this request."); + expect(emitted[0]!.choices.map(x => x.key)).toEqual(safe + ? ["accept", "accept_for_session", "decline", "cancel"] : ["decline", "cancel"]); + expect(JSON.stringify(emitted)).not.toContain("PRIVATE_CONTENT"); + if (!safe) for (const action of ["accept", "accept_for_session"] as const) { + expect(() => permissions.get("request-1")!.normalized.resolve({ action })).toThrow("offered choice"); + } + abort.abort(); await expect(pending).resolves.toEqual({ outcome: "cancel" }); + expect(permissions.size).toBe(0); + }); it("returns the sidecar retirement flag only after cancellation settlement and rejects stale turns", async () => { const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); const start = source.indexOf(' const expected = boundedIdentity', source.indexOf('if (request.command === "turn.cancel")')); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 77939901c53..b8cf2cc7fb6 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -778,7 +778,10 @@ async function waitForPermission( if (turnId !== activeTurnId || signal.aborted || permissions.size >= MAX_PENDING_INPUTS) { return { outcome: "cancel" }; } - const normalized = normalizeAcpxPermission(request, ["pi", "copilot"].includes(agent) ? { allowAlwaysScope: "session" } : {}); + const normalized = normalizeAcpxPermission(request, { + provider: agent, workingDirectory: openParams?.workingDirectory, + ...(["pi", "copilot"].includes(agent) ? { allowAlwaysScope: "session" } : {}), + }); const responseDelivery = requireAcpxResponseDelivery(context); const requestId = stableRequestId(activeTurnId, ++requestSequence, normalized.toolCallId); return await new Promise((settle) => { diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts index 07fdbc4c3ed..9f99cac536f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts @@ -43,3 +43,61 @@ describe("ACP permission normalization", () => { expect(() => normalizeAcpxPermission(duplicate)).toThrow("ambiguous"); }); }); + + +describe("Copilot canonical file permission context", () => { + const context = { provider: "copilot", workingDirectory: "/fixture/workspace", allowAlwaysScope: "session" as const }; + function edit(call: Record = {}) { + const value = request(); + Object.assign(value.raw.toolCall, { kind: "edit", title: "Create file", rawInput: { path: "/fixture/workspace/new.txt", content: "PRIVATE_CONTENT" }, locations: [{ path: "new.txt" }], ...call }); + return value; + } + it("puts only the safe target in the first canonical prompt and keeps offered grants", () => { + const normalized = normalizeAcpxPermission(edit(), context); + expect(normalized.title).toBe("Change file: new.txt"); + expect(JSON.stringify(normalized)).not.toContain("PRIVATE_CONTENT"); + expect(normalized.resolve({ action: "accept" })).toEqual({ outcome: "allow_once" }); + expect(normalized.resolve({ action: "accept_for_session" })).toEqual({ outcome: "allow_always" }); + expect(normalized.resolve({ action: "decline" })).toEqual({ outcome: "reject_once" }); + }); + it.each([ + { rawInput: {}, locations: [] }, + { rawInput: { path: "../outside.txt" }, locations: [] }, + { rawInput: { path: " new.txt" }, locations: [] }, + { rawInput: { path: "new.txt " }, locations: [] }, + { rawInput: { path: "/fixture/workspace/new.txt " }, locations: [] }, + { rawInput: { path: "/elsewhere/file" }, locations: [] }, + { rawInput: { path: "new.txt", fileName: "other.txt" }, locations: [] }, + { rawInput: { path: "new.txt" }, locations: [{ path: "other.txt" }] }, + { rawInput: { path: "new.txt" }, locations: [{}] }, + { rawInput: { path: "new.txt" }, locations: "invalid" }, + { rawInput: { path: "bad\nname" }, locations: [] }, + { rawInput: { path: "safe\u202Etxt.exe" }, locations: [] }, + { rawInput: { path: "safe\u2028other" }, locations: [] }, + { rawInput: { path: "safe\u0085other" }, locations: [] }, + { rawInput: { path: "a".repeat(1025) }, locations: [] }, + { rawInput: { path: "new.txt" }, locations: Array.from({ length: 17 }, () => ({ path: "new.txt" })) }, + { rawInput: { path: "https://private.example/file" }, locations: [] }, + { rawInput: { path: "secret?token=private-value" }, locations: [] }, + ])("offers no grant for absent, unsafe or conflicting context: %j", call => { + const normalized = normalizeAcpxPermission(edit(call), context); + expect(normalized.title).toContain("target unavailable"); + expect(normalized.choices.map(choice => choice.key)).toEqual(["decline", "cancel"]); + for (const action of ["accept", "accept_for_session"] as const) expect(() => normalized.resolve({ action })).toThrow("not an offered choice"); + expect(normalized.resolve({ action: "decline" })).toEqual({ outcome: "reject_once" }); + expect(normalized.resolve({ action: "cancel" })).toEqual({ outcome: "cancel" }); + }); + it("requires the bound workspace and does not infer edits from title/inferredKind", () => { + expect(normalizeAcpxPermission(edit(), { provider: "copilot" }).choices.map(x => x.key)).toEqual(["decline", "cancel"]); + for (const kind of [undefined, "execute", "read"]) { + const normalized = normalizeAcpxPermission(edit({ kind }), context); + expect(normalized.title).toBe("Create file"); + expect(normalized.resolve({ action: "accept" })).toEqual({ outcome: "allow_once" }); + } + expect(normalizeAcpxPermission(edit(), { ...context, provider: "pi" }).title).toBe("Create file"); + }); + it("does not invent a grant when context is valid but the provider did not offer one", () => { + const value = edit(); value.raw.options = request(["reject_once"]).raw.options; + expect(normalizeAcpxPermission(value, context).choices.map(x => x.key)).toEqual(["decline", "cancel"]); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts index 6eac1f3c6ec..30b1c362d64 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts @@ -1,6 +1,8 @@ import type { AcpPermissionDecision, AcpPermissionRequest } from "acpx/runtime"; import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js"; +import { safeCopilotEditTarget } from "./copilot-permission-context.js"; + export type AcpxPermissionAction = "accept" | "accept_for_session" | "decline" | "cancel"; export interface NormalizedAcpxPermission { title: string; @@ -11,7 +13,7 @@ export interface NormalizedAcpxPermission { } /** Provider options describe choices, never authority to execute an operation. */ -export function normalizeAcpxPermission(request: AcpPermissionRequest, options: { allowAlwaysScope?: "session" } = {}): NormalizedAcpxPermission { +export function normalizeAcpxPermission(request: AcpPermissionRequest, options: { allowAlwaysScope?: "session"; provider?: string; workingDirectory?: string } = {}): NormalizedAcpxPermission { const raw = request.raw; if (!raw || !Array.isArray(raw.options) || raw.options.length > 32) { throw new Error("ACP permission request has invalid options"); @@ -29,6 +31,12 @@ export function normalizeAcpxPermission(request: AcpPermissionRequest, options: ids.add(option.optionId); byKind.set(option.kind, { optionId: option.optionId, name: option.name }); } + const call = raw.toolCall; + if (!call || typeof call.toolCallId !== "string" || !call.toolCallId || call.toolCallId.length > 240) { + throw new Error("ACP permission request omitted its tool identity"); + } + const needsEditContext = options.provider === "copilot" && call.kind === "edit"; + const target = needsEditContext ? safeCopilotEditTarget(call, options.workingDirectory) : undefined; const bindings = new Map(); const choices: NormalizedAcpxPermission["choices"] = []; for (const [key, kind, label] of [ @@ -36,18 +44,17 @@ export function normalizeAcpxPermission(request: AcpPermissionRequest, options: ["accept_for_session", "allow_always", "Allow for session"], ["decline", "reject_once", "Deny"], ] as const) { + if (needsEditContext && !target && (key === "accept" || key === "accept_for_session")) continue; if (kind === "allow_always" && options.allowAlwaysScope !== "session") continue; if (byKind.has(kind)) { bindings.set(key, kind); choices.push({ key, label }); } } // A permanent rejection is not silently substituted for a one-time denial. bindings.set("cancel", "cancel"); choices.push({ key: "cancel", label: "Cancel" }); - const call = raw.toolCall; - if (!call || typeof call.toolCallId !== "string" || !call.toolCallId || call.toolCallId.length > 240) { - throw new Error("ACP permission request omitted its tool identity"); - } return { - title: typeof call.title === "string" && call.title.trim() + title: needsEditContext + ? target ? `Change file: ${target}` : "File change requested; target unavailable. Deny or cancel this request." + : typeof call.title === "string" && call.title.trim() ? call.title.slice(0, 4_000) : "Approve provider operation", kind: request.inferredKind ?? "other", toolCallId: call.toolCallId, 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 e7923590190..c569127182c 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 @@ -1651,6 +1651,42 @@ describe("Codex ACPX harness driver", () => { await session.close({ reason: "permission verified" }); }); + it.each(["safe", "missing", "conflicting", "outside"].flatMap(scenario => + ([undefined, "approve-all", "approve-reads"] as const).map(permissionMode => ({ scenario, permissionMode })), + ))("binds Copilot edit context before publishing any actionable request ($scenario, $permissionMode)", async ({ scenario, permissionMode }) => { + const safe = scenario === "safe"; + const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: false }, permissionMode }); + const session = await fixture.driver.openSession({ runId: "run-target", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + expect(fixture.hostOptions?.permissionMode).toBe(permissionMode ?? "approve-all"); + const created = collectUntil(session.events(), "runtime_request.created"); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Request an edit." } }); + const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!; + const response = callback({ inferredKind: "edit", raw: { + sessionId: "backend-1", toolCall: { toolCallId: "edit-target", title: "Create file", kind: "edit", + rawInput: { ...(scenario === "missing" ? {} : { path: scenario === "outside" ? "../outside.txt" : "/workspace/new.txt" }), + ...(scenario === "conflicting" ? { fileName: "other.txt" } : {}), content: "PRIVATE_CONTENT" } }, + options: ["allow_once", "allow_always", "reject_once"].map(kind => ({ optionId: kind, kind, name: kind })), + } } as Parameters[0], { signal: new AbortController().signal, responseDelivery: Promise.resolve() }); + const events = await created; + const request = session.pendingRuntimeRequests!()[0]!; + const prompt = safe ? "Change file: new.txt" : "File change requested; target unavailable. Deny or cancel this request."; + expect(request.prompt).toBe(prompt); + expect(events.at(-1)?.payload).toMatchObject({ request: { prompt, type: "permission" } }); + expect(JSON.stringify(events)).not.toContain("PRIVATE_CONTENT"); + if (!safe) { + expect(request.details).toMatchObject({ choices: [{ key: "decline" }, { key: "cancel" }] }); + for (const action of ["accept", "accept_for_session"] as const) { + await expect(session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action } })).rejects.toThrow("offered choice"); + } + expect(session.pendingRuntimeRequests!()).toHaveLength(1); + } + await session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: safe ? "accept" : "decline" } }); + await expect(response).resolves.toEqual({ outcome: safe ? "allow_once" : "reject_once" }); + fixture.finishTurn({ status: "completed", stopReason: "end_turn" }); + await collectUntil(session.events(), "turn.completed"); + await session.close({ reason: "target context checked" }); + }); + it.each(["written", "failed"] as const)("waits for the exact provider permission reply receipt: %s", async outcome => { const fixture = driverFixture({ agent: "copilot", model: "explicit-test-model", providerPolicy: { readOnly: false } }); const session = await fixture.driver.openSession({ runId: "run-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); 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 6d1421ec708..601508a7112 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1763,7 +1763,10 @@ class CodexAcpxSession implements HarnessSession { return { outcome: "cancel" }; } const responseDelivery = requireAcpxResponseDelivery(context); - const normalized = normalizeAcpxPermission(request, ["pi", "copilot"].includes(this.#agent) ? { allowAlwaysScope: "session" } : {}); + const normalized = normalizeAcpxPermission(request, { + provider: this.#agent, workingDirectory: this.#input.workingDirectory, + ...(["pi", "copilot"].includes(this.#agent) ? { allowAlwaysScope: "session" } : {}), + }); const requestId = stableId("acpx-permission", `${turnId}:${++this.#runtimeRequestSequence}:${normalized.toolCallId}`); const runtimeRequest: HarnessRuntimeRequest = { requestId, requestKind: "permission_approval", method: "session/request_permission", diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 658e4bb2cef..34e0b8de571 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -5,15 +5,17 @@ import { describe, expect, it, vi } from "vitest"; import { verifyNativeAcpxInstallation } from "./installation-integrity.js"; import { COPILOT_CLOSURE_SHA256, verifyCopilotInstallation } from "./copilot-installation.js"; import { COPILOT_LAUNCH_ARGUMENTS, COPILOT_SYSTEM_INSTRUCTION_DELIVERY } from "./copilot-profile.js"; +import { COPILOT_PERMISSION_CONTEXT_CONTRACT } from "./copilot-permission-context.js"; import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js"; vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { - it("admits the current v8 declaration and binds its source policy/patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v8-identity.json", import.meta.url), "utf8")); + it("admits the current v9 declaration and binds its source policy/patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v9-identity.json", import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); + expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); expect(identity.declaration.messageIdentityContract).toBe("copilot-native-message-id-v1"); expect(identity.declaration.ownedDistributionDelivery).toBe("COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1"); const inventory = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-message-identity-distributions-1.0.88.json", import.meta.url), "utf8")); @@ -23,7 +25,7 @@ describe("Copilot build-owned installation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe(identity.commandDigest); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).toBe(identity.commandDigest); - for (const [relative, field] of [["copilot-policy.ts", "policySha256"], ["../../../scripts/materialize-copilot-binary.mjs", "distributionSourceSha256"], ["../../../scripts/copilot-inner-distribution.mjs", "innerDistributionSourceSha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) { + for (const [relative, field] of [["copilot-permission-context.ts", "permissionContextSourceSha256"], ["acp-permission-adapter.ts", "permissionAdapterSourceSha256"], ["copilot-policy.ts", "policySha256"], ["../../../scripts/materialize-copilot-binary.mjs", "distributionSourceSha256"], ["../../../scripts/copilot-inner-distribution.mjs", "innerDistributionSourceSha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) { expect(createHash("sha256").update(readFileSync(new URL(relative!, import.meta.url))).digest("hex")).toBe(identity.declaration[field!]); } expect(readFileSync(new URL("../../../scripts/build-copilot-distribution.mjs", import.meta.url), "utf8")).toContain(identity.commandDigest); @@ -64,7 +66,7 @@ describe("Copilot build-owned installation", () => { }); it("rejects changed profile identities before native file access", async () => { vi.mocked(verifyNativeAcpxInstallation).mockClear(); - for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }]) { + for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 3 }, { agentProfileVersion: 4 }, { agentProfileVersion: 5 }, { agentProfileVersion: 6 }, { agentProfileVersion: 7 }, { agentProfileVersion: 8 }]) { await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned"); } expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts new file mode 100644 index 00000000000..d0a24575e06 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-permission-context.ts @@ -0,0 +1,22 @@ +import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; +import { safeAcpxLocations } from "./safe-locations.js"; + +export const COPILOT_PERMISSION_CONTEXT_CONTRACT = "copilot-edit-permission-context-v1"; +const record = (value: unknown): Record => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + +/** Display context only; this does not authorize filesystem access. */ +export function safeCopilotEditTarget(value: unknown, workingDirectory: string | undefined): string | undefined { + const call = record(value); + if (call.kind !== "edit" || !workingDirectory) return; + const input = record(call.rawInput); + if (call.locations !== undefined && !Array.isArray(call.locations)) return; + if (Array.isArray(call.locations) && (call.locations.length > 16 || call.locations.some(x => typeof record(x).path !== "string"))) return; + const paths = [input.path, input.fileName, ...(Array.isArray(call.locations) ? call.locations.map(x => record(x).path) : [])].filter(x => x !== undefined); + if (!paths.length || paths.some(x => typeof x !== "string" || !x || x.trim() !== x || x.length > 2048 || /[\\\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(x))) return; + const safe = paths.flatMap(path => safeAcpxLocations([{ path }], workingDirectory, call.kind, call.title)); + const names = safe.map(x => x.path).filter((x): x is string => typeof x === "string" && x.length <= 1024 && x.trim() === x && !x.includes(":")); + if (names.length !== paths.length || new Set(names).size !== 1) return; + const target = names[0]; + return target && redactPaperclipSemanticValue(target) === target ? target : undefined; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts index 3f186a7327d..de004db7a23 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts @@ -2,7 +2,7 @@ import { updateSingleReadEvidence, type SingleReadEvidence } from "./single-read import { createHash } from "node:crypto"; import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; import type { CanonicalProviderEvent } from "../../provider-events.js"; -import { safeAcpxLocations } from "./safe-locations.js"; +import { safeCopilotEditTarget } from "./copilot-permission-context.js"; const LIMIT = 256; const CATEGORY = "copilot_tool_evidence_v1"; @@ -61,20 +61,10 @@ export function createCopilotToolEvidence(binding: { details: Object.entries({ stage, toolCallId, ...fields }).map(([name, value]) => ({ name, value: String(value) })), }) as Record }); } - function target(call: Record): string | undefined { - const input = record(call.rawInput); - if (Array.isArray(call.locations) && call.locations.length > 16) return; - const paths = [input.path, input.fileName, ...(Array.isArray(call.locations) ? call.locations.slice(0, 16).map(x => record(x).path) : [])].filter(x => x !== undefined); - if (!paths.length || paths.some(x => typeof x !== "string" || x.length > 2048 || /[\u0000-\u001f\u007f\\]/u.test(x))) return; - const safe = paths.flatMap(path => safeAcpxLocations([{ path }], binding.workingDirectory, call.kind, call.title)); - const names = safe.map(x => x.path).filter((x): x is string => typeof x === "string" && x.length <= 1024 && !x.includes(":")); - if (names.length !== paths.length || new Set(names).size !== 1) return; - return names[0]; - } function inputFields(call: Record): Fields { const input = record(call.rawInput); const fields: Fields = {}; - if (call.kind === "edit") { const path = target(call); if (path) fields.target = path; } + if (call.kind === "edit") { const path = safeCopilotEditTarget(call, binding.workingDirectory); if (path) fields.target = path; } if (call.kind === "execute" && typeof input.command === "string" && input.command.length > 0 && Buffer.byteLength(input.command) <= 64 * 1024) { // ACP exposes an execution kind, not the native tool name. Never infer // `bash` from a provider-controlled title/description. diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 1618c6bd0e1..e2e36cd8ef3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -72,10 +72,10 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< }, copilot: { driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 8, + acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 9, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:3b97dac7020396a84997040ee2864a47af615d69aaf1c951de391e850038157a", + commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6", // Authenticated discovery has not established a qualification model. Never // turn this empty declaration into a default; callers must select an ID. qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v9-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v9-identity.json new file mode 100644 index 00000000000..d2ac26f93b6 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v9-identity.json @@ -0,0 +1,25 @@ +{ + "commandDigest": "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 9, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "2bc34ac7899ac2729909459321e5a8a3668342cc61921e8c95e672719c6d91f2" + } +}