mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
fix(runner): bind Copilot permission grants to visible targets
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
986f8cc534
commit
03090216b7
14 files changed
+235
-30
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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<string, { normalized: ReturnType<typeof normalizeAcpxPermission> }>();
|
||||
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")'));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<AcpxPermissionAction, AcpPermissionDecision["outcome"]>();
|
||||
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,
|
||||
|
||||
@@ -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<typeof callback>[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" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -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<string, unknown> });
|
||||
}
|
||||
function target(call: Record<string, unknown>): 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<string, unknown>): 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user