diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs index 77f5f913c3e..b455854db11 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_event_payload.rs @@ -377,7 +377,8 @@ fn decode_rich_event(value: &Value) -> Result value.clone(), } } - let payload = sanitize_display(payload); + let mut payload = sanitize_display(payload); + fit_redacted_display_to_length_bounds(event_type, &mut payload); if !validator.is_valid(&payload) { return Err(LocalRunnerError::invalid( "ACPX rich event redaction exceeded its schema bounds", @@ -411,6 +412,45 @@ pub(crate) fn has_bounded_rich_display_shape(event_type: &str, payload: &Value) }) } +fn rich_display_length_overflows(event_type: &str, payload: &Value) -> Vec<(String, usize)> { + let Some(schema) = rich_event_schema(event_type) else { + return Vec::new(); + }; + let Some(validator) = rich_event_validators() + .ok() + .and_then(|validators| validators.get(schema)) + else { + return Vec::new(); + }; + validator + .iter_errors(payload) + .filter_map(|error| match error.kind() { + jsonschema::error::ValidationErrorKind::MaxLength { limit } => usize::try_from(*limit) + .ok() + .map(|limit| (error.instance_path().as_str().to_owned(), limit)), + _ => None, + }) + .collect() +} + +pub(crate) fn fit_redacted_display_to_length_bounds(event_type: &str, payload: &mut Value) { + let mut output_truncated = false; + for (pointer, limit) in rich_display_length_overflows(event_type, payload) { + if let Some(Value::String(text)) = payload.pointer_mut(&pointer) { + let excess = text.chars().count().saturating_sub(limit); + *text = if pointer == "/output" { + output_truncated = true; + text.chars().skip(excess).collect() + } else { + text.chars().take(limit).collect() + }; + } + } + if output_truncated && event_type.starts_with("tool.execution.") { + payload["outputTruncated"] = Value::Bool(true); + } +} + fn sanitize_question_set(mut value: Value) -> Result { let original = value.clone(); let Some(question_set) = value.as_object_mut() else { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index e2765c5bb08..58fffdfb2e4 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -1947,13 +1947,17 @@ fn preserve_bounded_display_content( original: &Value, mut sanitized: Value, ) -> Result { - use crate::acpx_event_payload::{has_bounded_rich_display_shape, validate_question_set}; + use crate::acpx_event_payload::{ + fit_redacted_display_to_length_bounds, has_bounded_rich_display_shape, + validate_question_set, + }; // Rich display payloads are already closed and byte-bounded by their exact // protocol schemas. Applying a diagnostic preview cap again would silently // change complete output into a truncated artifact without its metadata. if has_bounded_rich_display_shape(event_type, original) { - let preserved = sanitize_bounded_display_value(original); + let mut preserved = sanitize_bounded_display_value(original); + fit_redacted_display_to_length_bounds(event_type, &mut preserved); if !has_bounded_rich_display_shape(event_type, &preserved) { return Err(DurableRunnerError::invalid( "redacted display activity exceeds its schema bounds", @@ -4677,4 +4681,6 @@ mod tests { .is_err()); assert!(state.outbox.is_empty()); } + + include!("tests/redaction_display_bounds.rs"); } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/tests/redaction_display_bounds.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/tests/redaction_display_bounds.rs new file mode 100644 index 00000000000..74bb4ee48ca --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/tests/redaction_display_bounds.rs @@ -0,0 +1,83 @@ +#[test] +fn redaction_growth_keeps_a_full_tool_output_inside_its_schema_bound() { + let mut config = config(PathBuf::from("unused")); + config.max_outbox_bytes = 1024 * 1024; + config.max_frame_bytes = 256 * 1024; + for (needle, secret) in [ + ("GITHUB_TOKEN: ***", "***"), + ("token=abc", "token=abc"), + ("Authorization: Bearer x", "Bearer x"), + ("password: hunter2", "hunter2"), + ] { + let mut state = DurableState::new(&config); + let line = format!("{}\n{needle}\n", "x".repeat(70)); + let text: String = line + .repeat(65_536 / line.len() + 1) + .chars() + .take(65_536) + .collect(); + state + .enqueue_event(&config, "tool.execution.completed", EventPriority::P1, json!({ + "schema":"paperclip.tool.execution.v1","executionId":"tool-1","transport":"builtin","operation":"execute","status":"completed", + "output":text,"outputBytes":200_000,"outputTruncated":false,"outputDigest":format!("sha256:{}", "a".repeat(64)) + })) + .unwrap_or_else(|error| panic!("{needle} was rejected: {error:?}")); + let stored = state.outbox[0] + .envelope + .pointer("/payload/payload") + .unwrap(); + let output = stored["output"].as_str().unwrap(); + let redacted = redact_sensitive_text_values(&text); + let expected: String = redacted + .chars() + .skip(redacted.chars().count() - 65_536) + .collect(); + assert_eq!(output, expected, "{needle}"); + assert!(!output.contains(secret), "{needle}"); + assert_eq!(stored["outputTruncated"], true, "{needle}"); + assert!(crate::acpx_event_payload::has_bounded_rich_display_shape( + "tool.execution.completed", + stored + )); + } +} + +#[test] +fn redaction_growth_keeps_a_bounded_notice_summary_inside_its_schema_bound() { + let config = config(PathBuf::from("unused")); + let mut state = DurableState::new(&config); + let summary = format!("token=abc {}", "y".repeat(3_990)); + assert_eq!(summary.chars().count(), 4_000); + state + .enqueue_event(&config, "provider.notice.recorded", EventPriority::P1, json!({ + "schema":"paperclip.provider.notice.v1","noticeId":"notice-1","severity":"info","category":"provider.status", + "scope":"turn","recoverable":true,"userActionable":false,"summary":summary + })) + .unwrap(); + let stored = state.outbox[0] + .envelope + .pointer("/payload/payload") + .unwrap(); + let stored_summary = stored["summary"].as_str().unwrap(); + assert_eq!(stored_summary.chars().count(), 4_000); + assert!(stored_summary.starts_with("token=[REDACTED] y")); + assert!(stored.get("outputTruncated").is_none()); +} + +#[test] +fn redaction_within_the_bound_leaves_short_tool_output_whole() { + let config = config(PathBuf::from("unused")); + let mut state = DurableState::new(&config); + state + .enqueue_event(&config, "tool.execution.completed", EventPriority::P1, json!({ + "schema":"paperclip.tool.execution.v1","executionId":"tool-1","transport":"builtin","operation":"execute","status":"completed", + "output":"before\ntoken=abc\nafter","outputBytes":22,"outputTruncated":false,"outputDigest":null + })) + .unwrap(); + let stored = state.outbox[0] + .envelope + .pointer("/payload/payload") + .unwrap(); + assert_eq!(stored["output"], "before\ntoken=[REDACTED]\nafter"); + assert_eq!(stored["outputTruncated"], false); +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_rich_events.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_rich_events.rs index dc570b69a41..d577d255489 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_rich_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_rich_events.rs @@ -179,3 +179,49 @@ fn full_plan_presentation_survives_utf8_redaction_without_diagnostic_truncation( .contains("196 KiB") ); } + +#[test] +fn redaction_keeps_full_length_rich_display_inside_its_schema_bounds() { + let markdown: String = format!("# Plan\ntoken=abc\n{}", "- step\n".repeat(10_000)) + .chars() + .take(60_000) + .collect(); + let line = format!("{}\nGITHUB_TOKEN: ***\n", "x".repeat(70)); + let output: String = line + .repeat(65_536 / line.len() + 1) + .chars() + .take(65_536) + .collect(); + for (event_type, payload, field, limit) in [ + ( + "plan.proposed", + json!({"schema":"paperclip.plan.proposed.v1","proposalId":"proposal-1","status":"completed","markdown":markdown,"source":"claude_exit_plan_mode"}), + "markdown", + 60_000, + ), + ( + "tool.execution.completed", + json!({"schema":"paperclip.tool.execution.v1","executionId":"tool-1","transport":"builtin","operation":"execute","status":"completed","output":output,"outputBytes":200_000,"outputTruncated":false,"outputDigest":null}), + "output", + 65_536, + ), + ] { + let decoded = decode_acpx_event(&scope(), &event(event_type, payload)) + .unwrap_or_else(|error| panic!("{event_type} was rejected: {error:?}")); + let AcpxEventPayload::RichActivity { payload, .. } = decoded else { + panic!("{event_type} did not decode as rich activity"); + }; + let text = payload[field].as_str().unwrap(); + assert_eq!(text.chars().count(), limit, "{event_type}"); + assert!(text.contains("[REDACTED]"), "{event_type}"); + assert!( + !text.contains("token=abc") && !text.contains("TOKEN: ***"), + "{event_type}" + ); + if field == "markdown" { + assert!(text.starts_with("# Plan\ntoken=[REDACTED]\n")); + } else { + assert_eq!(payload["outputTruncated"], true); + } + } +}