feat(runner): normalize ACPX provider events (#12416)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Rust runner now admits ACPX sidecar frames only after transport,
scope, and payload validation
> - Valid payloads still contain provider-native runtime event shapes
> - Provider-native shapes must not cross the PRP boundary or diverge
from direct Codex task activity
> - This pull request maps the display-safe runtime subset into existing
provider-neutral event families
> - Stateful semantic-result, terminal, and reasoning-deduplication
behavior remains reserved for the later provider adapter
> - The benefit is a reviewable normalization boundary without selecting
ACPX in production

## Linked Issues or Issue Description

Refs #12415

Refs #12414

## What Changed

- Normalize validated ACPX text, reasoning, plan, status, tool, notice,
and error updates into existing PRP activity families.
- Keep reasoning contents private while preserving a reasoning activity
boundary.
- Map plan entries, usage, review-mode status, and tool lifecycle into
bounded canonical payloads.
- Generate one shared ACPX sidecar event/classification contract for
TypeScript and Rust, with ASCII-only classification parity and bounded
kind/title fields.
- Preserve authoritative tool-call identity and classification even when
the aggregate native event exceeds the generic frame budget.
- Resolve display-only tool targets within the workspace under the
provider host's path semantics; reject raw, unmarked, absolute,
parent-traversing, URL-shaped, and unsafe drive-shaped values.
- Redact and digest retained tool output with the existing durable
policy.
- Ignore provider inventory status updates that have no user-facing
activity.
- Leave semantic results and `done` updates to the stateful adapter so
durable receipts and terminal events are not duplicated.
- Add cross-language and Rust coverage for every mapping family,
classifier parity, privacy, unsafe paths, redaction, bounded
titles/kinds, and oversized tool-call preservation.
- Document the normalization and display-path authorization boundary.
- Do not change dependencies, lockfiles, workflows, runnerd selection,
server behavior, UI, or migrations.

## Verification

- Replay base: `fe2ddfad2b5cb604b3244492257db0e6aec11d47` (`master`
after #12415 merged).
- Exact replay head: `b7f5588bf6e8e0f946ffa8869a3204c344808418`.
- Stable patch ID: `fda62c7c20afc5ef9c75d07f163a466db82efabd`, identical
to the prepared six-commit delta plus the focused oversized-tool-call
review fix.
- The exact delta is 14 files, 1,714 additions, and 60 deletions, all in
`packages/paperclip-runner`; it contains no lockfile, workflow, server,
UI, or migration change.
- Focused protocol-generation, TypeScript sidecar, Rust normalization,
package, repository, security, and Greptile checks: **PASSED** on the
replayed exact head. Full CI run `33361437835` completed 23/23 jobs
successfully, Greptile is exact-head 5/5, all security checks pass, and
no review threads remain unresolved.
- No local test result is claimed. GitHub Actions is the authoritative
verification environment for this replayed revision.

## Risks

- This code controls what provider activity is retained and displayed,
so malformed native values must not bypass the earlier decoder.
- The function contract requires an already scope-checked and
payload-validated runtime event; the future adapter must preserve that
order.
- Tool classification and identity are security-relevant authorization
inputs and remain explicit even when optional aggregate display data is
dropped for bounds.
- Repeated reasoning chunks require stateful suppression. This mapper
exposes a privacy-safe start boundary and the later adapter owns
per-turn deduplication.
- Semantic results and terminal authority intentionally produce no
activity here; the later adapter must commit them through the durable
operational paths.
- The package exports new generated and Rust normalization surfaces, but
no production path invokes them in this pull request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with GPT-5.6, agentic reasoning, tool use, and code
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-08-31 00:52:41 -05:00 committed by GitHub
parent fe2ddfad2b
commit 7bb6cebeae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1714 additions and 60 deletions

View File

@ -70,6 +70,19 @@ validates control identities, terminal status, question sets, and the admitted
runtime event types and bounded fields. It redacts diagnostic and retained
event values again before they can enter provider state.
Validated ACPX runtime events normalize into the same provider-neutral activity
families as the direct Codex transport. Reasoning contents stay private. Tool
targets are resolved within the workspace under the provider host's path
semantics and receive a versioned sidecar boundary marker before becoming
bounded, display-only PRP safe paths. Raw or unmarked provider locations fail
closed. URI-scheme and Windows drive-shaped values require a separate sidecar
attestation backed by an existing in-workspace entry or, for a not-yet-created
edit target, an existing in-workspace parent. This preserves real POSIX colon
filenames without treating arbitrary URI text as a path. Windows separators
are canonicalized, and consumers must not reinterpret the display value as
file-access authority. Operational semantic-result and terminal events remain
reserved for the stateful adapter rather than being duplicated.
Run the complete contract gate with:
```sh

View File

@ -5,7 +5,9 @@ use serde_json::Value;
use crate::acpx_event_scope::AcpxEventScope;
use crate::acpx_sidecar_transport::AcpxSidecarEvent;
use crate::durable::{redact_text, sanitize_value};
use crate::generated_acpx_sidecar_contract::GeneratedAcpxSidecarEventType;
use crate::generated_acpx_sidecar_contract::{
classify_generated_acpx_tool_operation, GeneratedAcpxSidecarEventType,
};
use crate::local_runner::LocalRunnerError;
const MAX_EVENT_PAYLOAD_BYTES: usize = 256 * 1024;
@ -37,6 +39,7 @@ pub enum AcpxTurnStatus {
pub enum AcpxEventPayload {
Runtime {
kind: AcpxRuntimeEventKind,
tool_operation: Option<&'static str>,
payload: Value,
},
PermissionRequested {
@ -213,6 +216,7 @@ fn decode_runtime_event(payload: &Value) -> Result<AcpxEventPayload, LocalRunner
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| LocalRunnerError::invalid("ACPX runtime event omitted its type"))?;
let mut tool_operation = None;
let kind = match runtime_type {
"text_delta" => {
bounded_required_text(payload, "text", MAX_RUNTIME_TEXT_CHARS, "runtime text")?;
@ -233,7 +237,7 @@ fn decode_runtime_event(payload: &Value) -> Result<AcpxEventPayload, LocalRunner
}
"tool_call" => {
bounded_optional_text(payload, "toolCallId", 240, "runtime tool call id")?;
bounded_optional_text(payload, "title", 4_000, "runtime tool title")?;
let title = bounded_optional_text(payload, "title", 4_000, "runtime tool title")?;
bounded_optional_text(payload, "status", 100, "runtime tool status")?;
if let Some(locations) = optional_array(payload, "locations", "runtime tool locations")?
{
@ -251,6 +255,23 @@ fn decode_runtime_event(payload: &Value) -> Result<AcpxEventPayload, LocalRunner
bounded_optional_text(location, "path", 4_000, "runtime tool path")?;
}
}
tool_operation = Some(match payload.get("toolOperation") {
Some(Value::String(operation)) => {
bounded_optional_text(payload, "kind", 4_000, "runtime tool kind")?;
admitted_tool_operation(operation).ok_or_else(|| {
LocalRunnerError::invalid("ACPX runtime tool operation is not admitted")
})?
}
Some(_) => {
return Err(LocalRunnerError::invalid(
"ACPX runtime tool operation must be text",
))
}
None => classify_generated_acpx_tool_operation(
payload.get("kind").and_then(Value::as_str).unwrap_or(""),
title.as_deref().unwrap_or(""),
),
});
AcpxRuntimeEventKind::ToolCall
}
"semantic_result" => {
@ -285,10 +306,19 @@ fn decode_runtime_event(payload: &Value) -> Result<AcpxEventPayload, LocalRunner
};
Ok(AcpxEventPayload::Runtime {
kind,
tool_operation,
payload: sanitize_value(payload),
})
}
fn admitted_tool_operation(operation: &str) -> Option<&'static str> {
if operation == "unknown" {
return Some("unknown");
}
let classified = classify_generated_acpx_tool_operation(operation, "");
(classified == operation).then_some(classified)
}
fn validate_plan(payload: &Value) -> Result<(), LocalRunnerError> {
let entries = payload
.get("entries")

View File

@ -2,6 +2,29 @@
pub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = 2;
pub const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE: &[(&str, &[&str])] = &[
("edit", &["edit", "write", "patch"]),
("read", &["read"]),
("search", &["search", "grep", "find"]),
("list", &["list", "glob"]),
];
pub fn classify_generated_acpx_tool_operation(kind: &str, title: &str) -> &'static str {
let kind = kind.to_ascii_lowercase();
let title = title.to_ascii_lowercase();
let candidate = if kind.is_empty() { &title } else { &kind };
for (operation, tokens) in GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE {
if tokens.iter().any(|token| candidate.contains(token)) {
return operation;
}
}
if candidate.is_empty() {
"unknown"
} else {
"execute"
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GeneratedAcpxSidecarCommand {
Initialize,

View File

@ -1,6 +1,7 @@
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use crate::acpx_event_payload::AcpxRuntimeEventKind;
use crate::durable::{redact_text, EventPriority};
const MAX_TEXT_CHARS: usize = 4_000;
@ -344,10 +345,439 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
events
}
/// Converts an already scope-checked and payload-validated ACPX runtime event
/// into provider-neutral PRP activity. Operational events such as semantic
/// results and turn completion remain owned by the stateful provider adapter.
/// That adapter also suppresses repeated reasoning-start boundaries in a turn.
pub fn normalize_acpx_runtime_event(
kind: AcpxRuntimeEventKind,
payload: &Value,
tool_operation: Option<&str>,
fallback_item_id: &str,
turn_id: &str,
provider_requests: u64,
) -> Vec<NormalizedProviderEvent> {
let item_id = stable_id(
match kind {
AcpxRuntimeEventKind::ToolCall => string(payload.get("toolCallId")),
AcpxRuntimeEventKind::Plan => turn_id,
_ => string(payload.get("messageId")),
},
fallback_item_id,
);
match kind {
AcpxRuntimeEventKind::TextDelta => vec![NormalizedProviderEvent {
event_type: "item.delta".to_owned(),
priority: EventPriority::P2,
payload: json!({
"provider": "acpx",
"itemId": item_id,
"kind": "agentMessage",
"channel": "progress",
"providerMethod": "runtime.event",
"text": bounded_text(string(payload.get("text")), MAX_TEXT_CHARS),
}),
}],
AcpxRuntimeEventKind::Thinking => vec![NormalizedProviderEvent {
event_type: "item.started".to_owned(),
priority: EventPriority::P2,
payload: json!({
"provider": "acpx",
"itemId": item_id,
"kind": "reasoning",
"status": "running",
"channel": "detail",
"text": Value::Null,
}),
}],
AcpxRuntimeEventKind::Plan => normalize_acpx_plan(payload, &item_id),
AcpxRuntimeEventKind::Status => {
normalize_acpx_status(payload, &item_id, turn_id, provider_requests)
}
AcpxRuntimeEventKind::ToolCall => {
normalize_acpx_tool_call(payload, &item_id, tool_operation.unwrap_or("unknown"))
}
AcpxRuntimeEventKind::ProviderNotice => vec![acpx_notice(
&item_id,
string(payload.get("severity")),
string(payload.get("category")),
string(payload.get("summary")),
false,
)],
AcpxRuntimeEventKind::Error => vec![acpx_notice(
&item_id,
"error",
string(payload.get("code")),
string(payload.get("message")),
true,
)],
AcpxRuntimeEventKind::SemanticResult | AcpxRuntimeEventKind::Done => Vec::new(),
}
}
fn normalize_acpx_plan(payload: &Value, plan_id: &str) -> Vec<NormalizedProviderEvent> {
let steps = payload
.get("entries")
.and_then(Value::as_array)
.into_iter()
.flatten()
.take(256)
.enumerate()
.filter_map(|(index, entry)| {
let body = bounded_text(string(entry.get("content")), MAX_TEXT_CHARS);
if body.trim().is_empty() {
return None;
}
Some(json!({
"stepId": format!("step-{}", index + 1),
"body": body,
"status": match string(entry.get("status")) {
"inProgress" | "in_progress" => "in_progress",
"completed" => "completed",
"blocked" | "failed" | "error" => "blocked",
_ => "pending",
},
}))
})
.collect::<Vec<_>>();
let complete = !steps.is_empty()
&& steps
.iter()
.all(|step| step.get("status").and_then(Value::as_str) == Some("completed"));
vec![NormalizedProviderEvent {
event_type: "plan.updated".to_owned(),
priority: EventPriority::P1,
payload: json!({
"schema": "paperclip.plan.updated.v1",
"planId": plan_id,
"revision": 1,
"explanation": Value::Null,
"steps": steps,
"complete": complete,
"syncStatus": "not_applicable",
"documentRevision": Value::Null,
}),
}]
}
fn normalize_acpx_status(
payload: &Value,
item_id: &str,
turn_id: &str,
provider_requests: u64,
) -> Vec<NormalizedProviderEvent> {
let tag = string(payload.get("tag"));
if tag == "usage_update" {
let breakdown = payload.get("breakdown").unwrap_or(&Value::Null);
let usage = json!({
"inputTokens": nonnegative_u64(breakdown.get("inputTokens")),
"outputTokens": nonnegative_u64(breakdown.get("outputTokens")),
"cacheReadTokens": nonnegative_u64(
breakdown
.get("cachedReadTokens")
.or_else(|| breakdown.get("cacheReadTokens")),
),
"cacheWriteTokens": nonnegative_u64(
breakdown
.get("cachedWriteTokens")
.or_else(|| breakdown.get("cacheWriteTokens")),
),
"activeSeconds": 0.0,
"requests": provider_requests,
"providerCostUsd": payload
.pointer("/cost/amount")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0),
});
return vec![NormalizedProviderEvent {
event_type: "usage.reported".to_owned(),
priority: EventPriority::P0,
payload: json!({
"provider": "acpx",
"model": payload
.get("model")
.and_then(Value::as_str)
.map(|value| bounded_text(value, 240)),
"providerSessionId": Value::Null,
"providerRequestId": Value::Null,
"cumulative": usage,
"runDelta": usage,
}),
}];
}
if tag == "current_mode_update" {
let status = string(payload.get("text"));
return vec![NormalizedProviderEvent {
event_type: "review.mode.changed".to_owned(),
priority: EventPriority::P1,
payload: json!({
"schema": "paperclip.review.mode_changed.v1",
"reviewId": stable_id(turn_id, item_id),
"state": if status.to_ascii_lowercase().contains("review")
|| status.to_ascii_lowercase().contains("plan")
{
"entered"
} else {
"exited"
},
"scope": if status.is_empty() {
Value::Null
} else {
Value::String(bounded_text(status, MAX_TEXT_CHARS))
},
}),
}];
}
if matches!(
tag,
"available_commands_update" | "config_option_update" | "session_info_update"
) {
return Vec::new();
}
vec![acpx_notice(
item_id,
"info",
tag,
string(payload.get("text")),
false,
)]
}
fn normalize_acpx_tool_call(
payload: &Value,
item_id: &str,
operation: &str,
) -> Vec<NormalizedProviderEvent> {
let native_status = string(payload.get("status"));
let status = provider_status(native_status, native_status == "completed");
let terminal = status != "running";
let raw_title = string(payload.get("title"));
let title = bounded_text(raw_title, 240);
let output = match payload.get("rawOutput").or_else(|| payload.get("output")) {
Some(Value::String(value)) => value.clone(),
Some(value) => serde_json::to_string(value).unwrap_or_default(),
None => String::new(),
};
let mut normalized = json!({
"schema": "paperclip.tool.execution.v1",
"executionId": item_id,
"transport": "builtin",
"operation": operation,
"name": if title.is_empty() { Value::Null } else { Value::String(title) },
"target": safe_acpx_location(payload.pointer("/locations/0"), operation == "edit"),
"namespace": Value::Null,
"readOnly": matches!(operation, "read" | "search" | "list"),
"status": status,
"durationMs": Value::Null,
"exitCode": Value::Null,
"progress": if terminal {
Value::Null
} else {
payload
.get("text")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(|value| Value::String(bounded_text(value, MAX_TEXT_CHARS)))
.unwrap_or(Value::Null)
},
});
if let (Some(object), Value::Object(output)) =
(normalized.as_object_mut(), bounded_output(&output))
{
object.extend(output);
}
vec![NormalizedProviderEvent {
event_type: if terminal {
"tool.execution.completed"
} else if string(payload.get("tag")) == "tool_call" {
"tool.execution.started"
} else {
"tool.execution.progressed"
}
.to_owned(),
priority: if terminal {
EventPriority::P1
} else {
EventPriority::P2
},
payload: normalized,
}]
}
fn acpx_notice(
item_id: &str,
severity: &str,
category: &str,
summary: &str,
user_actionable: bool,
) -> NormalizedProviderEvent {
NormalizedProviderEvent {
event_type: "provider.notice.recorded".to_owned(),
priority: if severity == "error" {
EventPriority::P0
} else {
EventPriority::P1
},
payload: json!({
"schema": "paperclip.provider.notice.v1",
"noticeId": item_id,
"severity": match severity {
"error" => "error",
"warning" => "warning",
_ => "info",
},
"category": stable_id(category, "acpx_provider_update"),
"scope": "turn",
"recoverable": severity != "error",
"userActionable": user_actionable,
"summary": if summary.trim().is_empty() {
"The qualified ACP agent emitted a provider update.".to_owned()
} else {
bounded_text(summary, MAX_TEXT_CHARS)
},
}),
}
}
fn nonnegative_u64(value: Option<&Value>) -> u64 {
value.and_then(Value::as_u64).unwrap_or(0)
}
fn safe_acpx_location(value: Option<&Value>, allow_create_target: bool) -> Value {
let Some(value) = value else {
return Value::Null;
};
// Only the pinned sidecar may attest that it resolved this value within
// the workspace under the provider host's path semantics. Ambiguous URI
// scheme, Windows drive, and leading-backslash shapes additionally require
// proof that the sidecar resolved an existing workspace entry as POSIX filename data,
// or that an edit's not-yet-created target has an in-workspace parent.
if value.get("pathBoundary").and_then(Value::as_str)
!= Some("paperclip.workspace_relative_display.v2")
{
return Value::Null;
}
let raw_path = value
.get("path")
.and_then(Value::as_str)
.unwrap_or_default();
if raw_path.is_empty()
|| raw_path.starts_with('/')
|| raw_path.contains('\0')
|| raw_path.split('/').any(|segment| segment == "..")
{
return Value::Null;
}
let requires_entry_attestation = raw_path.starts_with('\\')
|| has_windows_drive_prefix(raw_path)
|| has_rfc_uri_scheme_prefix(raw_path);
if requires_entry_attestation {
let attestation = value.get("pathAttestation").and_then(Value::as_str);
if attestation != Some("paperclip.workspace_entry.v1")
&& !(allow_create_target && attestation == Some("paperclip.workspace_create_target.v1"))
{
return Value::Null;
}
}
Value::String(raw_path.chars().take(4_000).collect())
}
fn has_windows_drive_prefix(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
fn has_rfc_uri_scheme_prefix(value: &str) -> bool {
let Some((scheme, _rest)) = value.split_once(':') else {
return false;
};
let mut characters = scheme.chars();
characters
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& characters.all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enforces_the_declared_safe_path_contract() {
for location in [
"/absolute/path",
r"\server\share",
r"C:\secret",
"../secret",
"src/../../secret",
r"foo\..\bar",
r"https:\host\secret",
"https://example.test/private",
"https:example.test/private",
"file:secret.txt",
"bad\0name",
] {
assert_eq!(
safe_acpx_location(Some(&json!({"path": location})), false),
Value::Null
);
}
assert_eq!(
safe_acpx_location(Some(&json!({"uri": "https://example.test/private"})), false,),
Value::Null,
);
for location in [
"/absolute/path",
"../secret",
"src/../../secret",
"custom:payload",
"urn:isbn:9780131103627",
r"C:Users\alice\secret.txt",
"D:relative.txt",
"bad\0name",
] {
assert_eq!(
safe_acpx_location(
Some(&json!({
"path": location,
"pathBoundary": "paperclip.workspace_relative_display.v2"
})),
false,
),
Value::Null,
);
}
}
#[test]
fn preserves_valid_posix_display_characters() {
for location in [
"src:main.rs",
"foo:bar/baz",
"src:/main.rs",
"a:/foo",
"A:b/file.txt",
r"folder\literal",
r"foo\..\bar",
"reports/100%/summary.txt",
] {
assert_eq!(
safe_acpx_location(
Some(&json!({
"path": location,
"pathBoundary": "paperclip.workspace_relative_display.v2",
"pathAttestation": "paperclip.workspace_entry.v1"
})),
false,
),
Value::String(location.to_owned()),
);
}
}
#[test]
fn maps_codex_plan_without_retaining_native_envelope() {
let events = normalize_codex_notification(

View File

@ -77,6 +77,78 @@ fn decodes_every_admitted_runtime_event_shape() {
}
}
#[test]
fn admits_sidecar_replacement_scalars_in_tool_fields() {
let scope = active_scope();
let kind = format!("{}\u{fffd}WRITE", "x".repeat(240));
let decoded = decode_acpx_event(
&scope,
&event(
GeneratedAcpxSidecarEventType::RuntimeEvent,
json!({
"type": "tool_call",
"toolCallId": "tool-\u{fffd}",
"kind": kind.clone(),
"status": "pend\u{fffd}ing",
"title": "Wri\u{fffd}te",
"locations": [],
}),
),
)
.expect("the sidecar's Unicode replacement values must remain admissible");
assert!(matches!(
decoded,
AcpxEventPayload::Runtime {
kind: AcpxRuntimeEventKind::ToolCall,
tool_operation: Some("edit"),
payload,
} if payload["toolCallId"].as_str() == Some("tool-\u{fffd}")
&& payload["kind"].as_str() == Some(kind.as_str())
&& payload["status"].as_str() == Some("pend\u{fffd}ing")
));
}
#[test]
fn retains_full_kind_classification_from_a_bounded_sidecar_frame() {
let scope = active_scope();
let decoded = decode_acpx_event(
&scope,
&event(
GeneratedAcpxSidecarEventType::RuntimeEvent,
json!({
"type": "tool_call",
"toolCallId": "tool-oversized-kind",
"kind": "x".repeat(4_000),
"toolOperation": "edit",
"status": "pending",
"title": "Provider tool",
"locations": [],
}),
),
)
.expect("a bounded tool frame with sidecar classification must remain admissible");
assert!(matches!(
decoded,
AcpxEventPayload::Runtime {
kind: AcpxRuntimeEventKind::ToolCall,
tool_operation: Some("edit"),
payload,
} if payload["type"] == "tool_call"
&& payload["kind"].as_str().is_some_and(|value| value.chars().count() == 4_000)
));
let invalid = event(
GeneratedAcpxSidecarEventType::RuntimeEvent,
json!({"type": "tool_call", "toolOperation": "write"}),
);
assert!(decode_acpx_event(&scope, &invalid)
.unwrap_err()
.to_string()
.contains("tool operation is not admitted"));
}
#[test]
fn rejects_unclassified_and_malformed_runtime_payloads() {
let scope = active_scope();

View File

@ -0,0 +1,454 @@
use paperclip_runner_core::acpx_event_payload::{
decode_acpx_event, AcpxEventPayload, AcpxRuntimeEventKind,
};
use paperclip_runner_core::acpx_event_scope::AcpxEventScope;
use paperclip_runner_core::acpx_sidecar_transport::AcpxSidecarEvent;
use paperclip_runner_core::durable::EventPriority;
use paperclip_runner_core::generated_acpx_sidecar_contract::{
classify_generated_acpx_tool_operation, GeneratedAcpxSidecarEventType,
};
use paperclip_runner_core::provider_events::normalize_acpx_runtime_event;
use serde_json::json;
fn normalize(
kind: AcpxRuntimeEventKind,
payload: serde_json::Value,
) -> Vec<paperclip_runner_core::provider_events::NormalizedProviderEvent> {
let operation = (kind == AcpxRuntimeEventKind::ToolCall).then(|| {
classify_generated_acpx_tool_operation(
payload
.get("kind")
.and_then(serde_json::Value::as_str)
.unwrap_or(""),
payload
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or(""),
)
});
normalize_acpx_runtime_event(kind, &payload, operation, "event-7", "turn-1", 3)
}
#[test]
fn generated_tool_classification_uses_ascii_case_mapping_for_kind_and_title() {
assert_eq!(
classify_generated_acpx_tool_operation("ſearch", ""),
"execute"
);
assert_eq!(
classify_generated_acpx_tool_operation("", "ſearch"),
"execute"
);
assert_eq!(
classify_generated_acpx_tool_operation("SEARCH", ""),
"search"
);
assert_eq!(classify_generated_acpx_tool_operation("", "WRITE"), "edit");
assert_eq!(
classify_generated_acpx_tool_operation(&format!("{}WRITE", "x".repeat(240)), ""),
"edit"
);
}
#[test]
fn preserves_tool_operation_authority_across_payload_sanitization() {
for payload in [
json!({
"type":"tool_call",
"toolCallId":"tool-long-kind",
"kind":format!("{}write", "x".repeat(4_097)),
"title":"Long kind",
"status":"pending",
"locations":[{
"path":"src:new.rs",
"pathBoundary":"paperclip.workspace_relative_display.v2",
"pathAttestation":"paperclip.workspace_create_target.v1"
}]
}),
json!({
"type":"tool_call",
"toolCallId":"tool-multibyte-title",
"kind":"",
"title":format!("{}write", "é".repeat(2_049)),
"status":"pending",
"locations":[{
"path":"src:new.rs",
"pathBoundary":"paperclip.workspace_relative_display.v2",
"pathAttestation":"paperclip.workspace_create_target.v1"
}]
}),
] {
let mut scope = AcpxEventScope::new("run-1").unwrap();
scope.bind_turn("turn-1").unwrap();
let decoded = decode_acpx_event(
&scope,
&AcpxSidecarEvent {
sequence: 1,
event_type: GeneratedAcpxSidecarEventType::RuntimeEvent,
run_id: Some("run-1".to_owned()),
turn_id: Some("turn-1".to_owned()),
payload,
},
)
.unwrap();
let AcpxEventPayload::Runtime {
kind,
tool_operation,
payload,
} = decoded
else {
panic!("runtime event must decode as runtime payload");
};
let events =
normalize_acpx_runtime_event(kind, &payload, tool_operation, "event-7", "turn-1", 3);
assert_eq!(events[0].payload["operation"], "edit");
assert_eq!(events[0].payload["target"], "src:new.rs");
assert!(events[0].payload["name"].as_str().unwrap().chars().count() <= 240);
}
}
#[test]
fn maps_text_and_thinking_without_exposing_reasoning() {
let text = normalize(
AcpxRuntimeEventKind::TextDelta,
json!({"type":"text_delta","messageId":"message-1","text":"Working"}),
);
assert_eq!(text[0].event_type, "item.delta");
assert_eq!(text[0].payload["itemId"], "message-1");
assert_eq!(text[0].payload["text"], "Working");
assert_eq!(text[0].priority, EventPriority::P2);
let thinking = normalize(
AcpxRuntimeEventKind::Thinking,
json!({"type":"thinking","text":"private chain of thought"}),
);
assert_eq!(thinking[0].event_type, "item.started");
assert_eq!(thinking[0].payload["kind"], "reasoning");
assert_eq!(thinking[0].payload["text"], serde_json::Value::Null);
assert!(!thinking[0].payload.to_string().contains("private chain"));
}
#[test]
fn maps_bounded_plan_and_completion_state() {
let events = normalize(
AcpxRuntimeEventKind::Plan,
json!({
"type":"plan",
"entries":[
{"content":"Inspect", "status":"in_progress"},
{"content":"Ship", "status":"completed"}
]
}),
);
assert_eq!(events[0].event_type, "plan.updated");
assert_eq!(events[0].payload["planId"], "turn-1");
assert_eq!(events[0].payload["steps"][0]["status"], "in_progress");
assert_eq!(events[0].payload["complete"], false);
assert_eq!(events[0].priority, EventPriority::P1);
}
#[test]
fn maps_usage_and_review_status_but_ignores_inventory_updates() {
let usage = normalize(
AcpxRuntimeEventKind::Status,
json!({
"type":"status",
"tag":"usage_update",
"breakdown":{"inputTokens":12,"outputTokens":4,"cachedReadTokens":2},
"cost":{"amount":0.25}
}),
);
assert_eq!(usage[0].event_type, "usage.reported");
assert_eq!(usage[0].payload["cumulative"]["inputTokens"], 12);
assert_eq!(usage[0].payload["cumulative"]["requests"], 3);
assert_eq!(usage[0].priority, EventPriority::P0);
let review = normalize(
AcpxRuntimeEventKind::Status,
json!({"type":"status","tag":"current_mode_update","text":"review mode"}),
);
assert_eq!(review[0].event_type, "review.mode.changed");
assert_eq!(review[0].payload["state"], "entered");
assert!(normalize(
AcpxRuntimeEventKind::Status,
json!({"type":"status","tag":"available_commands_update"}),
)
.is_empty());
}
#[test]
fn maps_tool_lifecycle_and_preserves_safe_display_paths() {
let started = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call",
"toolCallId":"tool-1",
"kind":"read",
"title":"Read file",
"status":"pending",
"locations":[{
"path":"src/main.rs",
"pathBoundary":"paperclip.workspace_relative_display.v2"
}],
"text":"Opening"
}),
);
assert_eq!(started[0].event_type, "tool.execution.started");
assert_eq!(started[0].payload["operation"], "read");
assert_eq!(started[0].payload["target"], "src/main.rs");
assert_eq!(started[0].payload["readOnly"], true);
let completed = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call_update",
"toolCallId":"tool-1",
"kind":"read",
"status":"completed",
"locations":[{"path":"../../secret"}],
"rawOutput":"Authorization: Bearer top-secret"
}),
);
assert_eq!(completed[0].event_type, "tool.execution.completed");
assert_eq!(completed[0].payload["target"], serde_json::Value::Null);
assert_eq!(completed[0].payload["outputTruncated"], true);
assert!(!completed[0].payload.to_string().contains("top-secret"));
for display_path in [
"src:main.rs",
"foo:bar/baz",
"src:/main.rs",
"a:/foo",
"A:b/file.txt",
r"\notes.md",
r"folder\literal",
r"foo\..\bar",
"reports/100%/summary.txt",
] {
let display = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call_update",
"toolCallId":"tool-display",
"kind":"read",
"status":"completed",
"locations":[{
"path":display_path,
"pathBoundary":"paperclip.workspace_relative_display.v2",
"pathAttestation":"paperclip.workspace_entry.v1"
}]
}),
);
assert_eq!(display[0].payload["target"], display_path);
}
for unsafe_path in [
r"\server\share",
r"C:\secret",
r"https:\host\secret",
"https://example.test/private",
"https:example.test/private",
"file:secret.txt",
"custom:payload",
"urn:isbn:9780131103627",
"tel:+15555550100",
r"C:Users\alice\secret.txt",
"D:relative.txt",
] {
let rejected = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call_update",
"toolCallId":"tool-unsafe-display",
"kind":"read",
"status":"completed",
"locations":[{
"path":unsafe_path,
"pathBoundary":"paperclip.workspace_relative_display.v2"
}]
}),
);
assert_eq!(rejected[0].payload["target"], serde_json::Value::Null);
}
let create_target = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call",
"toolCallId":"tool-create-display",
"kind":"edit",
"status":"pending",
"locations":[{
"path":"src:new.rs",
"pathBoundary":"paperclip.workspace_relative_display.v2",
"pathAttestation":"paperclip.workspace_create_target.v1"
}]
}),
);
assert_eq!(create_target[0].payload["target"], "src:new.rs");
for compound_kind in ["read_write", "search_write"] {
let compound_create_target = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call",
"toolCallId":format!("tool-{compound_kind}-display"),
"kind":compound_kind,
"status":"pending",
"locations":[{
"path":"src:new.rs",
"pathBoundary":"paperclip.workspace_relative_display.v2",
"pathAttestation":"paperclip.workspace_create_target.v1"
}]
}),
);
assert_eq!(compound_create_target[0].payload["operation"], "edit");
assert_eq!(compound_create_target[0].payload["readOnly"], false);
assert_eq!(compound_create_target[0].payload["target"], "src:new.rs");
}
let long_edit_kind = format!("{}write", "x".repeat(240));
let long_kind_create_target = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call",
"toolCallId":"tool-long-edit-display",
"kind":long_edit_kind,
"status":"pending",
"locations":[{
"path":"src:new.rs",
"pathBoundary":"paperclip.workspace_relative_display.v2",
"pathAttestation":"paperclip.workspace_create_target.v1"
}]
}),
);
assert_eq!(long_kind_create_target[0].payload["operation"], "edit");
assert_eq!(long_kind_create_target[0].payload["readOnly"], false);
assert_eq!(long_kind_create_target[0].payload["target"], "src:new.rs");
let long_edit_title = format!("{}write", "x".repeat(240));
let long_title_create_target = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call",
"toolCallId":"tool-long-title-edit-display",
"kind":"",
"title":long_edit_title,
"status":"pending",
"locations":[{
"path":"src:new.rs",
"pathBoundary":"paperclip.workspace_relative_display.v2",
"pathAttestation":"paperclip.workspace_create_target.v1"
}]
}),
);
assert_eq!(long_title_create_target[0].payload["operation"], "edit");
assert_eq!(long_title_create_target[0].payload["readOnly"], false);
assert_eq!(long_title_create_target[0].payload["target"], "src:new.rs");
// The sidecar classifies and emits the same 4,000-character title. A
// mutation token outside that transport boundary cannot authorize a create
// target that runner-core would see only as an execute operation.
let bounded_non_edit_title = "x".repeat(4_000);
let overlong_title_create_target = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call",
"toolCallId":"tool-overlong-title-display",
"kind":"",
"title":bounded_non_edit_title,
"status":"pending",
"locations":[{
"path":"src:new.rs",
"pathBoundary":"paperclip.workspace_relative_display.v2",
"pathAttestation":"paperclip.workspace_create_target.v1"
}]
}),
);
assert_eq!(
overlong_title_create_target[0].payload["operation"],
"execute"
);
assert_eq!(overlong_title_create_target[0].payload["readOnly"], false);
assert_eq!(
overlong_title_create_target[0].payload["target"],
serde_json::Value::Null
);
let create_attestation_on_read = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call",
"toolCallId":"tool-read-create-display",
"kind":"read",
"status":"pending",
"locations":[{
"path":"src:new.rs",
"pathBoundary":"paperclip.workspace_relative_display.v2",
"pathAttestation":"paperclip.workspace_create_target.v1"
}]
}),
);
assert_eq!(
create_attestation_on_read[0].payload["target"],
serde_json::Value::Null
);
let uri_only = normalize(
AcpxRuntimeEventKind::ToolCall,
json!({
"type":"tool_call",
"tag":"tool_call_update",
"toolCallId":"tool-uri",
"kind":"read",
"status":"completed",
"locations":[{"uri":"https://example.test/private"}]
}),
);
assert_eq!(uri_only[0].payload["target"], serde_json::Value::Null);
}
#[test]
fn maps_provider_notices_and_errors_with_stable_fields() {
let notice = normalize(
AcpxRuntimeEventKind::ProviderNotice,
json!({
"type":"provider_notice",
"severity":"warning",
"category":"rate limit",
"summary":"Retrying"
}),
);
assert_eq!(notice[0].event_type, "provider.notice.recorded");
assert_eq!(notice[0].payload["severity"], "warning");
assert_eq!(notice[0].payload["category"], "rate-limit");
let error = normalize(
AcpxRuntimeEventKind::Error,
json!({"type":"error","code":"provider/failure","message":"Stopped"}),
);
assert_eq!(error[0].payload["severity"], "error");
assert_eq!(error[0].payload["userActionable"], true);
assert_eq!(error[0].priority, EventPriority::P0);
}
#[test]
fn leaves_operational_semantic_and_terminal_events_to_the_adapter() {
assert!(normalize(
AcpxRuntimeEventKind::SemanticResult,
json!({"type":"semantic_result","callId":"call-1","result":{}}),
)
.is_empty());
assert!(normalize(AcpxRuntimeEventKind::Done, json!({"type":"done"})).is_empty());
}

View File

@ -13,6 +13,14 @@ const schema = JSON.parse(
);
const commands = schema.$defs.command.enum;
const events = schema.$defs.eventType.enum;
// Mutation wins for compound provider kinds so create-target attestation and
// the normalized readOnly flag cannot disagree across the sidecar boundary.
const toolOperationPrecedence = [
["edit", ["edit", "write", "patch"]],
["read", ["read"]],
["search", ["search", "grep", "find"]],
["list", ["list", "glob"]],
];
const protocolVersion = readAcpxSidecarProtocolVersion(schema);
const quote = (value) => JSON.stringify(value);
const rustVariant = (value) =>
@ -21,11 +29,37 @@ const rustVariant = (value) =>
.map((part) => part[0].toUpperCase() + part.slice(1))
.join("");
const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = ${protocolVersion} as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand =\n (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType =\n (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n`;
const rust = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\npub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = ${protocolVersion};\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum GeneratedAcpxSidecarCommand {\n${commands.map((value) => ` ${rustVariant(value)},`).join("\n")}\n}\n\nimpl GeneratedAcpxSidecarCommand {\n pub const fn as_str(self) -> &'static str {\n match self {\n${commands.map((value) => ` Self::${rustVariant(value)} => ${quote(value)},`).join("\n")}\n }\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]\npub enum GeneratedAcpxSidecarEventType {\n${events.map((value) => ` #[serde(rename = ${quote(value)})]\n ${rustVariant(value)},`).join("\n")}\n}\n`;
const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = ${protocolVersion} as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand =\n (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType =\n (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n\nexport type GeneratedAcpxToolOperation =\n | "read"\n | "search"\n | "list"\n | "edit"\n | "execute"\n | "unknown";\n\nexport const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE = [\n${toolOperationPrecedence.map(([operation, tokens]) => ` { operation: ${quote(operation)}, tokens: [${tokens.map(quote).join(", ")}] },`).join("\n")}\n] as const;\n\nexport function classifyGeneratedAcpxToolOperation(\n toolKind: unknown,\n toolTitle: unknown,\n): GeneratedAcpxToolOperation {\n const candidate =\n typeof toolKind === "string" && toolKind\n ? toolKind\n : typeof toolTitle === "string"\n ? toolTitle\n : "";\n const normalized = candidate.slice(0, 240).toLowerCase();\n for (const { operation, tokens } of GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE) {\n if (tokens.some((token) => normalized.includes(token))) return operation;\n }\n return normalized ? "execute" : "unknown";\n}\n`;
const rust = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\npub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = ${protocolVersion};\n\npub const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE: &[(&str, &[&str])] = &[\n${toolOperationPrecedence.map(([operation, tokens]) => ` (${quote(operation)}, &[${tokens.map(quote).join(", ")}]),`).join("\n")}\n];\n\npub fn classify_generated_acpx_tool_operation(kind: &str, title: &str) -> &'static str {\n let kind = kind.to_ascii_lowercase();\n let title = title.to_ascii_lowercase();\n let candidate = if kind.is_empty() { &title } else { &kind };\n for (operation, tokens) in GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE {\n if tokens.iter().any(|token| candidate.contains(token)) {\n return operation;\n }\n }\n if candidate.is_empty() {\n "unknown"\n } else {\n "execute"\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum GeneratedAcpxSidecarCommand {\n${commands.map((value) => ` ${rustVariant(value)},`).join("\n")}\n}\n\nimpl GeneratedAcpxSidecarCommand {\n pub const fn as_str(self) -> &'static str {\n match self {\n${commands.map((value) => ` Self::${rustVariant(value)} => ${quote(value)},`).join("\n")}\n }\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]\npub enum GeneratedAcpxSidecarEventType {\n${events.map((value) => ` #[serde(rename = ${quote(value)})]\n ${rustVariant(value)},`).join("\n")}\n}\n`;
// Classification must inspect the same complete provider value in both
// languages. Display-field truncation happens only after operation selection.
const typescriptWithAsciiLowercase = typescript.replace(
"export function classifyGeneratedAcpxToolOperation(",
`function lowercaseGeneratedAcpxAscii(value: string): string {
return value.replace(/[A-Z]/g, (character) =>
String.fromCharCode(character.charCodeAt(0) + 32),
);
}
export function classifyGeneratedAcpxToolOperation(`,
);
if (typescriptWithAsciiLowercase === typescript) {
throw new Error("generated TypeScript ACPX classifier export drifted");
}
const typescriptClassifier = typescriptWithAsciiLowercase.replace(
"candidate.slice(0, 240).toLowerCase()",
"lowercaseGeneratedAcpxAscii(candidate)",
);
if (typescriptClassifier === typescriptWithAsciiLowercase) {
throw new Error("generated TypeScript ACPX classifier template drifted");
}
const outputs = [
[resolve(root, "src/drivers/acpx/generated-sidecar-contract.ts"), typescript],
[
resolve(root, "src/drivers/acpx/generated-sidecar-contract.ts"),
typescriptClassifier,
],
[
resolve(
root,

View File

@ -31,10 +31,13 @@ import {
import {
ACPX_SIDECAR_MAX_FRAME_BYTES,
ACPX_SIDECAR_PROTOCOL_VERSION,
boundedSidecarText,
boundedSidecarValue,
frameAcpxToolClassification,
parseAcpxSidecarRequest,
record,
sanitizeAcpxPlanEntries,
stringifyAcpxSidecarFrame,
text,
type AcpxExpectedSessionIdentity,
type AcpxSidecarEvent,
@ -662,20 +665,47 @@ function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record<string, unknown> {
});
}
if (event.type === "tool_call") {
// Classification and the consumer must see the same title bytes. In
// particular, a mutation token beyond the transport bound must not grant a
// create-target attestation that runner-core cannot independently verify.
const toolTitle =
typeof event.title === "string"
? boundedSidecarText(event.title, 4_000)
: null;
// Classify the complete provider kind before retaining its bounded display
// prefix. The canonical operation keeps runner-core and the location
// attestation decision aligned even when the mutation token is outside the
// retained prefix.
const toolClassification = frameAcpxToolClassification(
event.kind,
toolTitle,
);
const toolCallIdentity = {
type: "tool_call",
toolCallId:
typeof event.toolCallId === "string"
? boundedSidecarText(event.toolCallId, 240)
: null,
status:
typeof event.status === "string"
? boundedSidecarText(event.status, 100)
: null,
title: toolTitle,
...toolClassification,
};
return boundedSidecarValue(
{
type: "tool_call",
toolCallId: event.toolCallId?.slice(0, 240) ?? null,
status: event.status?.slice(0, 100) ?? null,
title: event.title?.slice(0, 4_000) ?? null,
kind: event.kind ?? null,
...toolCallIdentity,
locations: safeAcpxLocations(
event.locations,
openParams?.workingDirectory,
event.kind,
toolTitle,
),
...safeOutput(event.rawOutput),
},
128 * 1024,
toolCallIdentity,
);
}
if (event.type === "error") {
@ -939,7 +969,7 @@ function response(
}
function writeFrame(value: AcpxSidecarEvent | AcpxSidecarResponse): void {
const line = JSON.stringify(value);
const line = stringifyAcpxSidecarFrame(value);
if (Buffer.byteLength(line) > ACPX_SIDECAR_MAX_FRAME_BYTES) {
process.stderr.write("[paperclip-acpx-sidecar] output_frame_too_large\n");
return;

View File

@ -1,55 +1,259 @@
import {
mkdirSync,
mkdtempSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, sep } from "node:path";
import { describe, expect, it } from "vitest";
import { safeAcpxLocations } from "./acpx-sidecar-locations.js";
describe("ACPX sidecar locations", () => {
it("preserves valid host-relative display names without admitting escape", () => {
it.runIf(sep === "/")(
"preserves valid host-relative display names without admitting escape",
() => {
const workspace = mkdtempSync(
join(tmpdir(), "paperclip-acpx-locations-"),
);
writeFileSync(join(workspace, "src:main.ts"), "");
mkdirSync(join(workspace, "a:"));
writeFileSync(join(workspace, "a:", "foo"), "");
writeFileSync(join(workspace, "custom:payload"), "");
try {
expect(
safeAcpxLocations(
[
{ path: "src/main.ts", line: 4 },
{ path: "src:main.ts" },
{ path: String.raw`folder\literal` },
{ path: "a:/foo" },
{ path: "custom:payload" },
{ path: String.raw`foo\..\bar` },
{ path: "reports/100%/summary.txt" },
{ path: "../outside.txt" },
{ path: "/etc/passwd" },
{ uri: "https://example.test/private" },
{ path: "bad\0name" },
],
workspace,
),
).toEqual([
{
path: "src/main.ts",
line: 4,
pathBoundary: "paperclip.workspace_relative_display.v2",
},
{
path: "src:main.ts",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
pathAttestation: "paperclip.workspace_entry.v1",
},
{
path: String.raw`folder\literal`,
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
},
{
path: "a:/foo",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
pathAttestation: "paperclip.workspace_entry.v1",
},
{
path: "custom:payload",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
pathAttestation: "paperclip.workspace_entry.v1",
},
{
path: String.raw`foo\..\bar`,
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
},
{
path: "reports/100%/summary.txt",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
},
]);
} finally {
rmSync(workspace, { force: true, recursive: true });
}
},
);
it.runIf(sep === "/")(
"attests a leading backslash as valid POSIX filename data",
() => {
const workspace = mkdtempSync(
join(tmpdir(), "paperclip-acpx-locations-"),
);
writeFileSync(join(workspace, String.raw`\notes.md`), "");
try {
expect(
safeAcpxLocations([{ path: String.raw`\notes.md` }], workspace),
).toEqual([
{
path: String.raw`\notes.md`,
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
pathAttestation: "paperclip.workspace_entry.v1",
},
]);
} finally {
rmSync(workspace, { force: true, recursive: true });
}
},
);
it("rejects URI and foreign-host syntax before attaching the boundary", () => {
expect(
safeAcpxLocations(
[
{ path: "src/main.ts", line: 4 },
{ path: "reports/100%/summary.txt" },
{ path: "../outside.txt" },
{ path: "/etc/passwd" },
{ uri: "https://example.test/private" },
{ path: "bad\0name" },
{ path: String.raw`C:\Users\alice\secret.txt` },
{ path: String.raw`\\server\share\secret.txt` },
{ path: String.raw`https:\host\secret` },
{ path: "https://host/secret" },
{ path: "file:secret.txt" },
{ path: "s3:bucket/key" },
{ path: "custom:payload" },
{ path: "urn:isbn:9780131103627" },
{ path: "tel:+15555550100" },
{ path: String.raw`C:Users\alice\secret.txt` },
{ path: "D:relative.txt" },
],
"/workspace/project",
tmpdir(),
),
).toEqual([
{
path: "src/main.ts",
line: 4,
pathBoundary: "paperclip.workspace_relative_display.v1",
},
{
path: "reports/100%/summary.txt",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v1",
},
]);
).toEqual([]);
});
it.runIf(process.platform !== "win32")(
"preserves POSIX literal colon and backslash filename characters",
it.runIf(sep === "/")(
"attests scheme-shaped targets after relative and absolute normalization",
() => {
expect(
safeAcpxLocations(
[{ path: "src:main.ts" }, { path: String.raw`folder\literal` }],
"/workspace/project",
),
).toEqual([
{
path: "src:main.ts",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v1",
},
{
path: String.raw`folder\literal`,
line: null,
pathBoundary: "paperclip.workspace_relative_display.v1",
},
]);
const workspace = mkdtempSync(
join(tmpdir(), "paperclip-acpx-locations-"),
);
const entry = join(workspace, "src:main.ts");
writeFileSync(entry, "");
try {
expect(
safeAcpxLocations(
[{ path: "./src:main.ts" }, { path: resolve(entry) }],
workspace,
),
).toEqual([
{
path: "src:main.ts",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
pathAttestation: "paperclip.workspace_entry.v1",
},
{
path: "src:main.ts",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
pathAttestation: "paperclip.workspace_entry.v1",
},
]);
} finally {
rmSync(workspace, { force: true, recursive: true });
}
},
);
it.runIf(sep === "/")(
"attests missing edit targets without weakening symlink containment",
() => {
const root = mkdtempSync(join(tmpdir(), "paperclip-acpx-locations-"));
const workspace = join(root, "workspace");
const outside = join(root, "outside");
mkdirSync(workspace);
mkdirSync(outside);
symlinkSync(outside, join(workspace, "src:"));
symlinkSync(
join(workspace, "missing"),
join(workspace, "dangling:new.ts"),
);
try {
expect(
safeAcpxLocations(
[
{ path: "src:new.ts", line: 8 },
{ path: "src:/outside.ts" },
{ path: "dangling:new.ts" },
{ path: "missing:/nested.ts" },
],
workspace,
"edit",
),
).toEqual([
{
path: "src:new.ts",
line: 8,
pathBoundary: "paperclip.workspace_relative_display.v2",
pathAttestation: "paperclip.workspace_create_target.v1",
},
]);
expect(
safeAcpxLocations([{ path: "src:new.ts" }], workspace, "read"),
).toEqual([]);
expect(
safeAcpxLocations(
[{ path: "src:new.ts" }],
workspace,
undefined,
"Write",
),
).toHaveLength(1);
for (const compoundKind of ["read_write", "search_write"]) {
expect(
safeAcpxLocations(
[{ path: "src:new.ts" }],
workspace,
compoundKind,
),
).toEqual([
{
path: "src:new.ts",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
pathAttestation: "paperclip.workspace_create_target.v1",
},
]);
}
expect(
safeAcpxLocations(
[{ path: "src:new.ts" }],
workspace,
`${"x".repeat(240)}write`,
),
).toEqual([
{
path: "src:new.ts",
line: null,
pathBoundary: "paperclip.workspace_relative_display.v2",
pathAttestation: "paperclip.workspace_create_target.v1",
},
]);
// The runtime sidecar sends and classifies this same bounded title. A
// mutation token beyond the transport boundary must not authorize the
// otherwise missing create target.
expect(
safeAcpxLocations(
[{ path: "src:new.ts" }],
workspace,
undefined,
`${"x".repeat(4_000)}write`.slice(0, 4_000),
),
).toEqual([]);
} finally {
rmSync(root, { force: true, recursive: true });
}
},
);

View File

@ -1,7 +1,15 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import { lstatSync, realpathSync } from "node:fs";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { classifyGeneratedAcpxToolOperation } from "../drivers/acpx/generated-sidecar-contract.js";
export const ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY =
"paperclip.workspace_relative_display.v1";
"paperclip.workspace_relative_display.v2";
export const ACPX_WORKSPACE_ENTRY_ATTESTATION = "paperclip.workspace_entry.v1";
export const ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION =
"paperclip.workspace_create_target.v1";
const RFC_URI_SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/u;
const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:/u;
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
@ -9,19 +17,28 @@ function record(value: unknown): Record<string, unknown> {
: {};
}
function permitsCreateTarget(toolKind: unknown, toolTitle: unknown): boolean {
return classifyGeneratedAcpxToolOperation(toolKind, toolTitle) === "edit";
}
/**
* Converts provider paths to workspace-relative display targets using the
* sidecar host's path semantics. A URI is not a path. Windows separators are
* canonicalized for PRP; POSIX backslashes and colons remain literal filename
* characters. Consumers must treat the result as display data, never as an
* authorization to access a file.
* sidecar host's path semantics. URI-scheme and Windows drive-shaped values
* are ambiguous on POSIX, so they require a real, in-workspace filesystem
* entry before the sidecar may attest them as filename data. Leading
* backslashes likewise require native-host attestation because they are rooted
* syntax on Windows but valid filename data on POSIX. Consumers must
* treat the result as display data, never as file-access authorization.
*/
export function safeAcpxLocations(
locations: readonly unknown[] | null | undefined,
workingDirectory: string | null | undefined,
toolKind?: unknown,
toolTitle?: unknown,
): Array<Record<string, unknown>> {
if (!workingDirectory) return [];
const cwd = resolve(workingDirectory);
let canonicalCwd: string | null | undefined;
return (locations ?? []).slice(0, 2_000).flatMap((location) => {
const candidate = record(location);
const rawPath = typeof candidate.path === "string" ? candidate.path : "";
@ -38,11 +55,84 @@ export function safeAcpxLocations(
) {
return [];
}
// Classify the value we actually emit as well as the provider's spelling.
// Dot-relative and absolute paths can normalize to an ambiguous display
// target even though their raw forms did not begin with an ambiguous shape.
const requiresEntryAttestation =
rawPath.startsWith("\\") ||
portable.startsWith("\\") ||
WINDOWS_DRIVE_PREFIX.test(rawPath) ||
RFC_URI_SCHEME_PREFIX.test(rawPath) ||
WINDOWS_DRIVE_PREFIX.test(portable) ||
RFC_URI_SCHEME_PREFIX.test(portable);
let pathAttestation: string | undefined;
if (requiresEntryAttestation) {
if (canonicalCwd === undefined) {
try {
canonicalCwd = realpathSync(cwd);
} catch {
canonicalCwd = null;
}
}
if (canonicalCwd === null) return [];
let entryExists = true;
try {
lstatSync(absolute);
} catch (error) {
entryExists = false;
if (
!permitsCreateTarget(toolKind, toolTitle) ||
!(
error !== null &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
)
) {
return [];
}
}
if (entryExists) {
try {
const canonicalEntry = realpathSync(absolute);
const entryRelative = relative(canonicalCwd, canonicalEntry);
if (
!entryRelative ||
isAbsolute(entryRelative) ||
entryRelative.split(sep).some((segment) => segment === "..")
) {
return [];
}
pathAttestation = ACPX_WORKSPACE_ENTRY_ATTESTATION;
} catch {
// An existing but unresolved entry includes dangling links. It must
// not be downgraded to a create target by an ENOENT from realpath.
return [];
}
} else {
try {
const canonicalParent = realpathSync(dirname(absolute));
const parentRelative = relative(canonicalCwd, canonicalParent);
if (
isAbsolute(parentRelative) ||
parentRelative.split(sep).some((segment) => segment === "..")
) {
return [];
}
pathAttestation = ACPX_WORKSPACE_CREATE_TARGET_ATTESTATION;
} catch {
return [];
}
}
}
return [
{
path: [...portable].slice(0, 4_000).join(""),
line: candidate.line ?? null,
pathBoundary: ACPX_WORKSPACE_RELATIVE_DISPLAY_BOUNDARY,
...(pathAttestation ? { pathAttestation } : {}),
},
];
});

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { classifyGeneratedAcpxToolOperation } from "./generated-sidecar-contract.js";
describe("generated ACPX sidecar tool classification", () => {
it("uses ASCII case mapping for both provider kinds and fallback titles", () => {
expect(classifyGeneratedAcpxToolOperation("ſearch", undefined)).toBe(
"execute",
);
expect(classifyGeneratedAcpxToolOperation(undefined, "ſearch")).toBe(
"execute",
);
expect(classifyGeneratedAcpxToolOperation("SEARCH", undefined)).toBe(
"search",
);
expect(classifyGeneratedAcpxToolOperation(undefined, "WRITE")).toBe("edit");
});
it("continues classifying the complete provider value", () => {
expect(
classifyGeneratedAcpxToolOperation(`${"x".repeat(240)}WRITE`, undefined),
).toBe("edit");
});
});

View File

@ -29,3 +29,41 @@ export const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [
] as const;
export type GeneratedAcpxSidecarEventType =
(typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];
export type GeneratedAcpxToolOperation =
| "read"
| "search"
| "list"
| "edit"
| "execute"
| "unknown";
export const GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE = [
{ operation: "edit", tokens: ["edit", "write", "patch"] },
{ operation: "read", tokens: ["read"] },
{ operation: "search", tokens: ["search", "grep", "find"] },
{ operation: "list", tokens: ["list", "glob"] },
] as const;
function lowercaseGeneratedAcpxAscii(value: string): string {
return value.replace(/[A-Z]/g, (character) =>
String.fromCharCode(character.charCodeAt(0) + 32),
);
}
export function classifyGeneratedAcpxToolOperation(
toolKind: unknown,
toolTitle: unknown,
): GeneratedAcpxToolOperation {
const candidate =
typeof toolKind === "string" && toolKind
? toolKind
: typeof toolTitle === "string"
? toolTitle
: "";
const normalized = lowercaseGeneratedAcpxAscii(candidate);
for (const { operation, tokens } of GENERATED_ACPX_TOOL_OPERATION_PRECEDENCE) {
if (tokens.some((token) => normalized.includes(token))) return operation;
}
return normalized ? "execute" : "unknown";
}

View File

@ -2,9 +2,13 @@ import { describe, expect, it } from "vitest";
import {
ACPX_SIDECAR_MAX_FRAME_BYTES,
boundedSidecarText,
boundedSidecarValue,
frameAcpxToolClassification,
parseAcpxSidecarRequest,
safeSidecarText,
sanitizeAcpxPlanEntries,
stringifyAcpxSidecarFrame,
} from "./sidecar-protocol.js";
describe("ACPX sidecar request parsing", () => {
@ -77,6 +81,95 @@ describe("ACPX sidecar request parsing", () => {
reason: "object_required",
});
});
it("bounds tool text on Unicode scalar boundaries", () => {
const prefix = "x".repeat(3_999);
const title = boundedSidecarText(`${prefix}🚀write`, 4_000);
expect([...title]).toHaveLength(4_000);
expect(title).toBe(`${prefix}🚀`);
expect(
boundedSidecarValue({ type: "tool_call", title }, 128 * 1024),
).toEqual({ type: "tool_call", title });
expect(boundedSidecarText(`safe\ud83d`, 10)).toBe("safe\uFFFD");
});
it("classifies an oversized tool kind before retaining its bounded frame value", () => {
const classification = frameAcpxToolClassification(
`${"x".repeat(128 * 1024)}\ud800WRITE`,
"Provider tool",
);
const payload = boundedSidecarValue(
{
type: "tool_call",
toolCallId: "tool-oversized-kind",
title: "Provider tool",
...classification,
locations: [],
},
128 * 1024,
);
expect(payload).toMatchObject({
type: "tool_call",
kind: "x".repeat(4_000),
toolOperation: "edit",
});
expect(payload).not.toHaveProperty("omitted");
expect(stringifyAcpxSidecarFrame(payload)).not.toMatch(
/\\ud[89ab][0-9a-f]{2}|\\ud[c-f][0-9a-f]{2}/iu,
);
});
it("preserves a bounded tool-call identity when aggregate fields overflow", () => {
const identity = {
type: "tool_call",
toolCallId: "tool-aggregate-overflow",
title: "Write",
kind: "write",
toolOperation: "edit",
};
expect(
boundedSidecarValue(
{ ...identity, locations: [{ path: "x".repeat(140 * 1024) }] },
128 * 1024,
identity,
),
).toEqual({
...identity,
omitted: true,
reason: "payload_limit",
});
});
it("emits only Rust-decodable Unicode scalar values in sidecar frames", () => {
const frame = stringifyAcpxSidecarFrame({
payload: {
toolCallId: `call-\ud800`,
kind: `wr\udfffite`,
status: `pend\ud800ing`,
input: {
[`nested-\ud800`]: { [`result-\udfff`]: "retained" },
},
},
});
expect(frame).not.toMatch(/\\ud[89ab][0-9a-f]{2}|\\ud[c-f][0-9a-f]{2}/iu);
expect(JSON.parse(frame)).toEqual({
payload: {
toolCallId: "call-\uFFFD",
kind: "wr\uFFFDite",
status: "pend\uFFFDing",
input: {
"nested-\uFFFD": { "result-\uFFFD": "retained" },
},
},
});
expect(safeSidecarText(`read\ud83d🚀\udc00write`)).toBe(
"read\uFFFD🚀\uFFFDwrite",
);
});
});
describe("ACPX sidecar structured plans", () => {

View File

@ -2,8 +2,10 @@ import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js";
import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js";
import {
classifyGeneratedAcpxToolOperation,
GENERATED_ACPX_SIDECAR_COMMANDS,
GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
type GeneratedAcpxToolOperation,
type GeneratedAcpxSidecarCommand,
type GeneratedAcpxSidecarEventType,
} from "./generated-sidecar-contract.js";
@ -114,26 +116,143 @@ export function parseAcpxSidecarRequest(value: unknown): AcpxSidecarRequest {
export function boundedSidecarValue(
value: unknown,
maxBytes = 64 * 1024,
overflowFallback?: Record<string, unknown>,
): Record<string, unknown> {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
throw new Error("ACPX sidecar value limit must be a positive integer");
}
const omitted = (reason: string): Record<string, unknown> => {
const marker = { omitted: true, reason };
if (overflowFallback === undefined) return marker;
try {
const serialized = stringifyAcpxSidecarFrame({
...overflowFallback,
...marker,
});
if (Buffer.byteLength(serialized) <= maxBytes) {
const parsed = JSON.parse(serialized);
if (typeof parsed === "object" && parsed !== null) {
return parsed as Record<string, unknown>;
}
}
} catch {
// Fall through to the bounded type-less marker when even the caller's
// minimal identity cannot be serialized safely.
}
return marker;
};
try {
const serialized = JSON.stringify(value);
const serialized = stringifyAcpxSidecarFrame(value);
if (!serialized || Buffer.byteLength(serialized) > maxBytes) {
return { omitted: true, reason: "payload_limit" };
return omitted("payload_limit");
}
const parsed = JSON.parse(serialized);
return typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: { omitted: true, reason: "object_required" };
: omitted("object_required");
} catch {
return { omitted: true, reason: "serialization_failed" };
return omitted("serialization_failed");
}
}
/**
* Replaces isolated UTF-16 surrogates with a Unicode scalar value. JavaScript
* can retain those code units and JSON.stringify emits them as escapes, while
* Rust's serde_json correctly rejects them as invalid JSON strings.
*/
export function safeSidecarText(value: string): string {
const safe: string[] = [];
for (const codePoint of value) safe.push(safeSidecarCodePoint(codePoint));
return safe.join("");
}
/**
* Serializes a frame without emitting string values or property names that
* Rust cannot decode. The initial round trip preserves JSON.stringify's
* ordinary toJSON, omission, and number semantics before keys are rebuilt.
*/
export function stringifyAcpxSidecarFrame(value: unknown): string {
const serialized = JSON.stringify(value);
if (!serialized) throw new Error("ACPX sidecar frame is not serializable");
return JSON.stringify(safeSidecarJsonValue(JSON.parse(serialized)));
}
function safeSidecarJsonValue(value: unknown): unknown {
if (typeof value === "string") return safeSidecarText(value);
if (Array.isArray(value)) return value.map(safeSidecarJsonValue);
if (value === null || typeof value !== "object") return value;
const safe = Object.create(null) as Record<string, unknown>;
for (const [key, candidate] of Object.entries(
value as Record<string, unknown>,
)) {
const safeKey = safeSidecarText(key);
if (Object.hasOwn(safe, safeKey)) {
throw new Error(
"ACPX sidecar frame has colliding Unicode property names",
);
}
safe[safeKey] = safeSidecarJsonValue(candidate);
}
return safe;
}
/**
* Bounds provider text by Unicode scalar count so the emitted UTF-8 frame and
* runner-core's `str::chars` admission check observe the same value. Provider
* strings may also contain an isolated UTF-16 surrogate; replace it rather
* than emitting a JSON escape that Rust cannot decode as a string.
*/
export function boundedSidecarText(
value: string,
maxCodePoints: number,
): string {
if (!Number.isSafeInteger(maxCodePoints) || maxCodePoints < 0) {
throw new Error("ACPX sidecar text limit must be a non-negative integer");
}
const bounded: string[] = [];
for (const codePoint of value) {
if (bounded.length >= maxCodePoints) break;
bounded.push(safeSidecarCodePoint(codePoint));
}
return bounded.join("");
}
/**
* Classifies the complete provider value before retaining a bounded display
* copy. `toolOperation` is the sidecar's classification authority when the
* provider token lies beyond the retained prefix; older frames can continue
* to be classified from `kind` and `title` by runner-core.
*/
export function frameAcpxToolClassification(
toolKind: unknown,
boundedToolTitle: unknown,
): {
kind: string | null;
toolOperation: GeneratedAcpxToolOperation;
} {
const toolOperation = classifyGeneratedAcpxToolOperation(
toolKind,
boundedToolTitle,
);
return {
kind:
typeof toolKind === "string"
? boundedSidecarText(toolKind, 4_000)
: null,
toolOperation,
};
}
function safeSidecarCodePoint(codePoint: string): string {
const codeUnit = codePoint.charCodeAt(0);
return codePoint.length === 1 && codeUnit >= 0xd800 && codeUnit <= 0xdfff
? "\uFFFD"
: codePoint;
}
export function sanitizeAcpxPlanEntries(value: unknown): Array<{
content: string;
status: "pending" | "in_progress" | "completed";