Affected: station-v0.88
primitives/egress_classifier.py (redact_messages/_redact_text), primitives/egress_tokens.py (tokenize_messages), wired in station_llm.py complete(). Class: incomplete output redaction / fail-open on an input shape (CWE-116 / CWE-656).
Guarantee. station_llm.complete() probes outbound messages for PII/PHI, lets the egress policy decide would_redact, redacts/tokenizes the flagged categories, forwards only the redacted copy, and seals original + forwarded HMAC into a signed receipt. An identifier the classifier flags should not leave the box in the clear.
Break — detection stringifies, redaction requires str.
probe_messagescallsprobe_text(str(m.get("content") or ""))— it stringifies content, so PII inside a listcontent(standard multimodal shapecontent:[{"type":"text","text":"…"}]) is detected and drives policywould_redact.- Both redactors gate on
isinstance(content, str)and pass anything else through unchanged:egress_classifier._redact_text(line 242) andegress_tokens.tokenize_messages(line 157 — the pathstation_llmuses).
So for list content: detected → policy says redact → redaction is a silent no-op → the raw PII passes the redaction stage unredacted into forwarded_messages and is handed to _provider_call(forwarded_messages). forwarded_messages = messages (station_llm:402); tokenize_messages(messages,…) (427); _provider_call(forwarded_messages,…) (674). And because redaction changed nothing, payload_hmac_original == payload_hmac_forwarded, even though a redaction decision was taken — so a receipt consumer trusting that decision is misled (the equal HMACs are the observable tell).
Proof (real functions):
probe_messages(list-content) -> {'email':1,'phone':1} # detected
redact_messages(list-content,[email,phone]) -> content UNCHANGED (raw email+phone)
tokenize_messages(list-content,…) -> content UNCHANGED, mapping {}
(str-content is correctly redacted/tokenized in both.)
Honest scope.
- Precondition 1: an operator egress policy with a
would_redactrule must apply (detection alone defaults to allow) — i.e. an install that has configured PII-egress redaction, the population this control serves. - Precondition 2: reachability needs a caller that preserves list content into
complete(). NOT the DAG path —workflow_enginejson.dumps-stringifies non-string model input first (so that path IS redacted). A direct/multimodal caller is the trigger. - The raw PII passes the redaction stage unredacted and is handed to the provider adapter; whether it is transmitted/ingested depends on a structured-content-capable adapter/model. The proof establishes the detection/redaction asymmetry in the real primitives and the forwarding call path — not a live network capture — so provider disclosure is established via the shown call path for compatible adapters, not asserted for every provider. No error/exception path involved; redaction IS called. Distinct from the two prior PHI threads (not-called; error-path fail-open) — here a whole input shape is skipped by an
isinstance(str)gate in both redaction primitives.
Fix. Normalize/recurse content in the redactors to match detection: when content is a list, redact each part's text (and recurse), mirroring how probe_messages already stringifies to detect. Or fail closed: a flagged message with non-string content the redactor can't process → deny the egress rather than forward it.
Reviewed adversarially against the source before posting.
Signed receipt (railcall verify → SIGNATURE VALID, offline):
{
"schema": "railcall_audit_receipt.v1",
"ran_at": "2026-08-13T13:17:34",
"file": {
"name": "findings_egress_list.csv",
"sha256": "sha256:741a378397abe9a45cacf1b447daa3283b7bf5c1b3e84e34c0e613f46d756492",
"bytes": 330
},
"audit": {
"rows": 1,
"columns": 4,
"import_breakers": 0,
"pii_columns": 0,
"formula_injection_cells": 0,
"findings": []
},
"network_audit": {
"lsof_available": false,
"error": "lsof_not_found",
"external_sockets_open": null
},
"result": "audited",
"receipt_version": "v2",
"flow": {
"dry_run": true,
"name": "audit",
"action_type": "audit"
},
"governance": {
"policy_ref": "none",
"policy_hash": "ff56072e81ed4908ea91f567741238b387e536cd1f5974513ee18df0d5c575b9",
"approval_chain": [],
"risk_classification": "unknown",
"irreversible": false
},
"execution": {
"input_sha256": "sha256:741a378397abe9a45cacf1b447daa3283b7bf5c1b3e84e34c0e613f46d756492",
"output_sha256": "",
"duration_ms": 0,
"exit_code": 0
},
"signer_alg": "ed25519",
"public_key_hex": "ea2446fec9cc4de478c853fb35c778262d4327ac7d32d6ccff36bdbbfcd775e2",
"signature_hex": "c3775f5845c53af10dd80a3a03c75eea15fb54ceb207f6ff3961f7591e35b9df5f06cd1ddc5107471df102e57ffdea6c97e3f96fcf514585ce629924bfa7c803"
}