Reject extra provider-death operations and lost process identity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 08:08:57 -05:00
1 parent 145f3f1725
commit 266035f210
5 files changed
+33 -5

No files matched your search

@@ -127,6 +127,11 @@ function ownedProcessCommand(pid: number): readonly string[] {
* Native onSpawn publishes the runnerd child. Bind its current argv to this run
* and per_turn lifecycle before treating its retirement as run cleanup.
*/
/** Losing a captured process identity cannot be repaired by a later empty sample. */
export function retainRunProcessIdentity<T extends { captured: boolean; live: number[]; identityChanged?: boolean }>(previous: T, current: T): T {
return { ...current, identityChanged: previous.identityChanged === true || current.identityChanged === true };
}
export function observeRunProcesses() {
const owned = new Map<number, ProcessIdentity>(); let root: ProcessIdentity | undefined; let providerDeathDispatched = false;
let authorityCheck: { authority: RunProcessAuthority; observed: ProcessIdentity; commandSha256: string; accepted: boolean } | undefined;
@@ -8,7 +8,7 @@ import { createCopilotToolEvidence } from "../../packages/paperclip-runner/src/d
import { validateAcpxRichEvent } from "../../packages/paperclip-runner/src/drivers/acpx/profile-extensions.js";
import { copilotOrigin, readCopilotToolEvidence } from "./copilot-evidence.js";
import { copilotProtectionCases, gradeCopilotAttachedSettlement, gradeCopilotDeniedWrite } from "./copilot-protection-cases.js";
import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, watchDeniedTarget, exists, isPerTurnRunProcess } from "./copilot-local-fixtures.js";
import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, watchDeniedTarget, exists, isPerTurnRunProcess, retainRunProcessIdentity } from "./copilot-local-fixtures.js";
import { runnerMatrix, suiteDefinitionHash } from "./catalog.js";
import { selectRunnerExecutions, parseRunnerSelectors } from "./selectors.js";
@@ -138,6 +138,13 @@ describe("Copilot Product protection integration", () => {
expect(isPerTurnRunProcess({ ...authority, groupId: 101 }, observed, args)).toBe(false);
expect(isPerTurnRunProcess(authority, { ...observed, start: new Date(1_700_000_001_000).toString() }, args)).toBe(false);
});
it("retains lost process identity after a later empty retirement sample", () => {
let sample = { captured: true, live: [100], identityChanged: false };
sample = retainRunProcessIdentity(sample, { captured: true, live: [], identityChanged: true });
sample = retainRunProcessIdentity(sample, { captured: true, live: [], identityChanged: false });
expect(sample).toEqual({ captured: true, live: [], identityChanged: true });
expect(retainRunProcessIdentity({ captured: true, live: [100] }, { captured: true, live: [] }).identityChanged).toBe(false);
});
it("reaps a real finite child before its provider-style client can exit", async () => {
const root = await mkdtemp("/tmp/pc-copilot-command-"); const fixture = await createAttachedCommandFixture(join(root, "marker"), 150);
try {
+6 -3
View File
@@ -9,7 +9,7 @@ import { createTaskThroughUi } from "./user-actions.js";
import { approveCopilotContextThroughUi, prepareCopilotContext } from "./copilot-context-permission.js";
import { onlyCopilotAttachedOperations, readCopilotSemanticCompletion } from "./copilot-semantic-evidence.js";
import { copilotOrigin, readCopilotToolEvidence, type CopilotToolNotice } from "./copilot-evidence.js";
import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, exists, observeRunProcesses } from "./copilot-local-fixtures.js";
import { createAttachedCommandFixture, createDeniedTargetFixture, bindDeniedTargetPrompt, exists, observeRunProcesses, retainRunProcessIdentity } from "./copilot-local-fixtures.js";
import { gradeCopilotAttachedSettlement, gradeCopilotDeniedWrite, type CopilotDeniedWriteEvidence } from "./copilot-protection-cases.js";
import { observeCopilotPreStop, type CopilotPreStopObservation, readCopilotDeniedEdit, copilotDenialSampleCursor, readCopilotDenialSettlement, observeCopilotFixtureCommand, readCopilotRemoteMarkerAfterRetirement, prepareCopilotRemoteAction, assertCopilotRemoteRetirement, assertCopilotRemoteAttached, copilotRemoteDeniedSample, copilotActionNotices, type CopilotRemoteBootstrap, type CopilotRemoteFixture, type CopilotRemoteSnapshot, countCopilotToolOrigins, countCopilotEditOriginsForTarget } from "./copilot-protection-evidence.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
@@ -99,7 +99,7 @@ export async function runCopilotProtectionFlow(input: {
const checks: Check[] = []; let issue: Row = {}, runs: Row[] = [], runEvents: Row[] = [];
let notices: CopilotToolNotice[] = [];
const processObserver = remote ? undefined : observeRunProcesses();
let processes: { captured: boolean; live: number[] } = processObserver?.sample() ?? { captured: false, live: [] };
let processes: { captured: boolean; live: number[]; identityChanged?: boolean } = processObserver?.sample() ?? { captured: false, live: [] };
let remoteFixture: CopilotRemoteFixture | undefined, baseline: CopilotRemoteSnapshot | undefined, sealed: CopilotRemoteSnapshot | undefined;
let remoteCommand: { command: string; commandSha256: string } | undefined;
const remoteMarker = `${randomBytes(24).toString("hex")}\n`;
@@ -138,7 +138,10 @@ export async function runCopilotProtectionFlow(input: {
runEvents = runs[0] ? await collectRunEvents<Row>((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runs[0]!.id}/events?afterSeq=${afterSeq}&limit=${limit}`)) : [];
notices = runs[0] ? readCopilotToolEvidence(runEvents, runs[0].id) : [];
const run = runs[0];
if (processObserver) processes = processObserver.sample(run?.processPid ? { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id } : undefined);
if (processObserver) {
processes = retainRunProcessIdentity(processes, processObserver.sample(run?.processPid ? { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id } : undefined));
if (processes.identityChanged) check("owned-process-identity", false, "A captured process identity changed; cleanup cannot be qualified");
}
return { issue, runs, runEvents, notices, processes };
}
const wait = (label: string, accept: (state: Awaited<ReturnType<typeof load>>) => boolean) => pollUntil({ label, deadlineAt: input.deadlineAt, load, accept, intervalMs: 200,
@@ -46,6 +46,17 @@ describe("provider-death independent evidence", () => {
it("accepts callback expiry and an unfinished failed task with no replay", () => {
expect(assertCopilotProviderDeath(facts())).toMatchObject({ expired: true, mutationReplay: false });
});
it.each(["execute", "read", "edit"])("refuses an additional %s operation after provider death", operation => {
const replay = facts();
const extra = row(4, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", scope: "turn", category: "copilot_tool_evidence_v1", provenance: { sessionId: "native-session", turnId: "turn", eventType: "tool", method: "session/update" }, details: Object.entries({ stage: "tool", toolCallId: "extra-tool", operation, status: "completed" }).map(([name, value]) => ({ name, value })) });
replay.events.push(extra);
expect(() => assertCopilotProviderDeath(replay)).toThrow(/replayed or completed an operation/);
});
it("refuses an additional semantic operation without a file mutation", () => {
const replay = facts();
replay.events.push(row(4, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", scope: "turn", category: "copilot_tool_evidence_v1", provenance: { sessionId: "native-session", turnId: "turn", eventType: "tool", method: "session/update" }, details: Object.entries({ stage: "tool", toolCallId: "semantic-extra", status: "completed", semanticOperationId: "issue.get", semanticCallIdentitySha256: "a".repeat(64), semanticInputSha256: "b".repeat(64), semanticNormalizedInputSha256: "null", semanticResultSha256: "c".repeat(64), semanticOutcome: "returned" }).map(([name, value]) => ({ name, value })) }));
expect(() => assertCopilotProviderDeath(replay)).toThrow(/replayed or completed an operation/);
});
it("refuses missing expiry, stale resolution, success and foreign evidence", () => {
const missing = facts(); missing.events.splice(1, 1); expect(() => assertCopilotProviderDeath(missing)).toThrow();
const resolved = facts(); resolved.events.push(row(4, "runtime_request.resolved", { requestId: "request" })); expect(() => assertCopilotProviderDeath(resolved)).toThrow();
+3 -1
View File
@@ -61,7 +61,9 @@ export function assertCopilotProviderDeath(input: { pending: ActiveStopPending;
if (pending.scope.requireContextRead && !target) throw new Error("Provider death lacks the exact mutation origin");
const context = target ? readCopilotContextRead(input.events, target, pending.scope.requireContextRead) : undefined;
const notices = context ? allNotices.filter(n => ![context.toolCallId, ...context.discoveries.map(d => d.toolCallId)].includes(n.toolCallId)) : allNotices;
if (notices.filter(n => n.stage === "permission_requested").length !== 1 || notices.some(n => n.stage === "permission_delivered" && ["allow_once", "allow_always"].includes(n.outcome ?? "")) || notices.some(n => n.stage === "tool" && n.operation === "edit" && (n.toolCallId !== pending.toolCallId || n.status === "completed"))) throw new Error("Provider death replayed or completed the mutation");
if (notices.filter(n => n.stage === "permission_requested").length !== 1 || notices.some(n => n.stage === "permission_delivered" && ["allow_once", "allow_always"].includes(n.outcome ?? "")) || notices.some(n => n.stage === "tool" && (n.toolCallId !== pending.toolCallId || n.operation !== "edit" || n.status === "completed"))) throw new Error("Provider death replayed or completed an operation");
const expirySeq = rows.find(row => row.payload.prpEvent === expiry[0])!.seq;
if (allNotices.some(n => n.stage === "tool" && n.seq >= expirySeq && !(n.toolCallId === pending.toolCallId && n.operation === "edit" && n.status === "failed"))) throw new Error("Provider death executed an operation after callback expiry");
if (!events.some(event => ["turn.failed", "turn.interrupted"].includes(event.eventType) && event.turnId === pending.turnId)) throw new Error("Provider death lacks a failed provider turn");
return { schema: "paperclip.e2e.copilot-provider-death.v1", runId: run.id, requestId: pending.requestId, expired: true, mutationReplay: false };
}