fix(a2a): defer route commit until EOS to prevent prefix-poisoning - #722
fix(a2a): defer route commit until EOS to prevent prefix-poisoning#722mkoushni wants to merge 4 commits into
Conversation
GuardResult::Redact was returning FilterAction::Continue while recording status=redacted (guardrails fix). Separately, the A2A non-streaming capture path committed task-route ownership as soon as the JSON balance scanner reported a structurally complete value, regardless of end_of_stream. A backend could deliver a valid JSON task response with end_of_stream=false, have the route stored, then append trailing garbage to produce an overall-invalid response — poisoning task ownership while the complete body was not valid JSON. When the scanner reports is_complete but end_of_stream is false, parse_to_tentative() now stores the parsed value in filter metadata under a2a.response.tentative_json without committing to the route store. Subsequent chunks are inspected: - non-whitespace bytes → tentative discarded (trailing content proves the response is not valid JSON) - end_of_stream with no further non-whitespace → commit_tentative_capture() promotes the held value into the store If end_of_stream arrives together with is_complete, try_capture_from_buffer() is called directly as before, preserving the common fast path. Update tests: - json_response_split_across_chunks_defers_capture_until_eos - many_single_byte_chunks_capture_route_at_eos - split_json_response_with_context_stores_context_route - assert_capture_scratch_cleared includes a2a.response.tentative_json Add regression tests: - complete_json_prefix_then_garbage_does_not_capture_route - complete_json_followed_by_whitespace_at_eos_captures_route Fixes: fnd_sig-feat-custom-ai-agentic-class_e1322f3e62 Signed-off-by: mkoushni <mkoushni@redhat.com>
praxis-bot
left a comment
There was a problem hiding this comment.
Security Fix Review: Defer Route Commit Until EOS
The fix correctly closes the prefix-poisoning vector by introducing a tentative-parse-then-confirm workflow. The core logic is sound: is_complete && !end_of_stream stores a tentative parse, subsequent non-whitespace discards it, and EOS-with-no-content commits it. The fast path (is_complete && end_of_stream simultaneously) is preserved unchanged.
Two medium findings below.
Reviewed: filters/src/agentic/a2a/mod.rs, filters/src/agentic/a2a/tests.rs
| // Non-whitespace bytes after the balanced prefix: the full response | ||
| // is not valid JSON. Discard the tentative route. | ||
| clear_capture_metadata(ctx); | ||
| } else if end_of_stream { |
There was a problem hiding this comment.
[Medium] The tentative guard has three branches (discard, commit, keep-waiting) and none emit tracing. Every other route lifecycle event in this module has structured debug! calls: store_task_route logs stores and removes, accumulate_response_hex logs size-exceeded, lookup_task_route logs hits and misses. The discard branch is especially important for visibility — it fires on a potential poisoning attempt and operators need to see it.
Add debug! calls to the discard and commit branches:
if has_new_content {
debug!("tentative route discarded: trailing non-whitespace after balanced JSON prefix");
clear_capture_metadata(ctx);
} else if end_of_stream {
debug!("tentative route confirmed at EOS: no trailing content");
commit_tentative_capture(ctx, store, config);
clear_capture_metadata(ctx);
}| let json = | ||
| r#"{"jsonrpc":"2.0","id":1,"result":{"task":{"id":"task-ws","status":{"state":"TASK_STATE_WORKING"}}}}"#; | ||
| let mut body1 = Some(Bytes::from(json)); | ||
| drop(filter.on_response_body(&mut ctx, &mut body1, false).unwrap()); |
There was a problem hiding this comment.
[Medium] The // else: whitespace-only chunk, EOS not yet seen — keep waiting branch in the tentative guard is not tested. complete_json_followed_by_whitespace_at_eos_captures_route delivers whitespace with end_of_stream=true in a single call, so it exercises the commit path, not the waiting path.
Add a test that sends a whitespace-only chunk with end_of_stream=false after the tentative is set, asserts the tentative survives (route not yet committed), then sends a separate end_of_stream=true callback and asserts the route is committed. This proves the tentative persists across multiple intermediate chunks.
#[tokio::test]
async fn tentative_survives_whitespace_chunk_before_eos() {
// ... setup ...
// Chunk 1: complete JSON, EOS=false -> tentative set
// Chunk 2: whitespace only, EOS=false -> tentative must survive
let mut ws = Some(Bytes::from_static(b" \n"));
drop(filter.on_response_body(&mut ctx, &mut ws, false).unwrap());
assert!(store.get_by_task_id(...).is_none(), "...");
assert!(ctx.filter_metadata.contains_key("a2a.response.tentative_json"), "...");
// Chunk 3: EOS=true, no body -> commit
let mut eos = None;
drop(filter.on_response_body(&mut ctx, &mut eos, true).unwrap());
assert_eq!(store.get_by_task_id(...).as_deref(), Some("agent-a"), "...");
}
praxis-bot
left a comment
There was a problem hiding this comment.
Security Fix Review: Defer Route Commit Until EOS (Follow-up)
The tentative-parse-then-confirm workflow is correct and closes the prefix-poisoning vector. The branch ordering (is_complete && !end_of_stream before is_complete || end_of_stream) ensures the tentative path fires only when needed, and the fast path (is_complete && end_of_stream simultaneously) remains unchanged. The has_new_content check correctly treats None bodies, empty bodies, and whitespace-only bodies as non-content, while rejecting any non-whitespace trailing bytes.
One medium finding below. Prior findings (tracing gaps, whitespace-only intermediate chunk test) from the earlier review still apply and are not repeated here.
| Severity | Count |
|---|---|
| Critical | 0 |
| Large | 0 |
| Medium | 1 |
Reviewed: filters/src/agentic/a2a/mod.rs, filters/src/agentic/a2a/tests.rs
| /// state immediately rather than holding the (still-growing) buffer and | ||
| /// re-attempting the same doomed parse at `end_of_stream`. | ||
| /// Called when EOS is confirmed (either `end_of_stream=true` on the current | ||
| /// call, or after a tentative parse was promoted by a whitespace-only EOS |
There was a problem hiding this comment.
[Medium] This doc comment says the function is called "after a tentative parse was promoted by a whitespace-only EOS callback," but that path calls commit_tentative_capture, not this function. try_capture_from_buffer is only reached from the is_complete || end_of_stream branch -- either the fast path where both conditions are true simultaneously, or the fallback where EOS arrives with an incomplete buffer.
Suggested:
/// Called from the `is_complete || end_of_stream` branch of
/// [`handle_non_streaming_capture`] -- either the fast path where both
/// conditions are true simultaneously, or the fallback where EOS arrives
/// with an incomplete buffer. Parses the full accumulated hex buffer and,
/// on success, stores the extracted routes. A failed parse is
/// unconditionally terminal and clears capture state.
fix(a2a): defer route commit until EOS to prevent prefix-poisoning
Summary
Addresses Clawpatch finding
fnd_sig-feat-custom-ai-agentic-class_e1322f3e62(severity: medium / security / confidence: high).The A2A non-streaming capture path committed task-route ownership as soon
as the JSON balance scanner reported a structurally complete value, regardless
of
end_of_stream. A backend could deliver a valid JSON task response withend_of_stream=false, have the route stored, then append trailing garbage —producing an overall-invalid response while poisoning task ownership.
Root Cause
Once the route was in the store and state was cleared, no subsequent chunk
could retract it. Trailing garbage arriving after a balanced JSON prefix was
silently dropped.
Fix
When
is_complete && !end_of_stream, the parsed value is stored in filtermetadata under
a2a.response.tentative_json. On every subsequent call:The
end_of_stream=true+is_complete=truefast path is unchanged.Changes
filters/src/agentic/a2a/mod.rshandle_non_streaming_capture— tentative guard + new branchfilters/src/agentic/a2a/mod.rsparse_to_tentative()— new helperfilters/src/agentic/a2a/mod.rscommit_tentative_capture()— new helperfilters/src/agentic/a2a/mod.rsclear_capture_metadata()— clearsa2a.response.tentative_jsonfilters/src/agentic/a2a/tests.rsUpdated tests:
json_response_split_across_chunks_defers_capture_until_eos(renamed fromcaptures_opportunistically)many_single_byte_chunks_capture_route_at_eos(renamed, adds EOS call)split_json_response_with_context_stores_context_route(adds EOS call)assert_capture_scratch_cleared(checkstentative_jsoncleared)New regression tests:
complete_json_prefix_then_garbage_does_not_capture_route— the exact attack scenario: valid JSON prefix + trailing garbage → no route storedcomplete_json_followed_by_whitespace_at_eos_captures_route— whitespace at EOS confirms and commits the tentative routeTest Results
Resolve
#675