The egress guard classifies a message array, asks the policy what to do, and
then redacts or tokenizes before forwarding. Detection and redaction disagree
about where PII can live inside a structured content list, and they disagree in
the unsafe direction.
Detection stringifies the whole content value, so it finds PII anywhere in the
structure. Both redactors walk the list and act only on bare strings and on dict
parts carrying a "text" key:
elif isinstance(part, dict) and isinstance(part.get("text"), str) and part["text"]:
...redact part["text"]...
else:
new_parts.append(part) # everything else passes through untouched
egress_classifier.redact_messages() and egress_tokens.tokenize_messages() both
have this shape. The comment above the branch describes the intent as letting
"non-text parts (images etc.)" through — but the standard tool-call shapes are
not images and are not inert: a tool_result carries its payload under "content",
and a tool_use under "input". Neither has a "text" key, so both fall into the
else branch with their payload intact.
The result is the worst combination: the PII is detected, so the policy
resolves to redact and the call is recorded and receipted as redacted — and the
identifiers are forwarded verbatim.
Reproduction steps:
- Build a message carrying PII in a tool_result part:
msgs = [{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_1",
"content": "patient record: ssn 123-45-6789 / patient.jane@clinic.org"}]}]
- Confirm the classifier detects it:
egress_classifier.probe_messages(msgs)
- Redact with the categories the classifier returned:
egress_classifier.redact_messages(msgs, list(cls.keys()))
- Tokenize the same message the way the production redact path does — the
pattern set station_llm supplies:
egress_tokens.tokenize_messages(msgs, hmac_key, list(cls.keys()),
station_llm._redaction_patterns())
- Repeat all of the above with the only difference being the payload key:
{"type": "text", "text": "<same string>"}.
Expected:
Whatever the classifier detected and the policy said to redact is redacted
before the message leaves the station. A content part the redactor does not
understand must not be forwarded carrying identifiers the policy just flagged —
either redact it, or fail closed, but do not report the call as redacted.
Actual:
A. tool_result part (payload under "content")
DETECTION probe_messages() -> {"email": 1, "ssn_reference": 1}
REDACT replacements: {} PII still verbatim: True
TOKENIZE tokens minted: 0 PII still verbatim: True
forwarded: [{"type":"tool_result","tool_use_id":"tu_1",
"content":"patient record: ssn 123-45-6789 / patient.jane@clinic.org"}]
B. CONTROL: text part (payload under "text")
DETECTION probe_messages() -> {"email": 1, "ssn_reference": 1}
REDACT replacements: {"email":1,"ssn_reference":1} PII verbatim: False
TOKENIZE tokens minted: 2 PII verbatim: False
forwarded: [{"type":"text","text":"patient record:
[REDACTED:ssn_reference] 123-45-6789 / [REDACTED:email]"}]
Identical PII, identical detection, identical policy verdict, opposite outcome —
the only difference is which key the payload sits under.
Root cause:
primitives/egress_classifier.py redact_messages() and
primitives/egress_tokens.py tokenize_messages() — both list-content branches
handle str parts and dict parts with a "text" key, and pass every other dict
part through unchanged, while detection (probe_messages) stringifies the whole
content and therefore sees inside those parts. Detection and redaction are
scoped differently over the same value.
Suggested fix:
Redact every string-valued field of a dict part, not only "text" — at minimum
"text", "content" and "input", and preferably any string leaf reached by walking
the part, which matches what detection already does. Keeping the two in sync
matters more than the exact key list: if the redactor cannot handle a part
shape, the honest outcomes are to fail closed (treat as denied) or to record the
call as NOT redacted, rather than to forward it while reporting redaction.
Honest scope:
Reachable where the message array is caller-shaped rather than stringified.
studio_server.guarded_chat() (behind /api/chat) and compose_raw() pass the
caller's array through as-is, and structured tool_result content is the ordinary
shape an MCP/agentic client produces — this needs no adversarial input, just a
conversation that carries tool output. I traced and am explicitly excluding the
paths that are NOT affected: the workflow model node and the agent loop both
json.dumps non-string content before building messages, so their content is
always a plain string and is redacted correctly.
This is a governance-integrity defect rather than an attacker exfiltrating a
third party's data: the operator's own redact policy silently does not apply,
and the receipt asserts otherwise. No signature forgery, no privilege
escalation, no remote exploit.
Counter-evidence checked:
- Confirmed against the real, unmodified modules from a clean tarball
extraction, driving probe_messages/redact_messages/tokenize_messages directly.
- Ran the tokenize arm with station_llm._redaction_patterns() — the exact
pattern set the production redact branch supplies. An earlier run passing
patterns=None showed the control "leaking" too; that was a harness error, not
a finding, and correcting it is what made the control meaningful.
- The control part proves the redactors work correctly on the shape they
handle, so this is a coverage gap and not a broken redactor.
- Confirmed both the classifier redactor and the tokenizer have the gap, so the
allow/redact policy branch does not rescue it.
- Confirmed the model node and agent loop stringify, so this claim is not
overstated to cover paths that are safe.
Distinctness:
This is the redactors' handling of structured content parts. It is distinct from
the reported finding that list-content dict parts with a "text" key were skipped
entirely — that gap was closed, and this report is about the branch that fix
added, which covers only "text". It is distinct from the egress policy
evaluator failing open on an unrecognized condition (a different module and a
different mechanism: there the policy resolves to allow; here the policy
correctly resolves to redact and the redaction does not happen). It is not the
guard's exception/fail-open path. I did not find a community thread about
non-"text" content parts bypassing redaction.