mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
Reject extra provider-death operations and lost process identity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user