From 45f591120dc78a080f347f3d3e4ad4fd05a19993 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 7 Oct 2026 10:37:34 -0500 Subject: [PATCH] fix(runner): bound encoded Hermes attachment messages Count serialized message, attachments, and metadata before turn admission. Keep TypeScript and Rust on a 7 MiB encoded content allowance within the existing encrypted frame bound. Validate before active-turn state changes, and prove accepted images and escaped documents traverse the secure PRP command frame. Validation: 28 attachment/permission tests, two encrypted-frame regressions, the Rust encoded-admission regression, and Runner TypeScript compilation passed. Co-Authored-By: Paperclip --- packages/paperclip-runner/docs/hermes.md | 4 +- .../runner-core/src/acpx_provider_session.rs | 98 ++++++++++++++----- .../src/cli/acpx-runtime-sidecar.ts | 9 +- .../src/contracts/user-attachments.test.ts | 16 ++- .../src/contracts/user-attachments.ts | 14 ++- .../durable-prp-control-plane.test.ts | 16 ++- .../src/drivers/acpx/codex-acpx-driver.ts | 2 + .../src/drivers/acpx/runtime-host.ts | 5 +- .../src/live/runnerd-codex-transport.ts | 3 +- 9 files changed, 128 insertions(+), 39 deletions(-) diff --git a/packages/paperclip-runner/docs/hermes.md b/packages/paperclip-runner/docs/hermes.md index 07f60f6e19..34102634ae 100644 --- a/packages/paperclip-runner/docs/hermes.md +++ b/packages/paperclip-runner/docs/hermes.md @@ -70,7 +70,9 @@ changes within the same run are rejected. - Reasoning and assistant text use distinct message identities. Native tool call IDs preserve overlapping calls and edit snapshots. - Images accept PNG, JPEG, WebP and GIF; documents accept UTF-8 text/Markdown. - Limits are eight attachments, 2 MiB per item and 4 MiB total. The controller + Limits are eight attachments, 2 MiB per item and 4 MiB total. The combined + message and attachments also have a 7 MiB JSON-encoded limit, including + escaping and metadata, to fit the encrypted Runner command frame. The controller authorizes and reads content before the turn; URLs and arbitrary local paths are rejected. Models without image support can reject image input. - The negotiated `_meta.paperclipHermes.version = 1` extension uses ACP wire diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index fbd68cf4bc..4b787104bf 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -25,6 +25,8 @@ use crate::stable_identity::{is_stable_id, DURABLE_STABLE_ID_CHARS, SHORT_STABLE const MAX_ID_CHARS: usize = 240; const MAX_MODEL_CHARS: usize = 240; const MAX_SYSTEM_INSTRUCTIONS_BYTES: usize = 1024 * 1024; +// Matches the TS attachment contract; retain command/encrypted-frame headroom. +const MAX_NATIVE_USER_MESSAGE_JSON_BYTES: usize = 7 * 1024 * 1024; const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991; #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -355,32 +357,7 @@ impl AcpxProviderSession { working_directory: &Path, attachments: Option<&Value>, ) -> Result { - if let Some(value) = attachments { - let entries = value - .as_array() - .ok_or_else(|| LocalRunnerError::invalid("Native attachments must be an array"))?; - if self.config.agent != "hermes" - || entries.len() > 8 - || value.to_string().len() > 12 * 1024 * 1024 - { - return Err(LocalRunnerError::invalid( - "Native attachments are unsupported or exceed their bound", - )); - } - for entry in entries { - if entry.get("schema").and_then(Value::as_str) - != Some("paperclip.user_attachment.v1") - || !matches!( - entry.get("kind").and_then(Value::as_str), - Some("image" | "text") - ) - { - return Err(LocalRunnerError::invalid( - "Native attachment contract is invalid", - )); - } - } - } + validate_turn_attachments(&self.config.agent, message, attachments)?; self.ensure_open()?; if self.runtime_retired { return Err(LocalRunnerError::invalid( @@ -1479,6 +1456,47 @@ fn validate_turn_message(value: &str) -> Result<(), LocalRunnerError> { Ok(()) } +fn validate_turn_attachments( + agent: &str, + message: &str, + attachments: Option<&Value>, +) -> Result<(), LocalRunnerError> { + let Some(value) = attachments else { + return Ok(()); + }; + let entries = value + .as_array() + .ok_or_else(|| LocalRunnerError::invalid("Native attachments must be an array"))?; + if agent != "hermes" || entries.len() > 8 { + return Err(LocalRunnerError::invalid( + "Native attachments are unsupported or exceed their bound", + )); + } + if !entries.is_empty() + && json!({"text":message,"attachments":value}) + .to_string() + .len() + > MAX_NATIVE_USER_MESSAGE_JSON_BYTES + { + return Err(LocalRunnerError::invalid( + "Message and attachments exceed the encoded content limit; shorten the message or remove an attachment", + )); + } + for entry in entries { + if entry.get("schema").and_then(Value::as_str) != Some("paperclip.user_attachment.v1") + || !matches!( + entry.get("kind").and_then(Value::as_str), + Some("image" | "text") + ) + { + return Err(LocalRunnerError::invalid( + "Native attachment contract is invalid", + )); + } + } + Ok(()) +} + fn validate_input_resolution( question_set: &Value, resolution: &Value, @@ -1529,6 +1547,34 @@ fn with_cleanup_error( #[cfg(test)] mod tests { + #[test] + fn attachment_admission_counts_json_escapes_and_combined_message() { + use super::*; + let attachment = |text: String| { + json!({"schema":"paperclip.user_attachment.v1", + "kind":"text","mediaType":"text/plain","name":"notes.txt","text":text}) + }; + for character in ["\\", "\u{0001}"] { + let value = json!([ + attachment(character.repeat(2 * 1024 * 1024)), + attachment(character.repeat(2 * 1024 * 1024)) + ]); + assert!(validate_turn_attachments("hermes", "Read", Some(&value)).is_err()); + } + let value = json!([attachment("a".repeat(2 * 1024 * 1024))]); + assert!(validate_turn_attachments("hermes", "Read", Some(&value)).is_ok()); + assert!( + validate_turn_attachments("hermes", &"\u{0001}".repeat(1024 * 1024), Some(&value)) + .is_err() + ); + let fixed = json!({"text":"","attachments":value}).to_string().len(); + let boundary = "a".repeat(MAX_NATIVE_USER_MESSAGE_JSON_BYTES - fixed); + assert!(validate_turn_attachments("hermes", &boundary, Some(&value)).is_ok()); + assert!(validate_turn_attachments("hermes", &(boundary + "a"), Some(&value)).is_err()); + assert!(validate_turn_attachments("codex", "Read", Some(&value)).is_err()); + assert!(validate_turn_attachments("codex", "Read", None).is_ok()); + } + #[test] fn turn_controls_require_exact_live_pi_capability_fields() { use super::*; diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index c2bb2a6b0d..2aaeb51cb8 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -18,7 +18,7 @@ import type { import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "../drivers/acpx/profile-extensions.js"; import type { PaperclipQuestionSet } from "../contracts/question-set.js"; import { createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js"; -import { parseNativeUserAttachments } from "../contracts/user-attachments.js"; +import { parseNativeUserAttachments, validateNativeUserMessageSize } from "../contracts/user-attachments.js"; import { parseNativeRuntimeContext } from "../contracts/runtime-context.js"; import { PRP_BLOCK_TOOL_NAME, @@ -359,6 +359,9 @@ async function dispatch( if (!runId) throw new Error("attach a run before starting an ACPX turn"); if (turnId) throw new Error("ACPX sidecar already has an active turn"); const currentTurnId = boundedIdentity(request.params.turnId, "turnId"); + const message = boundedText(request.params.message, "message", 1024 * 1024); + const attachments = parseNativeUserAttachments(request.params.attachments); + validateNativeUserMessageSize(message, attachments); const activeAgent = openParams!.agent; turnId = currentTurnId; turnControls.begin(currentTurnId); @@ -385,8 +388,8 @@ async function dispatch( usageBefore = await readSidecarHostStatusWithin(activeHost); runtimeTurn = activeHost.startTurn({ requestId: `${runId}:${currentTurnId}`, - text: boundedText(request.params.message, "message", 1024 * 1024), - attachments: parseNativeUserAttachments(request.params.attachments), + text: message, + attachments, onExtensionRequest: extensions.onExtensionRequest, onExtensionNotification: extensions.onExtensionNotification, onElicitation: (providerRequest, context) => diff --git a/packages/paperclip-runner/src/contracts/user-attachments.test.ts b/packages/paperclip-runner/src/contracts/user-attachments.test.ts index e005e026f3..e13a34f679 100644 --- a/packages/paperclip-runner/src/contracts/user-attachments.test.ts +++ b/packages/paperclip-runner/src/contracts/user-attachments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { acpxAttachmentInput, parseNativeUserAttachments, MAX_NATIVE_ATTACHMENT_BYTES } from "./user-attachments.js"; +import { acpxAttachmentInput, parseNativeUserAttachments, MAX_NATIVE_ATTACHMENT_BYTES, MAX_NATIVE_USER_MESSAGE_JSON_BYTES, validateNativeUserMessageSize } from "./user-attachments.js"; describe("authorized native attachment contract", () => { const text = { schema: "paperclip.user_attachment.v1", kind: "text", mediaType: "text/markdown", name: "notes.md", text: "Inspect this document" }; it("keeps ordinary message calls backward compatible", () => { @@ -18,4 +18,18 @@ describe("authorized native attachment contract", () => { expect(parseNativeUserAttachments([text])[0]).not.toBe(text); expect(() => parseNativeUserAttachments(Array(5).fill({ ...text, text: "a".repeat(MAX_NATIVE_ATTACHMENT_BYTES) }))).toThrow(); }); + it.each(["\\", "\u0001"])("rejects encoded text expansion before provider dispatch (%j)", character => { + // Raw content stays within both limits; JSON escaping exceeds the wire budget. + const attachments = Array(2).fill({ ...text, text: character.repeat(MAX_NATIVE_ATTACHMENT_BYTES) }); + expect(() => parseNativeUserAttachments(attachments)).toThrow("encoded content limit"); + }); + it("counts the message and attachment metadata in the same encoded budget", () => { + const attachments = parseNativeUserAttachments([{ ...text, text: "a".repeat(MAX_NATIVE_ATTACHMENT_BYTES) }]); + const message = "\u0001".repeat(1024 * 1024); + expect(() => validateNativeUserMessageSize(message, attachments)).toThrow("encoded content limit"); + expect(() => acpxAttachmentInput(message, attachments)).toThrow("encoded content limit"); + const boundary = "a".repeat(MAX_NATIVE_USER_MESSAGE_JSON_BYTES - Buffer.byteLength(JSON.stringify({ text: "", attachments }))); + expect(() => validateNativeUserMessageSize(boundary, attachments)).not.toThrow(); + expect(() => validateNativeUserMessageSize(boundary + "a", attachments)).toThrow("encoded content limit"); + }); }); diff --git a/packages/paperclip-runner/src/contracts/user-attachments.ts b/packages/paperclip-runner/src/contracts/user-attachments.ts index 83358b75ed..7757fee3df 100644 --- a/packages/paperclip-runner/src/contracts/user-attachments.ts +++ b/packages/paperclip-runner/src/contracts/user-attachments.ts @@ -4,11 +4,20 @@ export type NativeUserAttachment = { schema: "paperclip.user_attachment.v1"; nam | { kind: "text"; mediaType: "text/plain" | "text/markdown"; text: string }); export const MAX_NATIVE_ATTACHMENT_BYTES = 2 * 1024 * 1024; export const MAX_NATIVE_ATTACHMENTS_TOTAL_BYTES = 4 * 1024 * 1024; +// turn.start fits in the 16 MiB secure frame after ciphertext hex encoding. +// Reserve 1 MiB of its plaintext allowance for command/session framing. Count +// JSON bytes, since escapes can multiply otherwise valid text content. +export const MAX_NATIVE_USER_MESSAGE_JSON_BYTES = 7 * 1024 * 1024; +export function validateNativeUserMessageSize(text: string, attachments: NativeUserAttachment[]): void { + if (attachments.length && Buffer.byteLength(JSON.stringify({ text, attachments })) > MAX_NATIVE_USER_MESSAGE_JSON_BYTES) { + throw new Error("Message and attachments exceed the encoded content limit; shorten the message or remove an attachment"); + } +} export function parseNativeUserAttachments(value: unknown): NativeUserAttachment[] { if (value === undefined) return []; if (!Array.isArray(value) || value.length > 8) throw new Error("At most eight native attachments are supported"); let total = 0; - return value.map(raw => { + const attachments = value.map(raw => { if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid native attachment"); const a = raw as Record; const keys = a.kind === "image" ? ["schema", "name", "kind", "mediaType", "data"] : ["schema", "name", "kind", "mediaType", "text"]; @@ -30,9 +39,12 @@ export function parseNativeUserAttachments(value: unknown): NativeUserAttachment if (!bytes || bytes > MAX_NATIVE_ATTACHMENT_BYTES || total > MAX_NATIVE_ATTACHMENTS_TOTAL_BYTES) throw new Error("Native attachments exceed the per-file or total content limit"); return structuredClone(a) as NativeUserAttachment; }); + validateNativeUserMessageSize("", attachments); + return attachments; } export function acpxAttachmentInput(text: string, value: unknown) { const attachments = parseNativeUserAttachments(value); + validateNativeUserMessageSize(text, attachments); const images = attachments.flatMap(a => a.kind === "image" ? [{ mediaType: a.mediaType, data: a.data }] : []); return { text: text + attachments.map(a => a.kind === "text" ? `\n\nAttached document (${JSON.stringify(a.name)}):\n${a.text}` : "").join(""), diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index 3d7c8f844b..b2cb8d1a34 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -24,6 +24,7 @@ import { createCapabilityRunnerdCodexTransport, createCapabilityRunnerdProviderE import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js"; import { validatePrpEvent } from "../protocol/replay-contract.js"; import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js"; +import { parseNativeUserAttachments, validateNativeUserMessageSize } from "../contracts/user-attachments.js"; import { DurablePrpControlPlane, durableRecoveryInternals, @@ -2419,17 +2420,22 @@ describe("DurablePrpControlPlane", () => { } finally { await core.stop(); rmSync(root, { recursive: true, force: true }); } }); - it("delivers attachment-sized turn input through the encrypted command frame", async () => { + it.each(["images", "escaped text"])("delivers admitted %s attachments through the encrypted command frame", async kind => { const root = mkdtempSync(resolve(tmpdir(), "paperclip-attachment-frame-")); const core = new DurablePrpControlPlane({ stateDirectory: root, identity, expectedRunnerVersion, expectedRunnerDigest }); try { await core.start(); const client = (await authenticate(core, core.issueBootstrapTicket()))!; - const data = Buffer.alloc(2 * 1024 * 1024).toString("base64"); - const payload = { text: "Inspect both images", attachments: [ - { kind: "image", data }, { kind: "image", data }, - ] }; + const bytes = Buffer.alloc(2 * 1024 * 1024); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes); + const image = { schema: "paperclip.user_attachment.v1", name: "image.png", kind: "image", mediaType: "image/png", data: bytes.toString("base64") }; + const document = { schema: "paperclip.user_attachment.v1", name: "notes.txt", kind: "text", mediaType: "text/plain" }; + const attachments = parseNativeUserAttachments(kind === "images" ? [image, image] : [ + { ...document, text: "\\".repeat(2 * 1024 * 1024) }, { ...document, text: "a".repeat(2 * 1024 * 1024) }, + ]); + const payload = { text: "\\".repeat(128 * 1024), attachments }; + validateNativeUserMessageSize(payload.text, attachments); const pending = receiveSecure(client); core.queueCommand("turn.start", payload, undefined, true); const wire = await pending; diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index dacba37a1c..0e377710a4 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -40,6 +40,7 @@ import { import { PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2 } from "../../contracts/question-set.js"; import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js"; import type { NativeUserMessage } from "../../contracts/types.js"; +import { parseNativeUserAttachments, validateNativeUserMessageSize } from "../../contracts/user-attachments.js"; import type { PrpEvent, PrpStructuredRunResult, @@ -861,6 +862,7 @@ class CodexAcpxSession implements HarnessSession { message: NativeUserMessage; }): Promise<{ turnId: string }> { this.#assertOpen(); + validateNativeUserMessageSize(input.message.text, parseNativeUserAttachments(input.message.attachments)); if (this.#activeTurnId) { throw new Error("Codex ACPX session already has an active turn"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 804d19051f..32b92c126e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -9,6 +9,7 @@ import { stageManagedHermesCredential } from "./hermes-credentials.js"; import { stageHermesAgentState } from "./hermes-state.js"; import { claudeNativeSkillPrompt } from "./native-skill-prompt.js"; import { nativeMcpLaunchBinding } from "../native-mcp.js"; +import { parseNativeUserAttachments, validateNativeUserMessageSize } from "../../contracts/user-attachments.js"; import type { AcpElicitationHandler, @@ -818,10 +819,12 @@ export class AcpxRuntimeHost { const text = claudeNativeSkillPrompt( boundedTurnText(input.text), this.#claudeSkillNames, ); + const attachments = parseNativeUserAttachments(input.attachments); + validateNativeUserMessageSize(text, attachments); const turn = this.#runtime.startTurn({ text, requestId, - ...(input.attachments ? { attachments: input.attachments } : {}), + ...(attachments.length ? { attachments } : {}), ...(input.signal ? { signal: input.signal } : {}), ...(input.onElicitation ? { onElicitation: input.onElicitation } : {}), ...(input.onPermissionRequest ? { onPermissionRequest: input.onPermissionRequest } : {}), diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 7514da9c47..7fa34b7493 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -6,7 +6,7 @@ import { codexExecutableReadOnlyRoots } from "../drivers/codex/codex-security-co import { isCanonicalProviderEventType } from "../provider-events.js"; import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { parseNativeUserAttachments } from "../contracts/user-attachments.js"; +import { parseNativeUserAttachments, validateNativeUserMessageSize } from "../contracts/user-attachments.js"; import { appendFileSync, existsSync, @@ -5751,6 +5751,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { .filter((item) => item.type !== "skill") .map((item) => (typeof item.text === "string" ? item.text : "")) .join("\n"); + validateNativeUserMessageSize(message, attachments); const skills = resolveRunnerdCodexSkillInputs( input.filter((item) => item.type === "skill"), this.options.runtimeContext ?? null,