Fix: guardrail payload corruption before signing + missing after_tool for complete_task - #576
Open
Deez-Automations wants to merge 1 commit into
Conversation
… for complete_task Two related bugs in the Labs guardrail webhook feature. 1. GuardrailHookService.invoke() sliced the already-serialized JSON body at a raw byte offset when it exceeded the payload size limit, then signed those truncated bytes. Cutting a multi-byte UTF-8 character or a string/object literal mid-way produces a body that is neither valid UTF-8 nor valid JSON, while the HMAC signature still "validates" it -- the receiver has no way to detect the corruption from the signature alone. Fixed by truncating the envelope's long string fields (tool_result, user_message, model_output) individually before serialization -- always valid JSON regardless of where a Python string is cut -- and replacing tool_arguments (a dict, which can't be safely character- truncated) with a small marker preserving its top-level key names. Adds an X-Guardrail-Truncated response header when truncation occurred. 2. before_tool fired for every tool call including the internal complete_task control-flow tool, but complete_task returned immediately on success, bypassing the after_tool invocation every other tool call gets. A guardrail pairing before/after events would see every complete_task call as open-ended. Fixed by firing after_tool for complete_task too, immediately before the existing completion/return logic. Also: GuardrailHookService.invoke() previously only guaranteed no exception from the HTTP call itself -- config loading, envelope construction, and the new truncation logic were unguarded. Since the after_tool call added for fix GenAI-Security-Project#2 sits inside the same try/except as the tool call it's observing (unlike the sibling call site for ordinary tools, which sits outside it), an exception anywhere in guardrail plumbing could have silently converted an already-successful complete_task into a fake failure. invoke() is now wrapped so it never raises under any circumstance, matching its own documented contract ("never gates execution") -- caught during review of this fix, not by the author. Resolves GenAI-Security-Project#525
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related bugs in the Labs guardrail webhook feature, both confirmed against current source before fixing.
1. Payload truncated mid-JSON before HMAC signing
GuardrailHookService.invoke()sliced the already-serialized JSON body at a raw byte offset when it exceeded the payload size limit, then signed those truncated bytes:Cutting a multi-byte UTF-8 character or a string/object literal mid-way produces a body that is neither valid UTF-8 nor valid JSON. The HMAC signature is computed over those broken bytes and still "validates" fine on the receiver's end — there's no way to detect the corruption from the signature alone.
Fix:
_truncate_envelope_for_payload_limit()now truncates the envelope's long string fields (tool_result,user_message,model_output) individually, before serialization. Python string slicing operates on Unicode code points, so cutting a string at any character offset and then lettingmodel_dump_json()re-serialize it always produces valid JSON, regardless of where the cut lands.tool_argumentsis a dict, which can't be safely character-truncated without risking invalid JSON, so it's replaced with a small marker instead —{"_truncated": true, "original_size_bytes": N, "keys": [...]}, preserving the top-level key names so a receiver can still see which arguments existed.invoke()now also sets anX-Guardrail-Truncated: trueresponse header when truncation occurred, so the receiver isn't left guessing.2.
after_toolnever fires for thecomplete_taskcontrol-flow toolIn
finbot/agents/base.py's agent loop,before_toolfires for every tool call including the internalcomplete_tasktool, butcomplete_taskreturned immediately on success, bypassing theafter_toolinvocation every other tool call gets a few lines later. Any guardrail pairing before/after events (e.g. to measure execution time or verify output) would see everycomplete_taskcall as permanently open-ended.Fix: fires
after_toolforcomplete_tasktoo, immediately before the existing completion/return logic, using the same call signature as the sibling call site.3. Found during our own review, not by the issue:
invoke()could mask a successful task completionThe new
after_toolcall forcomplete_tasksits inside the sametry/exceptas the tool call it's observing — unlike the sibling call site for ordinary tools, which sits outside it. If anything in guardrail plumbing raised (config load, envelope construction, the new truncation logic, signing — none of which were previously wrapped, only the HTTP call itself was), the surroundingexcept Exceptionwould have discarded the already-successfulfunction_output, replaced it with a generic error, and let the loop continue instead of returning — silently converting a successfulcomplete_taskinto a fake failure.Fixed at the source rather than patched around the call site:
invoke()is now wrapped so it can never raise under any circumstance, matching its own documented contract ("never gates execution on the verdict"). The original body moved to a private_invoke(); the publicinvoke()catches everything and returnsHookOutcome.erroron any unexpected failure. This also means the try/except placement question becomes moot — the call is now safe wherever it's placed.A note on merge overlap with #575
This branch and the already-open #575 (DNS-based SSRF fix, same guardrail feature) both add tests to
TestWebhookInvocationintests/unit/labs/test_guardrail_service.py. Verified independently: each branch merges cleanly into currentmainon its own. If both get merged, whichever lands second will show a real (if trivial) conflict in that one file, purely because both are adding tests to the same class. Flagging this now so it's not a surprise at merge time — happy to rebase/resolve once the merge order between the two is decided, just didn't want to guess at that order preemptively.Test plan
TestPayloadTruncationclass (tests/unit/labs/test_guardrail_service.py): unchanged-under-limit, valid-JSON-under-truncation (including multi-byte content), single/multi-field truncation, dict-marker replacement with preserved keys, no-over-truncation when barely over, and the pathological case wheremax_payloadis smaller than the envelope's own fixed-field overhead (confirms no crash, still valid JSON, documented as a best-effort limit rather than a hard guarantee)test_oversized_payload_sends_valid_json_and_truncated_header: provesinvoke()sends ajson.loads()-able body with a signature that matches it, plus the truncation headertest_invoke_never_raises_even_on_internal_failure: forces an exception in previously-unwrapped code (_sign_payload) and confirmsinvoke()still returnsHookOutcome.errorrather than propagatingTestGuardrailHooksAroundToolCallsclass (tests/unit/agents/test_base_agent.py): confirmsafter_toolnow fires forcomplete_task, plus a regression guard that ordinary tool calls still get exactly oneafter_tooleachpytest tests/unit/labs/test_guardrail_service.py tests/unit/agents/test_base_agent.py -v— 38/38 passingpytest tests/unit/agents/ tests/unit/labs/ tests/unit/mcp/ -q— 135 passed, no new failurespytest tests/unit/ -q— full suite, 317 passed, 4 pre-existing failures unrelated to this change (present before it too)origin/main— clean on its own and combined with Fix: orchestrator confirms payment to vendor even when it fails #574; the one real conflict found (with Fix: DNS-based SSRF bypass in guardrail webhook URL validation #575, noted above) was isolated and confirmed precisely rather than assumed