Preserve task prose at the ACPX tool execution boundary

This commit is contained in:
Dotta 2026-09-11 15:33:52 -05:00
parent 9e20cc132e
commit d68ed3ae11
2 changed files with 124 additions and 3 deletions

View File

@ -4,7 +4,7 @@ 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::durable::{redact_text, sanitize_semantic_tool_input, sanitize_value};
use crate::generated_acpx_sidecar_contract::{
classify_generated_acpx_tool_operation, GeneratedAcpxSidecarEventType,
};
@ -133,11 +133,19 @@ pub fn decode_acpx_event(
"ACPX tool call input must be an object",
));
}
let operation_id = required_id(&event.payload, "operationId", "tool operation")?;
// This input is dispatched as a mutation, not merely displayed in
// the event feed. Use the same declared-prose policy as native
// semantic_tool.input before any generic diagnostic scrub can
// irreversibly change the task's requirements.
let safe_input = sanitize_semantic_tool_input(&operation_id, &input)
.map_err(|error| LocalRunnerError::invalid(error.to_string()))?;
Ok(AcpxEventPayload::ToolCalled {
call_id: required_id(&event.payload, "callId", "tool call")?,
operation_id: required_id(&event.payload, "operationId", "tool operation")?,
operation_id,
// Keep the original digest for the sidecar's result binding.
input_digest: semantic_value_digest(&input),
input: sanitize_value(&input),
input: safe_input,
})
}
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal => {

View File

@ -495,3 +495,116 @@ fn terminal_events_clear_pending_requests_and_reject_late_turn_events() {
))
.is_err());
}
#[test]
fn mutation_prose_survives_sidecar_decode_pending_state_and_semantic_projection() {
let mut state = AcpxProviderState::new("run-1").unwrap();
state.begin_turn("turn-1").unwrap();
let plan = format!(
"{}\nThe token CHAT8322bda781b81 must be included in the document.",
"Relevant context. ".repeat(400)
);
let input = json!({
"title": "Write project description",
"description": "The document must contain the token CHAT8322bda781b81.",
"initialPlan": plan,
"idempotencyKey": "CHAT8322bda781b81-task",
"apiToken": "actual-credential",
});
let mut expected = input.clone();
expected["apiToken"] = json!("[REDACTED]");
let emitted = state
.accept_event(&event(
1,
GeneratedAcpxSidecarEventType::RuntimeToolCalled,
Some("turn-1"),
json!({"callId": "call-1", "operationId": "create_task", "input": input}),
))
.unwrap();
assert_eq!(state.pending_tool("call-1").unwrap().input, expected);
let projected = project_acpx_state_event(
&AcpxEventProjectionContext {
run_id: "run-1".to_owned(),
normalized_session_id: "session-1".to_owned(),
turn_id: "turn-1".to_owned(),
provider_turn_id: None,
item_id: "call-1".to_owned(),
},
&emitted[0],
)
.unwrap();
assert_eq!(projected[0].event_type, "semantic_tool.input");
assert_eq!(projected[0].payload["semantic_tool"]["input"], expected);
assert_eq!(
projected[0].payload["semantic_tool"]["content"]["digest"],
json!(paperclip_runner_core::provider_bridge::semantic_value_digest(&expected))
);
for (operation, field, prose, preserved) in [
(
"write_document",
"body",
"Include the token CHAT8322bda781b81.",
true,
),
(
"create_project",
"description",
"Include the token CHAT8322bda781b81.",
true,
),
(
"get_task_context",
"description",
"Include the token CHAT8322bda781b81.",
false,
),
(
"mcp__untrusted__create_task",
"description",
"Include the token CHAT8322bda781b81.",
false,
),
(
"create_task",
"description",
"Authorization: Bearer actual-credential",
false,
),
(
"create_task",
"initialPlan",
"access token actual-credential",
false,
),
] {
state
.complete_tool(
"call-1",
state
.pending_tool("call-1")
.unwrap()
.operation_id
.clone()
.as_str(),
)
.unwrap();
let emitted = state
.accept_event(&event(
2,
GeneratedAcpxSidecarEventType::RuntimeToolCalled,
Some("turn-1"),
json!({"callId": "call-1", "operationId": operation, "input": {field: prose}}),
))
.unwrap();
let AcpxProviderStateEvent::ToolCall { input, .. } = &emitted[0] else {
panic!("expected tool call");
};
assert_eq!(
input[field] == json!(prose),
preserved,
"{operation}: {prose}"
);
assert!(!input.to_string().contains("actual-credential"));
}
}