Skip to content

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

Description

@prince-shakyaa

Guardrail webhook: payload is truncated mid-JSON before signing, making the body unparseable while the HMAC still passes

What's happening

When a guardrail hook fires with a large payload (e.g. an after_model hook on a long LLM response, or an after_tool hook on a big tool result), the platform truncates the raw JSON bytes to LABS_GUARDRAIL_MAX_PAYLOAD_BYTES before computing the HMAC signature and before sending the request.

The result is that the webhook receiver gets:

  1. A body that is not valid JSON : it has been sliced at a byte offset in the middle of a string field or object, e.g. ...{"model_output": "here is the ana
  2. A signature that verifies correctly : the HMAC is computed over the truncated bytes, so any signature check on the receiver side will pass

So the guardrail silently fails: signature verification succeeds, but json.loads(body) (or Pydantic model parsing) throws an error, and the receiver has no way to know whether truncation happened.


Root Cause

In finbot/guardrails/service.py, the invoke() method does this:

body_bytes = envelope.model_dump_json().encode()

max_payload = settings.LABS_GUARDRAIL_MAX_PAYLOAD_BYTES
if len(body_bytes) > max_payload:
    logger.info("guardrail payload truncated: ...")
    body_bytes = body_bytes[:max_payload]   # ← raw byte slice, breaks JSON

signature = self._sign_payload(body_bytes, config.signing_secret, timestamp)  # ← HMAC on broken bytes

# then sends body_bytes to webhook

The byte slice body_bytes[:max_payload] cuts the serialized JSON at a fixed byte offset. Because UTF-8 multi-byte characters or long string values can span many bytes, the resulting body is almost certainly not valid JSON. Yet the signature is computed over these truncated bytes, so it will match what the receiver independently computes — the receiver has no way to detect the corruption from the signature alone.


Two concrete failure scenarios

Scenario 1 : after_model on a long response

A model produces a 4000-character reply. The after_model hook serializes this into the envelope's model_output field. If the result exceeds the byte limit, the JSON is sliced mid-string and sent. The receiver's Pydantic model parse raises ValidationError or JSONDecodeError, and the hook result is silently dropped. The activity log shows outcome=invalid_verdict (or similar error), with no indication that truncation is the cause.

Scenario 2 : after_tool on a large tool result

An MCP tool returns a large file listing. The tool_result field fills the envelope beyond the limit. Same truncation occurs. The receiver gets corrupted JSON with a passing HMAC.


Also: before_tool fires for complete_task but after_tool never does

In finbot/agents/base.py, lines 152-189, before_tool fires for every tool call including the internal complete_task function. But when complete_task succeeds, the agent returns immediately on line 174, bypassing the after_tool invocation on line 183:

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 for complete_task

If anyone has built a guardrail that tracks paired before_tool/after_tool events to measure tool execution time or verify a tool's output, they'll see every complete_task tool call appear open-ended — a before_tool with no matching after_tool.


Steps to reproduce (payload truncation)

  1. Set up a guardrail webhook and set LABS_GUARDRAIL_MAX_PAYLOAD_BYTES to a small value (e.g. 500 bytes)
  2. Ask the chat assistant a question that produces a long response (> 500 bytes in the serialized hook envelope)
  3. Observe the after_model hook fires, but the receiver logs a JSON parse error
  4. Verify that the HMAC signature in the request headers is valid (it will pass)
  5. Note that there is no X-Guardrail-Truncated or similar header to warn the receiver

Expected behavior

If the payload must be truncated (to respect a size limit), the truncation should happen at the field level, not as a raw byte slice of the serialized JSON. For example, long fields like model_output or tool_result could be capped to a string length before serialization. This way the body remains valid JSON that the receiver can parse. Alternatively, a header like X-Guardrail-Truncated: true and X-Guardrail-Full-Size: <N> should be added so the receiver knows the body was intentionally shortened.


Affected files

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions