← Community
bugopen

The legacy /api/route "compose" path (_compose_byok / route()) never runs the community #22 PHI/PII egress guard — and v1.4.1's new "anthrop

ShwetaShweta#18d ago · 25 views
affected: station-v1.4.1

The legacy /api/route "compose" path (_compose_byok / route()) never runs the community #22 PHI/PII egress guard — and v1.4.1's new "anthropic" BYOK provider is reachable through it the moment an operator saves an Anthropic key (v1.4.1)

Reproduction steps:

  1. workbench/studio_server.py's Workflow Builder compose path

(compose_raw(), used by POST /api/build) explicitly runs the community
#22 guard before every cloud dispatch:
# PHI/PII egress guard (community #22): the Builder compose path
# reached the provider with no redaction. Run the same guard as the
# chat/module paths; a denied egress is treated as 'no safe compose
# path' (returns '').
_map = None
try:
import station_llm as _sllm
_dec, messages, _map, _why = _sllm.guard_egress_messages(
messages, prov, module_id="studio_builder")
if _dec == "denied":
return ""
except Exception:
_map = None
This runs for EVERY cloud provider dispatched from compose_raw(),
including the brand-new "anthropic" dialect this release adds
(routes/llm.py:anthropic_key, studio_server.py:_anthropic_call,
BYOK_PROVIDERS["anthropic"]).

  1. There is a second, older compose path that is STILL live and reachable

from an authenticated browser session: POST /api/route with
{"role": "compose", "payload": {"messages": [...]}, "opt_in": true,
"model": "<provider>"} -> routes/dispatch_router.py:_handle_route (which
DOES require a real session for role=="compose", so this is not an
unauthenticated hole — it is a sibling-path governance gap for an
authenticated operator) -> studio_server.route() -> studio_server.
_compose_byok(payload, provider, key) -> compose_engine.compose_spec(
messages, catalog(), _byok_raw(provider, key)), which sends
[sysmsg] + messages[-12:] straight to the BYOK callable. Nowhere in
this call chain — route(), _compose_byok(), or compose_engine.
compose_spec() — is station_llm.guard_egress_messages() ever called.
_compose_byok()'s only gate on provider is
if provider not in BYOK_PROVIDERS: return error.

  1. v1.4.1 is what added "anthropic" to that SAME shared BYOK_PROVIDERS dict

that both paths read from (previously it held only groq/openai/xai).
The instant an operator adds an Anthropic key in Connect — exactly what
the new Builder-model picker (list_builder_models/
set_builder_model_pref) this release ships is designed to make them
do — that key becomes usable through BOTH the new guarded compose_raw()
path AND the old, never-guarded _compose_byok()/route() path, with no
redaction on the latter.

  1. Run repro_compose_byok_router_egress_guard_missing_v141.py against a

clean v1.4.1 extraction. It configures a real egress_policy.json that
denies any message classified "email" for any provider, saves a fake
Anthropic key, and drives BOTH the real, unmodified compose_raw() and
the real, unmodified route()/_compose_byok() with the SAME message
containing an email address (no urlopen mocking of the guard itself —
only the final HTTP transport is intercepted so no live network call is
made, and to inspect exactly what bytes would have left the machine):
compose_raw() return value: ''
network calls made by the guarded path: 0
---
route(role='compose', opt_in=True, provider='anthropic') meta:
{'path': 'byok', 'role': 'compose', 'key_id': 'anthropic',
'external': True}
network calls made by the legacy path: 1
request went to: https://api.anthropic.com/v1/messages
SECRET_EMAIL present in the raw outbound request body: True
CONFIRMED

Expected:
Under an operator-configured egress_policy.json, the SAME message content
should be classified, evaluated, and (per this policy) denied or
redacted identically no matter which of the two live compose entry points
sent it — the whole premise of the community #22 fix quoted above is that
"the Builder compose path" (any surface that turns operator/user text into
a model call) must not reach a provider with zero redaction.

Actual:
The guarded path (compose_raw / /api/build) correctly denies and makes zero
network calls. The legacy path (route()/_compose_byok() / /api/route with
role="compose") makes zero calls to guard_egress_messages anywhere in its
call chain and sends the raw, unredacted message — containing the same
PHI/PII the policy is configured to block — straight to
https://api.anthropic.com/v1/messages. An operator's own egress policy is
silently ignored on this path, and this release specifically widens the
providers reachable through it by adding "anthropic" to the shared
BYOK_PROVIDERS table both paths read from.

Suggested fix:
Route _compose_byok() through the same guard compose_raw() uses, before
calling compose_engine.compose_spec():
def _compose_byok(payload, provider, key):
if provider not in BYOK_PROVIDERS:
return ({"ok": False, "error": "..."}, {...})
messages = payload.get("messages") or []
try:
import station_llm as _sllm
_dec, messages, _map, _why = _sllm.guard_egress_messages(
messages, provider, module_id="studio_builder_byok")
if _dec == "denied":
return ({"ok": False, "error": "blocked by your data-egress "
f"policy ({_why})"},
{"path": "egress_denied", "role": "compose",
"key_id": provider, "external": False})
except Exception:
_map = None
...
spec = compose_engine.compose_spec(
messages, catalog(), _byok_raw(provider, key))
...
Better still, have BOTH compose_raw() and _compose_byok() call one shared
_guarded_byok_call(provider, key, messages, model=None) helper so a third
compose entry point can never be added later without automatically
inheriting the guard — the exact class of gap this finding demonstrates
(one path hardened, a functionally-identical sibling path missed) is what
keeps recurring across this platform's history (guard_egress_messages
itself, station_llm chat, and now the Builder's two separate compose
entry points).

0 replies

Sign in to reply.