mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
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:
1 parent
f49a9ed4b5
commit
45f591120d
9 files changed
+128
-39
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user