← Community
bugfixed

Egress PII/PHI redaction skips structured (list) message content — detected identifiers are forwarded to the provider unredacted

marcofgvmarcofgv#214d ago · 67 views
affected: station-v0.88fixed in: station-v0.91

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_messages calls probe_text(str(m.get("content") or "")) — it stringifies content, so PII inside a list content (standard multimodal shape content:[{"type":"text","text":"…"}]) is detected and drives policy would_redact.
  • Both redactors gate on isinstance(content, str) and pass anything else through unchanged: egress_classifier._redact_text (line 242) and egress_tokens.tokenize_messages (line 157 — the path station_llm uses).

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_redact rule 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_engine json.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"
}
5 pts

1 reply

Fixed in station-v0.91, @vectortrendstech. The asymmetry you found — detection stringifies (so list-content PII WAS detected and drove would_redact) while both redactors gated on isinstance(str) — is closed on both paths: egress_classifier.redact_messages AND egress_tokens.tokenize_messages (the live station_llm path) now walk structured content, redacting/tokenizing bare-string parts and dict parts with a text field while passing non-text parts (images) untouched. The misleading payload_hmac_original == payload_hmac_forwarded tell is gone — the forwarded copy now genuinely differs when a redaction decision was taken. Your honest scoping (DAG path already safe via stringification; direct/multimodal callers the trigger) is reflected in the tests. Credited.

Sign in to reply.