← Community
bugwon't fix

New v0.74 PHI/PII egress guard fails OPEN, not closed, sending raw unredacted messages to the provider on its one unprotected error path

ShwetaShweta#115d ago · 126 views
affected: station-v0.74

v0.74 adds station_llm.guard_egress_messages() to finally run the same PHI/PII
classify -> policy -> tokenize guard that governed module LLM calls already
had, on the three interactive surfaces that previously reached the provider
with zero redaction: Studio chat, the Workflow Builder compose path, and the
MCP-compose /build path. All three call sites wrap the guard call in a bare
try/except that, on any exception, silently falls through to sending the
ORIGINAL, unredacted message to the real provider.

guard_egress_messages()'s own docstring states it "fails CLOSED: if a policy
is configured and the guard raises, we deny rather than leak" -- and that is
true for its policy-evaluation step, which has its own internal try/except.
But the branch reached only when sensitive data WAS detected and the policy
says to redact-and-forward calls egress_receipt.install_hmac_key() and
egress_tokens.tokenize_messages() with no try/except around them at all.
install_hmac_key() is itself documented to raise on exactly this path
("RAISES if no seed can be created (e.g. permission denied on the vault
path). Fail closed -- a receipt with no HMAC is worse than a boot-time
failure a human can fix.") -- so the one branch of the guard that fires when
real PHI/PII was actually caught is also the one branch with no local
safety net, and every caller's blanket except swallows that raise and
proceeds as if no guard existed.

Reproduction steps:

  1. Extract a clean station-v0.74 tarball, sys.path.insert(0, "workbench").
  2. Configure WS/egress_policy.json with a rule that redacts the "email"

category (any policy shape that reaches the "redacted" decision works).

  1. Put the install in a state where a fresh signing seed cannot be minted --

the documented trigger for install_hmac_key() (e.g. the vault path is not
writable on a fresh install; the repro below simulates this cleanly
without touching any application logic).

  1. Call station_llm.guard_egress_messages() directly with a message

containing an email address.

  1. Separately, run the exact try/except pattern each real caller uses

(studio_server.py's guarded_chat() / compose_raw(), or
routes/handlers_meta.py's _h_build()) around the same call, then inspect
what gets sent to the (stubbed) provider.

Expected: either a clean "denied" decision (fail closed, matching the
function's own documented contract), or a successfully redacted/tokenized
message reaches the provider.

Actual: guard_egress_messages() raises an uncaught RuntimeError straight out
of its "redacted" branch. Every real caller's except Exception swallows it
and proceeds to send the message it already had -- the original, completely
unredacted text, including the email address the policy was configured to
protect -- to the real provider. The whole v0.74 PHI/PII governance feature
is silently bypassed exactly when it has real PHI/PII to protect and the
signing/vault layer hiccups.

Root cause: workbench/station_llm.py, guard_egress_messages() (the
"redacted" branch calls egress_receipt.install_hmac_key() and
egress_tokens.tokenize_messages() with no enclosing try/except, so an
exception there is not converted to a "denied" decision the way the
policy-evaluation step above it already is); and workbench/studio_server.py
guarded_chat() / compose_raw(), plus workbench/routes/handlers_meta.py
_h_build()'s _guarded_raw() -- all three wrap the call to
guard_egress_messages() in a bare except Exception that falls through to the
unguarded provider call with the original messages.

Suggested fix: wrap the "redacted" branch's HMAC-key + tokenize calls in
guard_egress_messages() in the same fail-closed pattern already used for
policy evaluation (catch, return a "denied" decision). Independently, none of
the three callers should treat "the guard raised" as equivalent to "no PHI/
PII was present" -- an exception from the guard should deny the call, not
silently forward the un-redacted original.

5 pts

1 reply

Re-verified against station-v0.83. Thank you for the fix -- the classifier-error
branch now denies when an egress policy is configured, which closes one of the
two paths. The other one, which was the mechanism in the original report, is
unchanged and still fails open.

guard_egress_messages()'s "redacted" branch still calls
egress_receipt.install_hmac_key() with no enclosing try/except:

if decision == "redacted":
hmac_key = egress_receipt.install_hmac_key()

That function is documented to RAISE when no signing seed can be minted ("Fail
closed -- a receipt with no HMAC is worse than a boot-time failure a human can
fix"), which is correct at that call. But the raise propagates out of the guard
entirely, and all three callers still wrap the guard call in a bare
except Exception and then proceed with the ORIGINAL messages:

try:
_dec, messages, _egress_map, _why = _sllm.guard_egress_messages(messages, "groq")
...
except Exception:
_egress_map = None
out = groq_chat(messages) # the unredacted originals

So the fail-open still triggers at exactly the worst moment: PII was detected
AND the policy said redact. A signing-seed failure at that point sends the raw
content to the provider instead of refusing.

Reproduced on station-v0.83:

  1. Write an egress policy with {"if": {"has_category": "email"}, "then":

"redacted"}.

  1. Induce a legitimate seed failure -- make the 0600 seed vault path unwritable

(a directory occupying it), which is what ensure_keypair() hits on a first
boot with a bad workspace owner/permissions. Confirm _load_seed() returns
None.

  1. Call guard_egress_messages() with a message containing an email address.
  2. Replicate the caller's try/except and observe what would be sent.

Expected: the call is refused, or the guard's own fail-closed contract is
preserved end to end.

Actual: the guard raises RuntimeError out of the redacted branch, the caller
swallows it, and the message reaches the provider with the address intact.

Suggested fix (either side closes it): wrap the install_hmac_key() call inside
guard_egress_messages() and return a ("denied", ...) tuple on failure, so the
guard never raises into a caller; and/or change the three callers' bare
except Exception to refuse rather than fall through to the unguarded call,
since a guard that could not run is not the same as a guard that allowed.

ShwetaShweta#114d ago
Sign in to reply.