Skip to content

fix(a2a): defer route commit until EOS to prevent prefix-poisoning - #722

Open
mkoushni wants to merge 4 commits into
praxis-proxy:mainfrom
mkoushni:fix/a2a-defer-route-commit-until-eos
Open

fix(a2a): defer route commit until EOS to prevent prefix-poisoning#722
mkoushni wants to merge 4 commits into
praxis-proxy:mainfrom
mkoushni:fix/a2a-defer-route-commit-until-eos

Conversation

@mkoushni

Copy link
Copy Markdown
Contributor

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 with
end_of_stream=false, have the route stored, then append trailing garbage —
producing an overall-invalid response while poisoning task ownership.


Root Cause

// Before — VULNERABLE
} else if is_complete || end_of_stream {
    try_capture_from_buffer(ctx, store, config);  // ← commits immediately on is_complete
}
// try_capture_from_buffer — COMMITS AND CLEARS STATE
if let Some(value) = parsed && let Some(cluster) = ... {
    store_task_route(&value, cluster, store, config);  // ← route poisoned
}
clear_capture_metadata(ctx);  // ← any follow-up garbage ignored

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

} else if is_complete && !end_of_stream {
    // Hold tentatively — commit only when EOS confirms no trailing bytes.
    parse_to_tentative(ctx);
} else if is_complete || end_of_stream {
    // EOS confirmed: commit immediately (common fast path unchanged).
    try_capture_from_buffer(ctx, store, config);
}

When is_complete && !end_of_stream, the parsed value is stored in filter
metadata under a2a.response.tentative_json. On every subsequent call:

Condition Action
Non-whitespace bytes arrive before EOS Discard tentative — trailing content proves invalid JSON
EOS with no further non-whitespace bytes Commit tentative route to store
Whitespace-only bytes, EOS not yet seen Keep waiting

The end_of_stream=true + is_complete=true fast path is unchanged.


Changes

File Change
filters/src/agentic/a2a/mod.rs handle_non_streaming_capture — tentative guard + new branch
filters/src/agentic/a2a/mod.rs parse_to_tentative() — new helper
filters/src/agentic/a2a/mod.rs commit_tentative_capture() — new helper
filters/src/agentic/a2a/mod.rs clear_capture_metadata() — clears a2a.response.tentative_json
filters/src/agentic/a2a/tests.rs 4 existing tests updated + 2 regression tests added

Updated tests:

  • json_response_split_across_chunks_defers_capture_until_eos (renamed from captures_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 (checks tentative_json cleared)

New regression tests:

  • complete_json_prefix_then_garbage_does_not_capture_route — the exact attack scenario: valid JSON prefix + trailing garbage → no route stored
  • complete_json_followed_by_whitespace_at_eos_captures_route — whitespace at EOS confirms and commits the tentative route

Test Results

test result: ok. 1011 passed; 0 failed; 0 ignored (unit tests)
test result: ok. 2 passed; 0 failed; 5 ignored (integration tests)

Resolve

#675

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>
@mkoushni
mkoushni requested review from a team and aslakknutsen August 12, 2026 12:18

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants