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:
- Extract a clean station-v0.74 tarball, sys.path.insert(0, "workbench").
- Configure WS/egress_policy.json with a rule that redacts the "email"
category (any policy shape that reaches the "redacted" decision works).
- 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).
- Call station_llm.guard_egress_messages() directly with a message
containing an email address.
- 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.