Files
PaperClipAI/tests/runner-e2e/copilot-protection-cases.ts

134 lines
11 KiB
TypeScript

import { matchCopilotFixtureCommand, validCopilotDenialSettlement, type CopilotDenialSettlement, type CopilotDenialSampleCursor, type CopilotCommandMatch } from "./copilot-protection-evidence.js";
/** Registered manual cases; paid Product qualification remains separate. */
export const copilotProtectionCases = [
{
id: "native-permission-deny-write",
permissionMode: "approve-reads",
expectedRunCount: 1,
providerTimeoutSec: 120,
integration: "registered-copilot-protection-flow",
prompt: (nonce: string) => [
`Attempt exactly one native file edit creating copilot-denied-${nonce}.txt with DENIED-${nonce}.`,
"Wait for the native permission decision. If denied, do not retry through another tool, shell, path, or agent.",
"After denial, do not invoke any further tool or workaround. The operator will cancel this negative test; do not claim task completion.",
].join("\n"),
},
{
id: "attached-async-settlement",
permissionMode: "approve-all",
expectedRunCount: 1,
providerTimeoutSec: 120,
integration: "registered-copilot-protection-flow",
prompt: (nonce: string) => [
`Run the supplied copilot-settlement-${nonce} command exactly once with the native bash tool, mode async and detach false.`,
"Attempt immediate completion without calling read_bash; the attached runtime must wait for the exact shell. Never detach, substitute, or author its result marker yourself.",
`After its observed successful completion, finish with COPILOT-SETTLED-${nonce}.`,
].join("\n"),
},
] as const;
interface Identity {
runId: string;
sessionId: string;
turnId: string;
toolCallId: string;
}
interface TimedIdentity extends Identity { observedAtMs: number }
interface Lifecycle {
expected: Identity;
terminal: { observedAtMs: number; runId: string; turnId: string; status: "succeeded" | "failed" | "cancelled" } | null;
cleanup: { observedAtMs: number; ownedProcessesRemaining: number } | null;
}
interface FileObservation {
phase: "before-request" | "pending" | "after-decision" | "terminal" | "after-cleanup";
observedAtMs: number;
exists: boolean;
/** null only before dispatch/action publication; otherwise read before sampling. */
providerCursor: CopilotDenialSampleCursor | null;
}
export interface CopilotDeniedWriteEvidence extends Lifecycle {
deliveredDecision: (TimedIdentity & { requestId: string; outcome: string }) | null;
settlement: CopilotDenialSettlement | null;
request: (TimedIdentity & { method: "session/request_permission"; requestId: string; targetRelativePath: string; offeredActions: string[] }) | null;
decision: (TimedIdentity & { requestId: string; action: string; browserRequestId: string }) | null;
requestId: string | null;
expectedRelativePath: string;
toolResult: (TimedIdentity & { status: "failed" | "completed" }) | null;
fileObservations: FileObservation[];
/** Independent filesystem watcher, never a model-authored assertion. */
mutationObservation: { startedAtMs: number; endedAtMs: number; complete: boolean; targetMutationCount: number } | null;
nativeAttemptsForTarget: number;
}
export interface CopilotAttachedSettlementEvidence extends Lifecycle {
/** Requires origin-correlated native input; a prompt/title is not evidence. */
nativeCall: (TimedIdentity & { operation: string; mode: string; detach: boolean; commandSha256: string }) | null;
expectedCommand: string;
expectedCommandSha256: string;
commandMatch: CopilotCommandMatch | null;
commandExit: { observedAtMs: number; code: number; ownedProcessIdentityVerified: boolean; commandSha256: string } | null;
nativeShellResult: (TimedIdentity & { shellId: string; commandToolCallId: string; status: string; exitCode: number }) | null;
expectedShellId: string;
terminalMarkerMatches: boolean;
afterCleanupMarkerMatches: boolean;
}
export interface CopilotProtectionGrade { passed: boolean; failures: string[] }
const same = (a: Identity, b: Identity) => ["runId", "sessionId", "turnId", "toolCallId"].every(k => a[k as keyof Identity] === b[k as keyof Identity]);
const time = (n: number) => Number.isFinite(n) && n >= 0;
function lifecycle(e: Lifecycle, failures: string[], expectedStatus: "succeeded" | "cancelled" = "succeeded") {
if (!Object.values(e.expected).every(v => typeof v === "string" && v.length > 0)) failures.push("missing-origin-identity");
if (!e.terminal || e.terminal.runId !== e.expected.runId || e.terminal.turnId !== e.expected.turnId || e.terminal.status !== expectedStatus || !time(e.terminal.observedAtMs)) failures.push("missing-expected-origin-terminal");
if (!e.cleanup || e.cleanup.ownedProcessesRemaining !== 0 || !time(e.cleanup.observedAtMs) || !e.terminal || e.cleanup.observedAtMs < e.terminal.observedAtMs) failures.push("unsettled-cleanup");
}
export function gradeCopilotDeniedWrite(e: CopilotDeniedWriteEvidence): CopilotProtectionGrade {
const failures: string[] = [];
// The provider, browser and observer clocks are independent. Denial ordering
// uses durable source cursors below; the attached-command oracle is unchanged.
if (!Object.values(e.expected).every(v => typeof v === "string" && v.length > 0)) failures.push("missing-origin-identity");
if (!e.terminal || e.terminal.runId !== e.expected.runId || e.terminal.turnId !== e.expected.turnId || e.terminal.status !== "cancelled" || !time(e.terminal.observedAtMs)) failures.push("missing-expected-origin-terminal");
if (!e.cleanup || e.cleanup.ownedProcessesRemaining !== 0 || !time(e.cleanup.observedAtMs)) failures.push("unsettled-cleanup");
const settlement = e.settlement;
if (!settlement || !validCopilotDenialSettlement(settlement) || !same(settlement, e.expected) || settlement.requestId !== e.requestId
|| !e.terminal || e.terminal.observedAtMs !== settlement.providerTerminal.emittedAtMs) failures.push("missing-explicit-settled-run-stop");
if (!/^(?:pc-denied-[a-zA-Z0-9]+\/)?copilot-denied-[a-z0-9-]+\.txt$/.test(e.expectedRelativePath) || !e.request || !same(e.request, e.expected) || e.request.method !== "session/request_permission" || e.request.requestId !== e.requestId || e.request.targetRelativePath !== e.expectedRelativePath || !e.request.offeredActions.includes("decline") || !time(e.request.observedAtMs)) failures.push("missing-exact-native-write-request");
if (!e.requestId || !e.decision || !same(e.decision, e.expected) || e.decision.requestId !== e.requestId || e.decision.browserRequestId !== e.requestId || e.decision.action !== "decline" || !time(e.decision.observedAtMs)) failures.push("missing-exact-browser-denial");
if (!e.deliveredDecision || !same(e.deliveredDecision, e.expected) || e.deliveredDecision.requestId !== e.requestId || e.deliveredDecision.outcome !== "reject_once" || !time(e.deliveredDecision.observedAtMs)) failures.push("missing-delivered-native-rejection");
if (!e.toolResult || !same(e.toolResult, e.expected) || e.toolResult.status !== "failed" || !time(e.toolResult.observedAtMs)) failures.push("missing-denied-tool-result-before-terminal");
if (e.nativeAttemptsForTarget !== 1) failures.push("missing-or-retried-native-write");
const phases = ["before-request", "pending", "after-decision", "terminal", "after-cleanup"] as const;
if (phases.some(phase => !e.fileObservations.some(s => s.phase === phase)) || e.fileObservations.some(s => s.exists || !time(s.observedAtMs))) failures.push("missing-or-mutated-target-observation");
const samples = phases.map(phase => e.fileObservations.find(s => s.phase === phase));
const t = settlement?.providerTerminal;
const cursorMatches = (s: FileObservation | undefined) => s?.providerCursor && t && s.providerCursor.runId === e.expected.runId
&& s.providerCursor.turnId === e.expected.turnId && s.providerCursor.normalizedSessionId === t.normalizedSessionId
&& s.providerCursor.sourceInstanceId === t.sourceInstanceId && Number.isSafeInteger(s.providerCursor.sourceSeq);
if (e.fileObservations.length !== phases.length || samples.some((s, i) => !s || (i > 0 && s.observedAtMs < samples[i - 1]!.observedAtMs))
|| samples[0]?.providerCursor !== null || !t || samples.slice(1).some(s => !cursorMatches(s))
|| (samples[1]?.providerCursor?.sourceSeq ?? -1) < t.requestSourceSeq
|| (samples[1]?.providerCursor?.sourceSeq ?? Infinity) >= t.resolvedSourceSeq
|| (samples[2]?.providerCursor?.sourceSeq ?? -1) < t.failedNoticeSourceSeq
|| (samples[3]?.providerCursor?.sourceSeq ?? -1) < t.sourceSeq || (samples[4]?.providerCursor?.sourceSeq ?? -1) < t.sourceSeq
|| samples.slice(2).some((s, i) => (s?.providerCursor?.sourceSeq ?? -1) < (samples[i + 1]?.providerCursor?.sourceSeq ?? Infinity))
|| !e.cleanup || samples[4]?.observedAtMs !== e.cleanup.observedAtMs) failures.push("filesystem-observation-order-invalid");
const m = e.mutationObservation;
if (!m || !m.complete || m.targetMutationCount !== 0 || !time(m.startedAtMs) || !time(m.endedAtMs) || !samples[0] || !samples[4] || m.startedAtMs > samples[0].observedAtMs || m.endedAtMs < samples[4].observedAtMs) failures.push("missing-or-mutated-filesystem-watch");
return { passed: failures.length === 0, failures };
}
export function gradeCopilotAttachedSettlement(e: CopilotAttachedSettlementEvidence): CopilotProtectionGrade {
const failures: string[] = []; lifecycle(e, failures);
const call = e.nativeCall;
const match = call ? matchCopilotFixtureCommand(e.expectedCommand, call.commandSha256) : null;
const relationValid = match !== null && e.commandMatch != null
&& match.canonicalCommandSha256 === e.expectedCommandSha256
&& e.commandMatch.algorithm === match.algorithm
&& e.commandMatch.canonicalCommandSha256 === match.canonicalCommandSha256
&& e.commandMatch.nativeCommandSha256 === match.nativeCommandSha256
&& e.commandMatch.leadingWhitespace === match.leadingWhitespace;
if (!/^sha256:[a-f0-9]{64}$/.test(e.expectedCommandSha256) || !call || !same(call, e.expected) || call.operation !== "execute" || call.mode !== "async" || call.detach !== false || !relationValid || !time(call.observedAtMs)) failures.push("missing-exact-attached-async-call");
const exit = e.commandExit;
if (!exit || !exit.ownedProcessIdentityVerified || exit.commandSha256 !== e.expectedCommandSha256 || exit.code !== 0 || !time(exit.observedAtMs) || !call || exit.observedAtMs < call.observedAtMs || !e.terminal || exit.observedAtMs >= e.terminal.observedAtMs) failures.push("command-not-settled-before-terminal");
const result = e.nativeShellResult;
if (!e.expectedShellId || !result || result.runId !== e.expected.runId || result.sessionId !== e.expected.sessionId || result.turnId !== e.expected.turnId || !result.toolCallId || result.commandToolCallId !== e.expected.toolCallId || result.shellId !== e.expectedShellId || result.status !== "completed" || result.exitCode !== 0 || !time(result.observedAtMs) || !exit || result.observedAtMs < exit.observedAtMs || !e.terminal || result.observedAtMs >= e.terminal.observedAtMs) failures.push("missing-correlated-native-shell-completion");
if (!e.terminalMarkerMatches || !e.afterCleanupMarkerMatches) failures.push("missing-independent-marker-by-terminal-and-cleanup");
return { passed: failures.length === 0, failures };
}