mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Report bounded metadata for module link-count rejections
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ab1e8acc68
commit
f5a393dd60
5 files changed
+192
-10
No files matched your search
@@ -458,11 +458,12 @@ impl AcpxSidecarTransport {
|
||||
let error = response.error.expect("failed response has validated error");
|
||||
return Ok(CommandOutcome::Rejected(LocalRunnerError::invalid(
|
||||
format!(
|
||||
"ACPX sidecar command {} was rejected (retryable={}, classification={}){}{}",
|
||||
"ACPX sidecar command {} was rejected (retryable={}, classification={}){}{}{}",
|
||||
command.as_str(),
|
||||
error.retryable,
|
||||
response_error_classification(&error),
|
||||
response_error_module_fence(&error).map(|fence| format!(" moduleFence={fence}")).unwrap_or_default(),
|
||||
response_error_module_read(&error).unwrap_or_default(),
|
||||
self.diagnostic_suffix(),
|
||||
),
|
||||
)));
|
||||
@@ -926,6 +927,9 @@ fn stderr_diagnostic_categories(value: &str) -> BTreeSet<&'static str> {
|
||||
|
||||
// Only fixed loader fence codes may cross the private provider-stderr boundary.
|
||||
fn response_error_module_fence(error: &ResponseError) -> Option<&'static str> {
|
||||
if response_error_module_read(error).is_some() {
|
||||
return Some("link_count");
|
||||
}
|
||||
match error.code.as_str() {
|
||||
"AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.RESOLUTION" => Some("resolution"),
|
||||
"AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.OPEN" => Some("open"),
|
||||
@@ -948,6 +952,59 @@ fn response_error_module_fence(error: &ResponseError) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
// Independently validate the compact failure-only metadata from the sidecar.
|
||||
fn response_error_module_read(error: &ResponseError) -> Option<String> {
|
||||
let suffix = error
|
||||
.code
|
||||
.strip_prefix("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.")?;
|
||||
let fields: Vec<&str> = suffix.split('.').collect();
|
||||
if fields.len() != 5 || !matches!(fields[0], "1" | "2") {
|
||||
return None;
|
||||
}
|
||||
let number = |text: &str| -> Option<u64> {
|
||||
if text.is_empty()
|
||||
|| text.len() > 20
|
||||
|| !text.bytes().all(|b| b.is_ascii_digit())
|
||||
|| (text.len() > 1 && text.starts_with('0'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
text.parse().ok()
|
||||
};
|
||||
let before = number(fields[1])?;
|
||||
let after = number(fields[2])?;
|
||||
if before == after || fields[3] != if after > before { "I" } else { "D" } {
|
||||
return None;
|
||||
}
|
||||
let flags = fields[4];
|
||||
if flags.len() != 8
|
||||
|| !flags.starts_with("111111")
|
||||
|| !flags.bytes().all(|b| matches!(b, b'0' | b'1'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut diagnostic = format!(
|
||||
" moduleReadAttempt={} moduleNlinkBefore={} moduleNlinkAfter={} moduleNlinkDirection={}",
|
||||
fields[0],
|
||||
before,
|
||||
after,
|
||||
if after > before {
|
||||
"increase"
|
||||
} else {
|
||||
"decrease"
|
||||
}
|
||||
);
|
||||
for (field, equal) in [
|
||||
"Dev", "Inode", "Mode", "Uid", "Gid", "Size", "Mtime", "Ctime",
|
||||
]
|
||||
.into_iter()
|
||||
.zip(flags.bytes())
|
||||
{
|
||||
diagnostic.push_str(&format!(" module{field}Equal={}", equal == b'1'));
|
||||
}
|
||||
Some(diagnostic)
|
||||
}
|
||||
|
||||
fn response_error_classification(error: &ResponseError) -> &'static str {
|
||||
if response_error_module_fence(error).is_some() {
|
||||
return "agent_startup_unverified_module";
|
||||
@@ -1207,7 +1264,7 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
let response = json!({ "protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
|
||||
"id": 1, "ok": false, "error": { "code": "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.REPEATED_CTIME",
|
||||
"id": 1, "ok": false, "error": { "code": "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.1.2.1.D.11111110",
|
||||
"message": "/private/token-canary", "retryable": false } });
|
||||
let lines = frames
|
||||
.iter()
|
||||
@@ -1236,7 +1293,12 @@ mod tests {
|
||||
),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("moduleFence=repeated_ctime"), "{error}");
|
||||
assert!(error.contains("moduleFence=link_count"), "{error}");
|
||||
assert!(error.contains("moduleReadAttempt=1 moduleNlinkBefore=2 moduleNlinkAfter=1 moduleNlinkDirection=decrease"), "{error}");
|
||||
assert!(
|
||||
error.contains("moduleMtimeEqual=true moduleCtimeEqual=false"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("admissionStage=handshake admissionElapsedMs=24"),
|
||||
"{error}"
|
||||
@@ -1434,6 +1496,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_count_failure_details_are_bounded_and_keep_the_fence() {
|
||||
for (suffix, valid) in [
|
||||
("1.1.2.I.11111110", true),
|
||||
("2.2.1.D.11111101", true),
|
||||
("1.0.18446744073709551615.I.11111111", true),
|
||||
("0.1.2.I.11111110", false),
|
||||
("3.1.2.I.11111110", false),
|
||||
("1.-1.2.I.11111110", false),
|
||||
("1.01.2.I.11111110", false),
|
||||
("1.1.18446744073709551616.I.11111110", false),
|
||||
("1.1.2.D.11111110", false),
|
||||
("1.1.1.I.11111110", false),
|
||||
("1.1.2.I.01111110", false),
|
||||
("1.1.2.I.111111100", false),
|
||||
("1.1.2.I.11111110.PRIVATE_CANARY", false),
|
||||
] {
|
||||
let error = ResponseError {
|
||||
code: format!("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.{suffix}"),
|
||||
message: "/private/token-canary".to_owned(),
|
||||
retryable: false,
|
||||
};
|
||||
let detail = response_error_module_read(&error);
|
||||
assert_eq!(detail.is_some(), valid, "{suffix}");
|
||||
assert_eq!(
|
||||
response_error_module_fence(&error),
|
||||
if valid { Some("link_count") } else { None }
|
||||
);
|
||||
assert_eq!(
|
||||
response_error_classification(&error),
|
||||
if valid {
|
||||
"agent_startup_unverified_module"
|
||||
} else {
|
||||
"unclassified"
|
||||
}
|
||||
);
|
||||
if let Some(detail) = detail {
|
||||
assert!(!detail.contains("private") && !detail.contains("CANARY"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_auth_diagnostics_use_only_closed_codes_and_never_provider_text() {
|
||||
for (code, expected) in [
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -246,3 +250,31 @@ describe("ACPX sidecar input sequencing", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("closed link-count failure detail", () => {
|
||||
const classify = (detail: string) => acpxSidecarErrorCode(Object.assign(new Error("private-canary"), {
|
||||
detailCode: "AGENT_STARTUP_FAILED", stderrSummary: `PAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:LINK_COUNT:END PAPERCLIP_ACPX_LINK_COUNT:${detail}:END private-canary /secret`,
|
||||
}));
|
||||
it.each(["1:1:2:I:11111110", "2:2:1:D:11111101", "1:0:18446744073709551615:I:11111111"])("preserves bounded metadata %s", detail => {
|
||||
expect(classify(detail)).toBe(`AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.${detail.replaceAll(":", ".")}`);
|
||||
});
|
||||
it.each(["0:1:2:I:11111110", "3:1:2:I:11111110", "1:-1:2:I:11111110", "1:01:2:I:11111110", "1:1:18446744073709551616:I:11111110", "1:1:2:D:11111110", "1:1:1:I:11111110", "1:1:2:I:01111110", "1:1:2:I:111111100", "1:1:2:I:11111110.PRIVATE", "1:1:2:I:11111110:END_PRIVATE"])("drops malformed metadata without dropping fence: %s", detail => {
|
||||
expect(classify(detail)).toBe("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT");
|
||||
});
|
||||
it("survives the installed ACPX summarizer without leaking or accepting truncated details", async () => {
|
||||
const directory = dirname(createRequire(import.meta.url).resolve("acpx/runtime"));
|
||||
const chunk = (await readdir(directory)).find(name => /^live-checkpoint-.*\.js$/.test(name));
|
||||
expect(chunk).toBeDefined();
|
||||
const exports = await import(pathToFileURL(join(directory, chunk!)).href);
|
||||
const client = Object.values(exports).find(value => typeof value === "function" && typeof value.prototype?.summarizeStartupStderr === "function") as { prototype: { summarizeStartupStderr: (parts: string[]) => string } };
|
||||
const base = "PAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:LINK_COUNT:END";
|
||||
const detail = "PAPERCLIP_ACPX_LINK_COUNT:1:2:1:D:11111110:END";
|
||||
for (const complete of [true, false]) {
|
||||
const summary = client.prototype.summarizeStartupStderr([`${base}\n${complete ? detail : detail.slice(0, -1)}\nprivate-canary /secret`]);
|
||||
const code = acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { detailCode: "AGENT_STARTUP_FAILED", stderrSummary: summary }));
|
||||
expect(code).toBe(`AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT${complete ? ".READ.1.2.1.D.11111110" : ""}`);
|
||||
expect(code).not.toMatch(/private|secret/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,8 @@ function directAcpxSidecarErrorCode(error: Error): string | null {
|
||||
?? /(?:^|\n)PAPERCLIP_ACPX_IMPORT_FAILURE:(UNVERIFIED_MODULE|OTHER)(?::([A-Z_]+))?(?:\r?\n|$)/.exec(stderr);
|
||||
if (importFailure?.[1] === "UNVERIFIED_MODULE" && importFailure[2] === undefined) return "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE";
|
||||
if (importFailure?.[1] === "UNVERIFIED_MODULE" && MODULE_FENCES.has(importFailure[2])) {
|
||||
return `AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.${importFailure[2]}`;
|
||||
const detail = importFailure[2] === "LINK_COUNT" ? moduleLinkCountDetail(stderr) : null;
|
||||
return `AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.${importFailure[2]}${detail ? `.READ.${detail.replaceAll(":", ".")}` : ""}`;
|
||||
}
|
||||
if (/ERR_ACPX_UNVERIFIED_MODULE/.test(stderr)) {
|
||||
return "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE";
|
||||
@@ -181,3 +182,15 @@ const STABLE_ACPX_SIDECAR_CODES = new Set([
|
||||
"ACPX_TOOL_CALL_STALE",
|
||||
...GENERIC_ACPX_OUTPUT_CODES,
|
||||
]);
|
||||
|
||||
|
||||
/** Failure-only unsigned metadata; no provider strings or identifiers cross. */
|
||||
function moduleLinkCountDetail(stderr: string): string | null {
|
||||
const match = /(?:^|\s)PAPERCLIP_ACPX_LINK_COUNT:([12]):(0|[1-9][0-9]{0,19}):(0|[1-9][0-9]{0,19}):([ID]):(111111[01]{2}):END(?=\s|$)/.exec(stderr);
|
||||
if (!match) return null;
|
||||
const before = BigInt(match[2]!);
|
||||
const after = BigInt(match[3]!);
|
||||
if (before > 18446744073709551615n || after > 18446744073709551615n || before === after
|
||||
|| match[4] !== (after > before ? "I" : "D")) return null;
|
||||
return match.slice(1).join(":");
|
||||
}
|
||||
@@ -1766,6 +1766,10 @@ describe("ACPX installation integrity", () => {
|
||||
if (reasons[transition]) {
|
||||
pass = 0;
|
||||
expect(read).toThrow(expect.objectContaining({ code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence: reasons[transition] }));
|
||||
if (transition === "nlink") {
|
||||
pass = 0;
|
||||
expect(read).toThrow(expect.objectContaining({ moduleReadDiagnostic: "1:1:2:I:11111110" }));
|
||||
}
|
||||
}
|
||||
expect(pass).toBeLessThanOrEqual(2);
|
||||
if (transition === "snapshot_second") expect(verify).toHaveBeenCalledTimes(2);
|
||||
@@ -1774,7 +1778,19 @@ describe("ACPX installation integrity", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32").each(["once", "recurring", "bytes", "mode", "rename", "replacement", "symlink"] as const)("loads dependency bytes only after stable ctime-only continuity (%s)", async (transition) => {
|
||||
it("reports the second read attempt without accepting link drift", () => {
|
||||
const bytes = Buffer.from("stable bytes");
|
||||
const before = { dev: 1n, ino: 2n, mode: 0o100444n, uid: 1n, gid: 1n, nlink: 1n,
|
||||
size: BigInt(bytes.length), mtimeNs: 10n, ctimeNs: 20n, isFile: () => true };
|
||||
let passes = 0;
|
||||
expect(() => readStableSnapshotModuleSource({ before, maxBytes: 64, verify: () => {},
|
||||
read: (buffer, offset, length, position) => { if (position === 0) passes++; return bytes.copy(buffer, offset, position, position + length); },
|
||||
stat: () => ({ ...before, ctimeNs: 21n, nlink: passes === 2 ? 0n : 1n }),
|
||||
})).toThrow(expect.objectContaining({ code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence: "LINK_COUNT", moduleReadDiagnostic: "2:1:0:D:11111111" }));
|
||||
expect(passes).toBe(2);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32").each(["once", "recurring", "bytes", "mode", "rename", "replacement", "symlink", "link"] as const)("loads dependency bytes only after stable ctime-only continuity (%s)", async (transition) => {
|
||||
const fixture = await installationFixture();
|
||||
await writeFile(join(fixture.commandDirectory, "dependency.mjs"), 'export const value = "dependency-success";');
|
||||
const command = `import fs from 'node:fs';
|
||||
@@ -1785,6 +1801,7 @@ describe("ACPX installation integrity", () => {
|
||||
if (buffer.subarray(offset, offset + count).includes(Buffer.from('dependency-success'))) {
|
||||
moduleFd = fd; passes += 1;
|
||||
if (${JSON.stringify(transition)} === 'bytes' && passes === 2) buffer[offset] = 0;
|
||||
if (process.platform === 'linux' && passes === 1 && ${JSON.stringify(transition)} === 'link') fs.linkSync(new URL('./dependency.mjs', import.meta.url), new URL('./dependency-link.mjs', import.meta.url));
|
||||
if (passes === 1 && ['rename','replacement','symlink'].includes(${JSON.stringify(transition)})) {
|
||||
const named = new URL('./dependency.mjs', import.meta.url);
|
||||
fs.renameSync(named, new URL('./moved.mjs', import.meta.url));
|
||||
@@ -1799,6 +1816,7 @@ describe("ACPX installation integrity", () => {
|
||||
if (fd === moduleFd && options?.bigint) {
|
||||
metadata.ctimeNs += ${JSON.stringify(transition)} === 'recurring' ? BigInt(passes) : 1n;
|
||||
if (${JSON.stringify(transition)} === 'mode') metadata.mode ^= 0o100n;
|
||||
if (process.platform !== 'linux' && ${JSON.stringify(transition)} === 'link') metadata.nlink += 1n;
|
||||
}
|
||||
return metadata;
|
||||
};
|
||||
@@ -1821,7 +1839,12 @@ describe("ACPX installation integrity", () => {
|
||||
const [code] = await closed;
|
||||
expect(code).toBe(transition === "once" ? 0 : 1);
|
||||
expect(stdout).toBe(transition === "once" ? "dependency-success" : "");
|
||||
const fences: Record<string, string> = { recurring: "REPEATED_CTIME", bytes: "BYTE_CONTINUITY", mode: "MODE" };
|
||||
const fences: Record<string, string> = { recurring: "REPEATED_CTIME", bytes: "BYTE_CONTINUITY", mode: "MODE", link: "LINK_COUNT" };
|
||||
if (transition === "link") {
|
||||
expect(stderr).toContain("PAPERCLIP_ACPX_LINK_COUNT:1:1:2:I:11111110:END");
|
||||
expect(acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { detailCode: "AGENT_STARTUP_FAILED", stderrSummary: stderr.replace(/\s+/gu, " ") })))
|
||||
.toBe("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT.READ.1.1.2.I.11111110");
|
||||
}
|
||||
const fence = fences[transition];
|
||||
if (fence && !(transition === "bytes" && process.platform === "darwin")) {
|
||||
expect(stderr).toContain(`PAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:${fence}:END\n`);
|
||||
|
||||
@@ -1870,9 +1870,9 @@ export function readStableSnapshotModuleSource(input: {
|
||||
verify: (bytes: Buffer) => void;
|
||||
maxBytes: number;
|
||||
}): Buffer {
|
||||
const reject = (moduleFence: string): never => {
|
||||
const reject = (moduleFence: string, moduleReadDiagnostic?: string): never => {
|
||||
throw Object.assign(new Error("ACPX provider module changed while it was read"), {
|
||||
code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence,
|
||||
code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence, ...(moduleReadDiagnostic ? { moduleReadDiagnostic } : {}),
|
||||
});
|
||||
};
|
||||
let before = input.before;
|
||||
@@ -1898,7 +1898,17 @@ export function readStableSnapshotModuleSource(input: {
|
||||
if (before.mode !== after.mode) reject("MODE");
|
||||
if (before.uid !== after.uid) reject("UID");
|
||||
if (before.gid !== after.gid) reject("GID");
|
||||
if (before.nlink !== after.nlink) reject("LINK_COUNT");
|
||||
if (before.nlink !== after.nlink) {
|
||||
// Failure-only data: never admit a link topology change as hydration.
|
||||
const bounded = before.nlink >= 0n && after.nlink >= 0n
|
||||
&& before.nlink <= 18446744073709551615n && after.nlink <= 18446744073709551615n;
|
||||
const equal = [before.dev === after.dev, before.ino === after.ino, before.mode === after.mode,
|
||||
before.uid === after.uid, before.gid === after.gid, before.size === after.size,
|
||||
before.mtimeNs === after.mtimeNs, before.ctimeNs === after.ctimeNs].map(value => value ? "1" : "0").join("");
|
||||
reject("LINK_COUNT", bounded
|
||||
? `${attempt + 1}:${before.nlink}:${after.nlink}:${after.nlink > before.nlink ? "I" : "D"}:${equal}`
|
||||
: undefined);
|
||||
}
|
||||
if (before.mtimeNs !== after.mtimeNs) reject("MTIME");
|
||||
if (first !== null && !first.equals(bytes)) reject("BYTE_CONTINUITY");
|
||||
if (before.ctimeNs === after.ctimeNs) return bytes;
|
||||
@@ -2060,7 +2070,7 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string {
|
||||
guarded
|
||||
? "const exitOnImportFailure = reapOnGuardianLoss;"
|
||||
: "const exitOnImportFailure = process.exit.bind(process);",
|
||||
'import(target).catch((error) => { let category = "OTHER"; try { if (error?.code === "ERR_ACPX_UNVERIFIED_MODULE") { category = "UNVERIFIED_MODULE"; const fence = error.moduleFence; if (["RESOLUTION", "OPEN", "BOUNDS", "ANCESTRY", "FORMAT", "PATH_IDENTITY", "READ_RESULT", "SIZE", "DEVICE", "INODE", "MODE", "UID", "GID", "LINK_COUNT", "MTIME", "BYTE_CONTINUITY", "REPEATED_CTIME"].includes(fence)) category += ":" + fence; } } catch {} try { writeImportFailure(2, "PAPERCLIP_ACPX_IMPORT_FAILURE:" + category + ":END\\n"); console.error(error); } finally { exitOnImportFailure(1); } });',
|
||||
'import(target).catch((error) => { let category = "OTHER"; try { if (error?.code === "ERR_ACPX_UNVERIFIED_MODULE") { category = "UNVERIFIED_MODULE"; const fence = error.moduleFence; if (["RESOLUTION", "OPEN", "BOUNDS", "ANCESTRY", "FORMAT", "PATH_IDENTITY", "READ_RESULT", "SIZE", "DEVICE", "INODE", "MODE", "UID", "GID", "LINK_COUNT", "MTIME", "BYTE_CONTINUITY", "REPEATED_CTIME"].includes(fence)) category += ":" + fence; } } catch {} try { writeImportFailure(2, "PAPERCLIP_ACPX_IMPORT_FAILURE:" + category + ":END\\n"); const detail = error?.moduleReadDiagnostic; if (category === "UNVERIFIED_MODULE:LINK_COUNT" && typeof detail === "string" && /^[12]:(0|[1-9][0-9]{0,19}):(0|[1-9][0-9]{0,19}):[ID]:111111[01]{2}$/.test(detail)) writeImportFailure(2, "PAPERCLIP_ACPX_LINK_COUNT:" + detail + ":END\\n"); console.error(error); } finally { exitOnImportFailure(1); } });',
|
||||
].join("");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user