From eeb48425eecb4752bc40364a3260f7c630dfdcf2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 30 Sep 2026 20:09:09 -0500 Subject: [PATCH] fix(runner): bind transitive Copilot permission policy sources Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 7 ++- .../runner-core/src/acpx_provider_backend.rs | 4 +- .../scripts/build-copilot-distribution.mjs | 2 +- .../drivers/acpx/copilot-installation.test.ts | 54 +++++++++++++++++-- .../src/drivers/acpx/qualified-profiles.ts | 2 +- .../fixtures/copilot-profile-v9-identity.json | 7 ++- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index 32adacd7dd1..86e9b31c68c 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -17,7 +17,12 @@ 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 +The declaration binds the context projector, permission adapter, workspace path +validator, semantic redaction policy, and generated tool-operation classifier. +A recursive value-import test requires every non-builtin policy dependency to +remain in this source hash set; type-only imports do not affect execution. +The candidate v9 digest was refreshed before live admission to close this +transitive policy binding. No retained v8 evidence is regraded. 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 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 c2eecce910a..657cab463e4 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:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6", + "sha256:98936d763497bd6f0e5605f52831a344f456357de58c457e440e69a1a468a2f4", ), "grok" => ( "grok-4.7", @@ -2812,7 +2812,7 @@ mod tests { "copilot", "@github/copilot", "1.0.88", - "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6", + "sha256:98936d763497bd6f0e5605f52831a344f456357de58c457e440e69a1a468a2f4", 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 91445c06514..4a598a1846e 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:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6"; +const PROFILE_DIGEST = "sha256:98936d763497bd6f0e5605f52831a344f456357de58c457e440e69a1a468a2f4"; /** 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/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 34e0b8de571..8cff4072e21 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -1,13 +1,46 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { build } from "esbuild"; 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"; +const PERMISSION_POLICY_SOURCES = [ + ["copilot-permission-context.ts", "permissionContextSourceSha256"], + ["acp-permission-adapter.ts", "permissionAdapterSourceSha256"], + ["safe-locations.ts", "permissionLocationsSourceSha256"], + ["../../semantic-tools/redaction.ts", "permissionRedactionSourceSha256"], + ["generated-sidecar-contract.ts", "permissionClassifierSourceSha256"], +] as const; + +// Walk executable imports, not just the projector's immediate dependencies. +async function permissionPolicyClosure(extraClassifierImport = false): Promise> { + const root = fileURLToPath(new URL(".", import.meta.url)); + const result = await build({ + absWorkingDir: root, + entryPoints: ["acp-permission-adapter.ts"], + bundle: true, write: false, metafile: true, platform: "node", treeShaking: false, + plugins: extraClassifierImport ? [{ + name: "unbound-transitive-policy-negative-control", + setup(builder) { + builder.onResolve({ filter: /^unbound-policy$/ }, () => ({ path: "unbound-policy", namespace: "negative-control" })); + builder.onLoad({ filter: /.*/, namespace: "negative-control" }, () => ({ contents: "export const policy = true;", loader: "ts" })); + builder.onLoad({ filter: /generated-sidecar-contract\.ts$/ }, args => ({ + contents: readFileSync(args.path, "utf8") + '\nimport "unbound-policy";', + loader: "ts", resolveDir: root, + })); + }, + }] : [], + }); + return new Set(Object.keys(result.metafile!.inputs).map(path => path.startsWith("negative-control:") + ? path : pathToFileURL(resolve(root, path)).href)); +} + vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { @@ -25,10 +58,25 @@ 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-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"]]) { + for (const [relative, field] of [...PERMISSION_POLICY_SOURCES, ["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); + expect(readFileSync(new URL("../../../runner/crates/runner-core/src/acpx_provider_backend.rs", import.meta.url), "utf8")).toContain(identity.commandDigest); + }); + it("binds the complete executable permission-policy import closure", async () => { + expect(await permissionPolicyClosure()).toEqual(new Set(PERMISSION_POLICY_SOURCES.map(([path]) => new URL(path, import.meta.url).href))); + }); + it("detects an added transitive dependency even when the projector is unchanged", async () => { + const closure = await permissionPolicyClosure(true); + const bound = new Set(PERMISSION_POLICY_SOURCES.map(([path]) => new URL(path, import.meta.url).href)); + expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); + }); + it.each(PERMISSION_POLICY_SOURCES)("changing %s changes the candidate identity", (_path, field) => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v9-identity.json", import.meta.url), "utf8")); + identity.declaration[field] = "0".repeat(64); + const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); + expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); }); it("pins the unchanged executable plus complete patched distribution on every target platform", () => { const fixture = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-message-identity-distributions-1.0.88.json", import.meta.url), "utf8")); @@ -66,7 +114,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 }, { agentProfileVersion: 8 }]) { + for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6" }, { 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/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index e2e36cd8ef3..73c53cb7104 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -75,7 +75,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 9, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6", + commandDigest: "sha256:98936d763497bd6f0e5605f52831a344f456357de58c457e440e69a1a468a2f4", // 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 index d2ac26f93b6..88aea29da29 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-profile-v9-identity.json +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v9-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:ed39259990cb0efda48d6105d2fa3599aac76873eb75cd80b4d0b179ce0579a6", + "commandDigest": "sha256:98936d763497bd6f0e5605f52831a344f456357de58c457e440e69a1a468a2f4", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "copilot", @@ -20,6 +20,9 @@ "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", "permissionContextContract": "copilot-edit-permission-context-v1", "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", - "permissionAdapterSourceSha256": "2bc34ac7899ac2729909459321e5a8a3668342cc61921e8c95e672719c6d91f2" + "permissionAdapterSourceSha256": "2bc34ac7899ac2729909459321e5a8a3668342cc61921e8c95e672719c6d91f2", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4" } }