mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
Share bounded artifact verification for ACP command and runtime
This commit is contained in:
1 parent
92be5d8596
commit
a93abeb160
2 files changed
+56
-90
No files matched your search
@@ -29,6 +29,7 @@ import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import {
|
||||
verifyProvisionedGrokExecutable,
|
||||
openVerifiedRuntimeExecutable,
|
||||
inspectCommand,
|
||||
builtinGrokLauncherPath,
|
||||
awaitVerifiedAcpxProviderExit,
|
||||
awaitVerifiedAcpxProviderOwnership,
|
||||
@@ -58,6 +59,8 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("ACPX installation integrity", () => {
|
||||
describe.each(["runtime", "command"] as const)("%s executable hydration", (kind) => {
|
||||
const verify = kind === "runtime" ? openVerifiedRuntimeExecutable : inspectCommand;
|
||||
it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "paperclip-runtime-hydration-"));
|
||||
temporaryDirectories.push(directory);
|
||||
@@ -100,19 +103,22 @@ describe("ACPX installation integrity", () => {
|
||||
});
|
||||
try {
|
||||
if (transition === "once") {
|
||||
const verified = await openVerifiedRuntimeExecutable(executable, digest, "fixture");
|
||||
const verified = await verify(executable, digest, "fixture");
|
||||
try {
|
||||
const stable = await verified.handle.stat({ bigint: true });
|
||||
const stable = await stat(executable, { bigint: true });
|
||||
if ("bytes" in verified) expect(verified.bytes).toEqual(bytes);
|
||||
expect(verified.identity.changedNanoseconds).toBe(stable.ctimeNs.toString());
|
||||
expect(verified.identity.modifiedNanoseconds).toBe(stable.mtimeNs.toString());
|
||||
} finally { await verified.handle.close(); }
|
||||
} finally { if ("handle" in verified) await verified.handle.close(); }
|
||||
} else {
|
||||
await expect(openVerifiedRuntimeExecutable(executable, digest, "fixture")).rejects.toThrow(transition === "wrong_digest" ? "digest mismatch" : "changed while it was verified");
|
||||
await expect(verify(executable, digest, "fixture")).rejects.toThrow(transition === "wrong_digest" ? "digest mismatch" : "changed while it was verified");
|
||||
}
|
||||
expect(reads).toBe(["once", "every_read", "second_content"].includes(transition) ? 2 : 1);
|
||||
} finally { readSpy.mockRestore(); }
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it.each([false, true])("admits only the declared qualified Codex platform in its npm-hoisted slot (published platform declaration: %s)", async (publishedDeclaration) => {
|
||||
const parent = await realpath(await mkdtemp(join(tmpdir(), "paperclip-codex-npm-hoist-")));
|
||||
temporaryDirectories.push(parent);
|
||||
|
||||
@@ -1093,7 +1093,7 @@ async function readBoundedRegularFile(
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function inspectCommand(
|
||||
export async function inspectCommand(
|
||||
commandPath: string,
|
||||
expectedDigest: string,
|
||||
agent: string,
|
||||
@@ -1102,67 +1102,20 @@ async function inspectCommand(
|
||||
digest: string;
|
||||
identity: VerifiedAcpxCommandIdentity;
|
||||
}> {
|
||||
const lexicalBefore = await lstat(commandPath, { bigint: true }).catch(
|
||||
() => null,
|
||||
const verified = await openVerifiedExecutable(
|
||||
commandPath,
|
||||
expectedDigest,
|
||||
`ACPX ${agent} executable`,
|
||||
{ maxBytes: MAX_AGENT_COMMAND_BYTES, requireExecutable: false, captureBytes: true },
|
||||
);
|
||||
if (
|
||||
lexicalBefore === null ||
|
||||
lexicalBefore.isSymbolicLink() ||
|
||||
!lexicalBefore.isFile()
|
||||
) {
|
||||
throw new Error(`ACPX ${agent} executable must be a real regular file`);
|
||||
}
|
||||
let handle: Awaited<ReturnType<typeof open>>;
|
||||
try {
|
||||
handle = await open(
|
||||
commandPath,
|
||||
verifiedExecutableOpenFlags(process.platform, constants.O_NOFOLLOW),
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`ACPX ${agent} executable could not be opened as a no-follow regular file`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(MAX_AGENT_COMMAND_BYTES)
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} executable must be a bounded regular file`,
|
||||
);
|
||||
}
|
||||
const bytes = await readHandleAtStart(handle, Number(before.size));
|
||||
const after = await handle.stat({ bigint: true });
|
||||
const lexicalAfter = await lstat(commandPath, { bigint: true }).catch(
|
||||
() => null,
|
||||
);
|
||||
const beforeIdentity = fileIdentity(before);
|
||||
const afterIdentity = fileIdentity(after);
|
||||
if (
|
||||
bytes.length < 1 ||
|
||||
bytes.length > MAX_AGENT_COMMAND_BYTES ||
|
||||
lexicalAfter === null ||
|
||||
lexicalAfter.isSymbolicLink() ||
|
||||
!lexicalAfter.isFile() ||
|
||||
!sameIdentity(fileIdentity(lexicalBefore), fileIdentity(lexicalAfter)) ||
|
||||
!sameIdentity(fileIdentity(lexicalAfter), afterIdentity) ||
|
||||
!sameIdentity(beforeIdentity, afterIdentity) ||
|
||||
after.size !== BigInt(bytes.length)
|
||||
) {
|
||||
throw new Error(`ACPX ${agent} executable changed while it was verified`);
|
||||
}
|
||||
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
||||
if (digest !== expectedDigest) {
|
||||
throw new Error(`ACPX ${agent} executable digest mismatch`);
|
||||
}
|
||||
return { bytes, digest, identity: afterIdentity };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
return {
|
||||
bytes: verified.bytes,
|
||||
digest: verified.digest,
|
||||
identity: verified.identity,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
await verified.handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1171,6 +1124,28 @@ export async function openVerifiedRuntimeExecutable(
|
||||
expectedDigest: string,
|
||||
agent: string,
|
||||
): Promise<{ handle: FileHandle; identity: VerifiedAcpxCommandIdentity }> {
|
||||
const verified = await openVerifiedExecutable(
|
||||
executablePath,
|
||||
expectedDigest,
|
||||
`ACPX ${agent} runtime executable`,
|
||||
{ maxBytes: MAX_ACPX_RUNTIME_EXECUTABLE_BYTES, requireExecutable: true, captureBytes: false },
|
||||
);
|
||||
return { handle: verified.handle, identity: verified.identity };
|
||||
}
|
||||
|
||||
/** Verify command snapshots and native executables under the same identity fence. */
|
||||
async function openVerifiedExecutable(
|
||||
executablePath: string,
|
||||
expectedDigest: string,
|
||||
label: string,
|
||||
options: { maxBytes: number; requireExecutable: boolean; captureBytes: boolean },
|
||||
): Promise<{
|
||||
handle: FileHandle;
|
||||
identity: VerifiedAcpxCommandIdentity;
|
||||
digest: string;
|
||||
bytes: Buffer;
|
||||
}> {
|
||||
const { maxBytes, requireExecutable, captureBytes } = options;
|
||||
const lexicalBefore = await lstat(executablePath, { bigint: true }).catch(
|
||||
() => null,
|
||||
);
|
||||
@@ -1180,7 +1155,7 @@ export async function openVerifiedRuntimeExecutable(
|
||||
!lexicalBefore.isFile()
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable must be a real regular file`,
|
||||
`${label} must be a real regular file`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1192,7 +1167,7 @@ export async function openVerifiedRuntimeExecutable(
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable could not be opened as a no-follow regular file`,
|
||||
`${label} could not be opened as a no-follow regular file`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1203,11 +1178,11 @@ export async function openVerifiedRuntimeExecutable(
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(MAX_ACPX_RUNTIME_EXECUTABLE_BYTES) ||
|
||||
(before.mode & 0o111n) === 0n
|
||||
before.size > BigInt(maxBytes) ||
|
||||
(requireExecutable && (before.mode & 0o111n) === 0n)
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable must be a bounded executable file`,
|
||||
`${label} must be a bounded ${requireExecutable ? "executable file" : "regular file"}`,
|
||||
);
|
||||
}
|
||||
const beforeIdentity = fileIdentity(before);
|
||||
@@ -1216,11 +1191,12 @@ export async function openVerifiedRuntimeExecutable(
|
||||
lexicalBaseline.mode !== before.mode
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable changed while it was verified`,
|
||||
`${label} changed while it was verified`,
|
||||
);
|
||||
}
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.alloc(1024 * 1024);
|
||||
const chunks: Buffer[] = [];
|
||||
let position = 0;
|
||||
try {
|
||||
while (position < Number(before.size)) {
|
||||
@@ -1232,6 +1208,7 @@ export async function openVerifiedRuntimeExecutable(
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
if (captureBytes) chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
|
||||
position += bytesRead;
|
||||
}
|
||||
} finally {
|
||||
@@ -1256,15 +1233,15 @@ export async function openVerifiedRuntimeExecutable(
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable changed while it was verified`,
|
||||
`${label} changed while it was verified`,
|
||||
);
|
||||
}
|
||||
const digest = `sha256:${hash.digest("hex")}`;
|
||||
if (digest !== expectedDigest) {
|
||||
throw new Error(`ACPX ${agent} runtime executable digest mismatch`);
|
||||
throw new Error(`${label} digest mismatch`);
|
||||
}
|
||||
if (sameIdentity(beforeIdentity, afterIdentity)) {
|
||||
return { handle, identity: afterIdentity };
|
||||
return { handle, identity: afterIdentity, digest, bytes: Buffer.concat(chunks, captureBytes ? position : 0) };
|
||||
}
|
||||
// A lazy filesystem restore can change ctime on the first read. Only
|
||||
// already-qualified bytes with otherwise identical metadata may receive
|
||||
@@ -1273,7 +1250,7 @@ export async function openVerifiedRuntimeExecutable(
|
||||
lexicalBaseline = lexicalAfter;
|
||||
}
|
||||
throw new Error(
|
||||
`ACPX ${agent} runtime executable changed while it was verified`,
|
||||
`${label} changed while it was verified`,
|
||||
);
|
||||
} catch (error) {
|
||||
await handle.close();
|
||||
@@ -1436,23 +1413,6 @@ function verifiedDirectoryOpenFlags(
|
||||
return constants.O_RDONLY | noFollowFlag | directoryFlag;
|
||||
}
|
||||
|
||||
async function readHandleAtStart(
|
||||
handle: FileHandle,
|
||||
size: number,
|
||||
): Promise<Buffer> {
|
||||
const bytes = Buffer.alloc(size);
|
||||
let offset = 0;
|
||||
while (offset < size) {
|
||||
const read = await handle.read(bytes, offset, size - offset, offset);
|
||||
if (read.bytesRead === 0) break;
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
if (offset !== size) {
|
||||
throw new Error("ACPX provider executable ended during verification");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function commandLease(
|
||||
commandDirectoryPath: string,
|
||||
commandName: string,
|
||||
|
||||
Reference in new issue
Block a user