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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 10:37:34 -05:00
1 parent f49a9ed4b5
commit 45f591120d
9 files changed
+128 -39

No files matched your search

+3 -1
View File
@@ -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
@@ -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<Value, LocalRunnerError> {
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::*;
@@ -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) =>
@@ -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");
});
});
@@ -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<string, unknown>;
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(""),
@@ -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;
@@ -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");
}
@@ -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 } : {}),
@@ -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,