Skip to content

fix: guardrail webhook sends invalid JSON on large payloads; after_tool skipped for complete_task#527

Open
prince-shakyaa wants to merge 3 commits into
GenAI-Security-Project:mainfrom
prince-shakyaa:fix/guardrail-payload-truncation-and-after-tool-hook
Open

fix: guardrail webhook sends invalid JSON on large payloads; after_tool skipped for complete_task#527
prince-shakyaa wants to merge 3 commits into
GenAI-Security-Project:mainfrom
prince-shakyaa:fix/guardrail-payload-truncation-and-after-tool-hook

Conversation

@prince-shakyaa

@prince-shakyaa prince-shakyaa commented Jun 7, 2026

Copy link
Copy Markdown

fix: guardrail webhook sends invalid JSON on large payloads; after_tool skipped for complete_task

Summary

Fixes #525

This PR fixes two bugs in the FinBot Labs guardrail webhook system:

  1. Payload truncated mid-JSON before signing : large guardrail hook payloads were being sliced at a raw byte offset, producing invalid JSON that the receiver could not parse, while the HMAC signature continued to validate correctly. The receiver had no way to detect this corruption.

  2. after_tool hook never fires for complete_task : the agent loop returned immediately after complete_task succeeded, bypassing the corresponding after_tool invocation and leaving every complete_task call with an unmatched before_tool event.


Problem 1 : Payload truncated mid-JSON

Root Cause

In finbot/guardrails/service.py, the invoke() method serializes the full HookEnvelope to JSON bytes and then slices them at LABS_GUARDRAIL_MAX_PAYLOAD_BYTES:

body_bytes = envelope.model_dump_json().encode()
if len(body_bytes) > max_payload:
    body_bytes = body_bytes[:max_payload]   # ← raw byte slice, breaks JSON
signature = self._sign_payload(body_bytes, ...)  # HMAC over broken bytes

A raw byte slice cuts the JSON in the middle of a field value. The result is syntactically invalid JSON (e.g. ..."model_output": "here is the ana), but because the HMAC is computed over the same truncated bytes, the signature check on the receiver side passes - the corruption is completely invisible to the receiver.

Fix

Truncation is moved before serialization, at the field level:

  • Before calling model_dump_json(), we compute a tentative payload size.
  • If it would exceed max_payload, we cap the long string fields (model_output, tool_result, user_message) inside the envelope so that re-serialization fits within the limit.
  • The signed body is always valid, parseable JSON.
  • Two new headers are added to the webhook request when truncation occurs:
    • X-Guardrail-Truncated: true
    • X-Guardrail-Full-Size: <original_byte_count>

This allows the receiver to detect truncation explicitly rather than hitting a JSONDecodeError.

Files Changed

File Change
finbot/guardrails/service.py Replace raw byte slice with field-level truncation; add X-Guardrail-Truncated and X-Guardrail-Full-Size headers

Problem 2 : after_tool skipped for complete_task

Root Cause

In finbot/agents/base.py, lines 152–189, the before_tool guardrail hook fires for every tool call, including complete_task. However, when complete_task succeeds, the agent loop returns immediately on line 174, before the after_tool invocation on line 183 is reached:

await self._guardrail_service.invoke(HookKind.before_tool, ...)  # fires ✓

try:
    function_output = await callable_fn(**tool_call["arguments"])
    if tool_call_name == "complete_task":
        await self.log_task_completion(...)
        return function_output  # ← returns here, skips after_tool ✗

await self._guardrail_service.invoke(HookKind.after_tool, ...)  # never reached

Any guardrail that tracks paired before_tool / after_tool events (e.g. to measure tool execution time or audit a tool's output) will see every complete_task appear open-ended.

Fix

The after_tool invocation is moved to execute before the early return for complete_task:

function_output = await callable_fn(**tool_call["arguments"])
if tool_call_name == "complete_task":
    await self._guardrail_service.invoke(        # fires ✓ before return
        HookKind.after_tool,
        tool_name=tool_call_name,
        tool_source=tool_source,
        tool_arguments=tool_call.get("arguments"),
        tool_result=str(function_output),
    )
    await self.log_task_completion(task_result=function_output)
    return function_output

Files Changed

File Change
finbot/agents/base.py Invoke after_tool guardrail hook before the early return for complete_task

Behaviour After This PR

Scenario Before After
Large after_model payload exceeds limit Receiver gets broken JSON; HMAC passes; parse fails silently Receiver gets valid JSON with long fields truncated; X-Guardrail-Truncated: true header present
Large after_tool payload exceeds limit Same as above Same as above
Normal-sized payload Unchanged Unchanged
complete_task tool call before_tool fires, after_tool never fires Both before_tool and after_tool fire

Tests

Five new unit tests are added in tests/unit/labs/test_guardrail_truncation_and_hooks.py, following the existing patterns in test_guardrail_service.py:

Test ID Description
GWT-TRN-001 Large payload is truncated at field level : body remains valid JSON
GWT-TRN-002 Normal-sized payload is sent unchanged : no truncation headers present
GWT-TRN-003 X-Guardrail-Truncated: true and X-Guardrail-Full-Size: <N> headers set when truncated
GWT-TRN-004 HMAC signature is computed over the valid (post-truncation) JSON body
GWT-ATL-001 after_tool fires for complete_task before the agent loop exits : no unpaired hook

All 5 tests pass locally.


Affected Files

File Type Description
finbot/guardrails/service.py Modified Field-level payload truncation + truncation headers
finbot/agents/base.py Modified after_tool hook for complete_task before early return
tests/unit/labs/test_guardrail_truncation_and_hooks.py New Unit tests for both fixes

Prince Shakya and others added 3 commits June 7, 2026 21:02
…ped for complete_task

- Replace raw byte-slice truncation in GuardrailHookService.invoke() with
  field-level truncation: cap model_output, tool_result, user_message strings
  before serialisation so the body always remains valid JSON.
- Add X-Guardrail-Truncated: true and X-Guardrail-Full-Size: <N> headers
  when truncation occurs so the webhook receiver can detect it explicitly.
- In _run_agent_loop(), invoke the after_tool guardrail hook for complete_task
  before the early return, ensuring every before_tool has a matching after_tool.

Fixes: guardrail HMAC passes on broken JSON body; after_tool never fires for
complete_task tool calls.
Signed-off-by: Prince Shakya <psprince2005@gmail.com>
…hook pairing

- GWT-TRN-001: large payload truncated at field level, body remains valid JSON
- GWT-TRN-002: normal-sized payload is sent unchanged, no truncation headers
- GWT-TRN-003: X-Guardrail-Truncated / X-Guardrail-Full-Size headers set on truncation
- GWT-TRN-004: HMAC signature is computed over the valid (post-truncation) body
- GWT-ATL-001: after_tool guardrail hook fires for complete_task before early return
@prince-shakyaa

Copy link
Copy Markdown
Author

Hii @saikishu @e2hln ,
This PR fixes two guardrail bugs reported in the issue : the raw byte-slice truncation that was silently sending broken JSON to webhooks (while the HMAC still passed), and the missing after_tool hook for complete_task that left every agent loop exit with an unpaired before_tool event.
Both fixes are covered by 5 new unit tests in tests/unit/labs/ that follow the existing test patterns.

Happy to make any changes based on your feedback.

Thank You.

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.

[Bug] Guardrail webhook payload truncated mid-JSON before signing: body is unparseable but HMAC passes, and after_tool hook skipped for complete_task

1 participant