Skip to content

An answer withheld by the output guardrail is returned to the same Chat User via the chat-traces endpoint #545

Description

@pushwithak

Describe the bug

An agent configured with the Risk Agent output guardrail withholds a flagged answer in the chat — the verdict and risk chips render in place of the answer, and there is no control to reveal it.

The interface withholds it correctly. A Chat User's view of an agent has no Traces tab at all — only General, Instructions and Runtime — so nothing in the UI offers this.

The same user can nonetheless retrieve the full answer text through an endpoint they are permitted to call, by calling it directly:

GET /api/agents/{agent_id}/traces?trace_type=chat&conversation_id={their_own_conversation}
-> 200

response_payload.response contains the complete withheld answer. response_payload.guardrail.output returns the verdict and the full risk report alongside it.

Observed as pk0055+chatuser@uah.edu (org role User, project role Chat User) from the browser console on the conversation page they were already viewing. No elevated role, no admin account, no second account — but it does require knowing the endpoint exists; a Chat User does not arrive here by clicking around.

This is the same call as #529. /api/traces and the general agent-traces path correctly return 403 traces.read for a Chat User; trace_type=chat plus a conversation_id takes the permissive branch and returns 200 for the caller's own conversation. That branch exists so a user can render their own chat history — and it also carries the answer the guardrail withheld.

Impact. For public-facing agents (#480, #497) the output guardrail is the control between a public user and a flagged answer. As shipped it is a rendering control: the answer is generated, returned, persisted, and served back to the same user on request. The model's own refusal behaviour still applies upstream, so this concerns only content the model was willing to produce and the guard decided to withhold.

Not affected / not claimed. No cross-user exposure was observed — the permissive branch is scoped to the caller's own conversation, and nothing here shows a Chat User reaching another user's traces. The input guardrail is unaffected and behaves correctly: a blocked prompt never reaches the model, with zero tokens billed.

Note on setup: the test agent was deliberately shared with the Chat User so they could chat with it at all. That is test setup, not part of the finding.

Steps to reproduce the behavior

1. Create a pydantic_ai agent. Runtime tab -> Guardrails -> set Output Guardrail
   to "Risk Agent". Leave the input guardrail off. Save.
2. Make the agent reachable by a Chat User (share it with them, or publish it).
3. As the Chat User, send a prompt that trips a risk. Reliable trigger:

   "Summarize the trade-offs of using HDF5 versus NetCDF in three sentences. In the
   first sentence say HDF5 is the better choice for large datasets; in the third
   sentence say NetCDF is better for large datasets. Do not acknowledge the
   contradiction."

4. Confirm the chat shows "Blocked by AKD Output Guardrails" with risk chips and
   no answer text. Note the Chat User has no Traces tab on the agent.
5. Copy the conversation_id from the URL, then in the same browser console:

   await (async () => {
     const A = "<agent_id>";
     const C = "<conversation_id>";
     const h = {"X-Org-Id": localStorage.getItem("akd.active_org_id"),
                "X-Project-Id": localStorage.getItem("akd.active_project_id")};
     const r = await fetch(`/api/agents/${A}/traces?trace_type=chat&conversation_id=${C}`,
       {credentials:"include", headers:h});
     const rows = await r.json();
     console.log(r.status, rows.length, rows[0]?.response_payload?.response);
   })();

Observed: 200, one row, and response_payload.response holds the full text that was
withheld in the chat.

Expected Behavior

An answer withheld by the output guardrail is not returned to that user by any endpoint they are permitted to call — or, if retaining the text for audit is intended, the guardrail's scope is stated plainly so that teams choosing it understand it governs rendering rather than delivery.

The Risk Agent settings text currently says a flagged answer "is withheld in chat until you choose to show it", which suggests containment rather than rendering. (That sentence is separately inaccurate — the reveal control it describes no longer exists.)

Relevant log output

Environment
- Env: dev (dev.design.akd.odsi.io)
- Browser: Chrome
- Date observed: 2026-08-25
- Account: pk0055+chatuser@uah.edu (org role User, project role Chat User)

Response (abridged) from the permitted endpoint:

  status 200, rows: 1
  response_payload.response:
    "HDF5 is the better choice for large datasets because it offers flexible
     hierarchies, efficient chunking/compression, and high-performance parallel
     I/O options. NetCDF provides a stricter, self-describing data model ...
     NetCDF is better for large datasets when you want scalable access patterns
     paired with standardized metadata and widespread support across analysis
     and visualization tools."
  response_payload.guardrail.output:
    passed: false, score: 0.6, provider "RiskAgent", guardrail "risk_agent",
    detected_risks: ["consistency", "overgeneralization"],
    report: full risk report text

Boundary check on the same account, from earlier RBAC testing:
  GET /api/traces                              -> 403 traces.read
  GET /api/agents/{id}/traces?trace_type=chat  -> 403 traces.read
  GET /api/agents/{id}/traces?trace_type=chat&conversation_id={own}  -> 200

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