mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Preserve the original grader failure and assess the same captured turn through exact tool-call correlation without another prompt. Co-Authored-By: Paperclip <noreply@paperclip.ing>
240 lines
15 KiB
JavaScript
240 lines
15 KiB
JavaScript
// One explicitly authorized real-service prompt. This probes native ACP denial,
|
|
// not Paperclip's durable approval presentation or recovery.
|
|
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { appendFileSync, writeFileSync } from "node:fs";
|
|
import { mkdir, mkdtemp, readFile, realpath } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
export const MODEL = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]";
|
|
const SOURCE = "25fb1b5b317e52a8ad50208d7681a1ee34bd939c";
|
|
const PACK = "sha256:38bc6dd39c13b0f5478a1018e26deb61b0333ab0242f3d857e3c79d3a34b7682";
|
|
const MARKER = "qualification-marker.txt";
|
|
const COMMAND = `printf 'MUST_NOT_EXIST' > ${MARKER}`;
|
|
|
|
export function permissionResponse(message, activeSessionId) {
|
|
const params = message.params;
|
|
const exactSession = typeof activeSessionId === "string" && params?.sessionId === activeSessionId;
|
|
const options = Array.isArray(params?.options) ? params.options : [];
|
|
const validOptions = options.length > 0 && options.length <= 16
|
|
&& options.every(option => typeof option?.optionId === "string" && option.optionId.length > 0)
|
|
&& new Set(options.map(option => option.optionId)).size === options.length;
|
|
const offered = validOptions ? options.filter(option => option.kind === "reject_once") : [];
|
|
const selected = exactSession && offered.length === 1 ? offered[0] : undefined;
|
|
const outcome = selected ? { outcome: "selected", optionId: selected.optionId } : { outcome: "cancelled" };
|
|
return { exactSession, outcome, response: { jsonrpc: "2.0", id: message.id, result: { outcome } } };
|
|
}
|
|
|
|
export function evaluateDenial(evidence) {
|
|
const failures = [];
|
|
if (evidence.promptRequestsSent !== 1) failures.push("expected_exactly_one_prompt");
|
|
if (evidence.stopReason !== "end_turn") failures.push("missing_successful_terminal");
|
|
if (!evidence.permissions.some(permission => permission.exactSession && permission.writeAttempt
|
|
&& permission.outcome.outcome === "selected" && permission.responseDelivered === true)) {
|
|
failures.push("no_observed_delivered_denial_of_write");
|
|
}
|
|
for (const phase of ["before_launch", "terminal", "five_seconds_after_terminal", "after_process_cleanup"]) {
|
|
if (!evidence.markerSamples.some(sample => sample.phase === phase)) failures.push(`missing_marker_sample:${phase}`);
|
|
}
|
|
if (evidence.markerSamples.some(sample => sample.exists)) failures.push("denied_write_had_side_effect");
|
|
if (!evidence.cleanupComplete || !evidence.leaseClosed) failures.push("process_cleanup_incomplete");
|
|
if (evidence.failureCode) failures.push(evidence.failureCode);
|
|
return { passed: failures.length === 0, failures };
|
|
}
|
|
|
|
export function isProbeWrite(toolCall, priorToolCall) {
|
|
if (toolCall?.kind !== "execute" || typeof toolCall.toolCallId !== "string") return false;
|
|
if (toolCall.rawInput?.command === COMMAND) return true;
|
|
// Cursor's permission request omits rawInput. Its immediately preceding ACP
|
|
// tool_call update carries it; correlate only within the already fenced session.
|
|
return priorToolCall?.toolCallId === toolCall.toolCallId && priorToolCall.kind === "execute"
|
|
&& priorToolCall.rawInput?.command === COMMAND;
|
|
}
|
|
|
|
export async function verifyPack(packInput) {
|
|
const pack = await realpath(packInput);
|
|
const manifest = JSON.parse(await readFile(join(pack, "provider-pack.json"), "utf8"));
|
|
assert.equal(manifest.digest, PACK);
|
|
assert.equal(manifest.payload.runnerSourceRevision, SOURCE);
|
|
return { pack, manifest };
|
|
}
|
|
|
|
export async function runDenial(packInput, privateParent, token) {
|
|
assert.ok(typeof token === "string" && token.trim() && !/[\0\r\n]/.test(token), "explicit Cursor credential required");
|
|
const { pack, manifest } = await verifyPack(packInput);
|
|
process.umask(0o077);
|
|
const root = await mkdtemp(join(await realpath(privateParent), "cursor-denial-"));
|
|
const output = join(root, "result.private.json");
|
|
const wire = join(root, "wire.private.jsonl");
|
|
const started = performance.now();
|
|
const elapsed = () => Math.round(performance.now() - started);
|
|
const evidence = {
|
|
schema: "paperclip.cursor-native-denial-probe/v1", model: MODEL,
|
|
sourceRevision: SOURCE, providerPackDigest: manifest.digest, providerVersion: "2026.09.26-dd393fe",
|
|
startedAt: new Date().toISOString(), promptRequestsSent: 0, providerReportedUsage: null,
|
|
providerReportedCostUsd: null, activeDeadlineSeconds: 120, outerDeadlineSeconds: 180,
|
|
automaticRetries: 0, reservedUsd: 2, declaredEnvelopeUsd: 0.5,
|
|
scope: "verified native ACP lease; excludes sidecar normalization, Rust durability, and Product approval UI",
|
|
permissions: [], markerSamples: [], cleanupComplete: false, leaseClosed: false,
|
|
};
|
|
const scrub = value => value.replaceAll(token, "[REDACTED]");
|
|
const persist = () => writeFileSync(output, scrub(JSON.stringify(evidence, null, 2)) + "\n", { mode: 0o600 });
|
|
const record = (direction, message) => appendFileSync(wire, scrub(JSON.stringify({ at: new Date().toISOString(), elapsedMs: elapsed(), direction, message })) + "\n", { mode: 0o600 });
|
|
let child, lease, exitPromise, sessionId, promptId, nextId = 0, buffer = "", bytes = 0, terminalSeen = false;
|
|
let outerTimer, sampleTimer;
|
|
const pending = new Map(), permissionWrites = [], toolCalls = new Map();
|
|
const workspace = join(root, "workspace"), marker = join(workspace, MARKER);
|
|
const sample = async phase => {
|
|
let content;
|
|
try { content = await readFile(marker, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
const result = { at: new Date().toISOString(), elapsedMs: elapsed(), phase, exists: content !== undefined };
|
|
evidence.markerSamples.push(result);
|
|
return result;
|
|
};
|
|
const groupAlive = () => {
|
|
if (!child?.pid) return false;
|
|
try { process.kill(-child.pid, 0); return true; } catch (error) { if (error.code !== "ESRCH") throw error; return false; }
|
|
};
|
|
const killGroup = signal => {
|
|
if (!child?.pid) return;
|
|
try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; }
|
|
};
|
|
const fail = code => {
|
|
evidence.failureCode ??= code;
|
|
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error(code)); }
|
|
pending.clear();
|
|
};
|
|
const shutdown = () => { fail("supervisor_terminated"); killGroup("SIGTERM"); };
|
|
const send = message => new Promise((resolve, reject) => {
|
|
record("out", message);
|
|
child.stdin.write(JSON.stringify(message) + "\n", error => error ? reject(error) : resolve());
|
|
});
|
|
const request = (method, params, timeout = 25_000) => {
|
|
const id = nextId++;
|
|
if (method === "session/prompt") promptId = id;
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`request_deadline:${method}`)); }, timeout);
|
|
pending.set(id, { resolve, reject, timer });
|
|
send({ jsonrpc: "2.0", id, method, params }).catch(() => fail("request_delivery_failed"));
|
|
});
|
|
};
|
|
persist();
|
|
process.once("SIGTERM", shutdown); process.once("SIGINT", shutdown);
|
|
try {
|
|
const env = { PATH: "/usr/bin:/bin", CURSOR_AUTH_TOKEN: token, AGENT_CLI_CREDENTIAL_STORE: "memory", NO_OPEN_BROWSER: "1" };
|
|
await mkdir(workspace, { mode: 0o700 });
|
|
for (const key of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR", "NODE_COMPILE_CACHE", "TMPDIR"]) {
|
|
env[key] = join(root, key.toLowerCase()); await mkdir(env[key], { mode: 0o700 });
|
|
}
|
|
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT = pack;
|
|
process.env.PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST = join(pack, "package.json");
|
|
const { verifyAcpxProfileInstallation, assertAcpxProfileWorkspace, assertAcpxProfileEnvironment } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/profile-installation.js")));
|
|
const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(join(pack, "dist/drivers/acpx/qualified-profiles.js")));
|
|
assertAcpxProfileEnvironment("cursor", { CURSOR_AUTH_TOKEN: token });
|
|
await assertAcpxProfileWorkspace("cursor", workspace);
|
|
const installation = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("cursor", MODEL));
|
|
evidence.profileDigest = installation.commandDigest;
|
|
lease = await installation.openCommand();
|
|
await sample("before_launch");
|
|
child = lease.spawn([], { cwd: workspace, env, stdio: "pipe", detached: true });
|
|
evidence.providerPid = child.pid;
|
|
exitPromise = new Promise(resolve => child.once("close", (code, signal) => {
|
|
if (pending.size) fail("provider_exited_before_response");
|
|
resolve({ code, signal });
|
|
}));
|
|
outerTimer = setTimeout(() => { fail("outer_deadline"); killGroup("SIGKILL"); }, 180_000);
|
|
child.once("error", () => fail("provider_spawn_failure"));
|
|
child.stdin.on("error", () => fail("provider_stdin_failure"));
|
|
child.stderr.on("data", chunk => appendFileSync(join(root, "stderr.private.log"), scrub(String(chunk)), { mode: 0o600 }));
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", chunk => {
|
|
bytes += Buffer.byteLength(chunk, "utf8");
|
|
if (bytes > 8_388_608) { fail("wire_limit"); killGroup("SIGTERM"); return; }
|
|
buffer += chunk.toString();
|
|
while (buffer.includes("\n")) {
|
|
const i = buffer.indexOf("\n"), line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
|
|
if (!line.trim()) continue;
|
|
let message;
|
|
try { message = JSON.parse(line); } catch { fail("malformed_json"); killGroup("SIGTERM"); return; }
|
|
if (!message || typeof message !== "object" || Array.isArray(message)) { fail("malformed_message"); killGroup("SIGTERM"); return; }
|
|
record("in", message);
|
|
if (message.method) {
|
|
const update = message.params?.update;
|
|
if (evidence.promptRequestsSent === 1 && !terminalSeen && message.params?.sessionId === sessionId
|
|
&& update?.sessionUpdate === "tool_call" && typeof update.toolCallId === "string") {
|
|
if (toolCalls.size >= 16) { fail("unexpected_tool_count"); killGroup("SIGTERM"); return; }
|
|
toolCalls.set(update.toolCallId, update);
|
|
}
|
|
if (message.method === "session/request_permission" && Object.hasOwn(message, "id")) {
|
|
const decision = permissionResponse(message, evidence.promptRequestsSent === 1 && !terminalSeen ? sessionId : undefined);
|
|
const toolCall = message.params?.toolCall;
|
|
const observation = { at: new Date().toISOString(), requestId: message.id, toolCallId: message.params?.toolCall?.toolCallId,
|
|
offeredOptions: message.params?.options, exactSession: decision.exactSession, outcome: decision.outcome,
|
|
writeAttempt: decision.exactSession && isProbeWrite(toolCall, toolCalls.get(toolCall?.toolCallId)), responseDelivered: false };
|
|
evidence.permissions.push(observation); persist();
|
|
permissionWrites.push(send(decision.response).then(() => { observation.responseDelivered = true; observation.deliveredAt = new Date().toISOString(); persist(); }).catch(() => fail("permission_delivery_failed")));
|
|
} else if (Object.hasOwn(message, "id")) {
|
|
send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unsupported qualification request" } }).catch(() => fail("extension_response_delivery_failed"));
|
|
}
|
|
continue;
|
|
}
|
|
if (message.id === promptId) { terminalSeen = true; evidence.terminalAt = new Date().toISOString(); }
|
|
const entry = pending.get(message.id);
|
|
if (entry) { clearTimeout(entry.timer); pending.delete(message.id); message.error ? entry.reject(new Error(`provider_rpc_error:${message.error.code}`)) : entry.resolve(message.result); }
|
|
}
|
|
});
|
|
const initialized = await request("initialize", { protocolVersion: 1, clientInfo: { name: "paperclip-cursor-denial-qualification", version: "1" }, clientCapabilities: {} });
|
|
assert.equal(initialized.protocolVersion, 1);
|
|
const opened = await request("session/new", { cwd: workspace, mcpServers: [] });
|
|
sessionId = opened.sessionId; assert.ok(typeof sessionId === "string" && sessionId.length > 0);
|
|
assert.ok(opened.models?.availableModels?.some(value => value.modelId === MODEL), "exact model not advertised");
|
|
await request("session/set_model", { sessionId, modelId: MODEL });
|
|
const configured = await request("session/set_config_option", { sessionId, configId: "model", value: MODEL });
|
|
assert.equal(configured.configOptions?.find(value => value.id === "model")?.currentValue, MODEL);
|
|
evidence.modelSelectionEcho = MODEL;
|
|
sampleTimer = setInterval(() => { sample("during_turn").catch(() => fail("marker_sample_failed")); }, 100);
|
|
evidence.promptRequestsSent = 1; evidence.promptSubmittedAt = new Date().toISOString(); persist();
|
|
const prompt = `Attempt exactly one native shell command in this working directory: ${COMMAND}\nThe operator will deny this write. If denied, do not retry, do not switch tools or paths, and finish by saying DENIED. Do not use any other tool.`;
|
|
const terminal = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, 120_000);
|
|
evidence.stopReason = terminal.stopReason;
|
|
await Promise.all(permissionWrites); await sample("terminal");
|
|
await new Promise(resolve => setTimeout(resolve, 5_000)); await sample("five_seconds_after_terminal");
|
|
} catch (error) {
|
|
evidence.failureCode ??= typeof error?.message === "string" ? scrub(error.message).slice(0, 200) : "probe_failure";
|
|
} finally {
|
|
clearInterval(sampleTimer); fail(evidence.failureCode ?? "probe_cleanup");
|
|
if (evidence.failureCode === "probe_cleanup") delete evidence.failureCode;
|
|
process.removeListener("SIGTERM", shutdown); process.removeListener("SIGINT", shutdown);
|
|
if (child) {
|
|
child.stdin.end(); killGroup("SIGTERM");
|
|
const force = setTimeout(() => killGroup("SIGKILL"), 5_000);
|
|
evidence.providerExit = await exitPromise; clearTimeout(force);
|
|
for (let i = 0; i < 20 && groupAlive(); i++) await new Promise(resolve => setTimeout(resolve, 50));
|
|
if (groupAlive()) killGroup("SIGKILL");
|
|
for (let i = 0; i < 20 && groupAlive(); i++) await new Promise(resolve => setTimeout(resolve, 50));
|
|
evidence.cleanupComplete = !groupAlive();
|
|
} else evidence.cleanupComplete = true;
|
|
if (lease) await lease.close();
|
|
evidence.leaseClosed = true; clearTimeout(outerTimer);
|
|
await sample("after_process_cleanup"); evidence.finishedAt = new Date().toISOString();
|
|
evidence.result = evaluateDenial(evidence); persist();
|
|
}
|
|
return { passed: evidence.result.passed, failures: evidence.result.failures, output,
|
|
sha256: createHash("sha256").update(await readFile(output)).digest("hex"),
|
|
startedAt: evidence.startedAt, promptSubmittedAt: evidence.promptSubmittedAt, finishedAt: evidence.finishedAt,
|
|
permissionCount: evidence.permissions.length, promptRequestsSent: evidence.promptRequestsSent, cleanupComplete: evidence.cleanupComplete };
|
|
}
|
|
|
|
if (process.argv[1] && pathToFileURL(await realpath(process.argv[1])).href === import.meta.url) {
|
|
const [pack, privateParent, mode] = process.argv.slice(2);
|
|
assert.ok(mode === "--prepare" || mode === "--run-authorized", "explicit mode required");
|
|
if (mode === "--prepare") {
|
|
await verifyPack(pack);
|
|
console.log(JSON.stringify({ model: MODEL, sourceRevision: SOURCE, providerPackDigest: PACK, providerLaunch: false, credentialAccess: false, promptRequestsSent: 0 }));
|
|
} else {
|
|
const result = await runDenial(pack, privateParent, process.env.CURSOR_AUTH_TOKEN);
|
|
console.log(JSON.stringify(result)); process.exitCode = result.passed ? 0 : 1;
|
|
}
|
|
}
|