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:
- 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
- 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)
- Set up a guardrail webhook and set
LABS_GUARDRAIL_MAX_PAYLOAD_BYTES to a small value (e.g. 500 bytes)
- Ask the chat assistant a question that produces a long response (> 500 bytes in the serialized hook envelope)
- Observe the
after_model hook fires, but the receiver logs a JSON parse error
- Verify that the HMAC signature in the request headers is valid (it will pass)
- 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
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_modelhook on a long LLM response, or anafter_toolhook on a big tool result), the platform truncates the raw JSON bytes toLABS_GUARDRAIL_MAX_PAYLOAD_BYTESbefore computing the HMAC signature and before sending the request.The result is that the webhook receiver gets:
...{"model_output": "here is the anaSo 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, theinvoke()method does this: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_modelon a long responseA model produces a 4000-character reply. The
after_modelhook serializes this into the envelope'smodel_outputfield. If the result exceeds the byte limit, the JSON is sliced mid-string and sent. The receiver's Pydantic model parse raisesValidationErrororJSONDecodeError, and the hook result is silently dropped. The activity log showsoutcome=invalid_verdict(or similar error), with no indication that truncation is the cause.Scenario 2 :
after_toolon a large tool resultAn MCP tool returns a large file listing. The
tool_resultfield fills the envelope beyond the limit. Same truncation occurs. The receiver gets corrupted JSON with a passing HMAC.Also:
before_toolfires forcomplete_taskbutafter_toolnever doesIn
finbot/agents/base.py, lines 152-189,before_toolfires for every tool call including the internalcomplete_taskfunction. But whencomplete_tasksucceeds, the agent returns immediately on line 174, bypassing theafter_toolinvocation on line 183:If anyone has built a guardrail that tracks paired
before_tool/after_toolevents to measure tool execution time or verify a tool's output, they'll see everycomplete_tasktool call appear open-ended — abefore_toolwith no matchingafter_tool.Steps to reproduce (payload truncation)
LABS_GUARDRAIL_MAX_PAYLOAD_BYTESto a small value (e.g. 500 bytes)after_modelhook fires, but the receiver logs a JSON parse errorX-Guardrail-Truncatedor similar header to warn the receiverExpected 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_outputortool_resultcould be capped to a string length before serialization. This way the body remains valid JSON that the receiver can parse. Alternatively, a header likeX-Guardrail-Truncated: trueandX-Guardrail-Full-Size: <N>should be added so the receiver knows the body was intentionally shortened.Affected files
finbot/guardrails/service.py—invoke(), lines 119–132 (truncation before signing)finbot/agents/base.py—_run_agent_loop(), lines 152–189 (after_toolskipped forcomplete_task)