fix(runner): bind Copilot permission grants to visible targets

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 19:59:38 -05:00
1 parent a9cec40001
commit fcf1866928
14 files changed
+235 -30

No files matched your search

@@ -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.
@@ -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"
}
}