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:54:27 -05:00
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"
}
}