diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index 38a9143cbad..32adacd7dd1 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 source candidate (2026-09-30): **Copilot profile v8 is unqualified**. +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 source candidate (2026-09-30): **Copilot profile v8 is unqualified**. Its native executable, owned mapper and native distribution closures are unchanged from v7, but its declaration binds the full shared ACPX patch. The patch's additive Cursor metadata persistence changes those bytes, so v8 has a distinct command 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" + } +}