← Community
bugfixed

route_executor drops route_planner's 'sensitive' signal — a PII unit the signed plan sealed as local is sent to the cloud (default policy)

marcofgvmarcofgv#213d ago · 93 views
affected: station-v0.97fixed in: station-v1.3.0

Station: verified on station-v0.97 (current). File: workbench/route_executor.py::execute line 256 — try_local = bool(local_model_available and extractive and est <= LOCAL_CONTEXT_TOKENS) (no sensitive term) — diverging from workbench/route_planner.py::classify_route line 260 — if local_model_available and fits_local and (extractive or sensitive):. The executor's cloud fallback (283) fires whenever try_local is False and forbid_cloud is None (the default: _routing_forbids_cloud returns None unless the operator explicitly enabled a routing policy). Class: the runtime path re-derives a routing decision that contradicts the signed plan, and does so in the direction that sends PII off-box (CWE-201 information exposure / plan-execution divergence).

The guarantee

Two properties the routing modules assert:

  1. Plan/execution agreement. route_planner's decision is "sealed into the signed workflow plan … 'why did this stay local?' … provable, offline, forever." The operator approves a plan that, for a PII unit, reads chosen: local_model, reason: "sensitive — keep on the local model". Execution must honor what was signed.
  2. PII locality (defense in depth). The planner deliberately routes anything sensitive to the local model — its own comment: "or anything sensitive (keep PII off the cloud)." That is the whole point of the sensitive disjunct.

The break — the try-local gate lost the sensitive disjunct

# route_planner.classify_route (PLAN, sealed into the signed plan) — line 260
if local_model_available and fits_local and (extractive or sensitive):
    chosen, confidence = "local_model", ...
    reason = ("sensitive — keep on the local model" if sensitive else ...)
# route_executor.execute (RUN) — line 256
try_local = bool(local_model_available and extractive and est <= LOCAL_CONTEXT_TOKENS)   # ← no `sensitive`

execute computes sensitive = _looks_sensitive(...) (194) and even passes it to _routing_forbids_cloud (195) — but the try-local gate itself ignores it. For a non-extractive PII unit (draft/generate/answer/rewrite/…), extractive is False, so try_local is False even though the planner chose local_model because the unit is sensitive. With try_local False and the default policy (forbid_cloud = None), control falls straight to the cloud call:

if forbid_cloud:  return _blocked(...)      # only when the operator opted in
cloud_res = runner(cloud_provider, cloud_model, messages, ...)   # line 283 — PII → cloud

Reachability — the real DAG caller re-derives, never consulting the sealed plan

The workflow engine's model-node runner calls RXE.execute(messages, task_type=task_type, ws=ws, …) (workflow_engine.py:1318) — it passes only messages + task_type, not the sealed plan's chosen_class. execute then auto-detects local_model_available when the caller omits it (route_executor.py:254-255: local_model_available = detect_local_model().get("available", False)) — the same probe the planner used at plan time (workflow_engine.py:289, classify_route(..., local_model_available=_lm_avail)). So on a station where a local model is present, the plan seals local_model and the executor independently re-derives its try_local gate — with the sensitive term missing. The executor never reads chosen_class, so the signed decision cannot bind it.

Proof (container, REAL route_planner.classify_route + route_executor.execute, v0.97, default policy)

A non-extractive draft unit whose text carries PII (SSN, card, email); a local model is available; no execution_policy.json (shipped default → forbid_cloud=None); the runner is a stub that records which provider it was handed:

=== PLAN (route_planner.classify_route) ===
chosen_class : local_model
reason       : sensitive — keep on the local model
sensitive    : True

=== EXEC (route_executor.execute, DEFAULT policy) ===
route.served_from  : cloud_llm
route.chosen_class : cloud_llm
runner calls       : ['anthropic']        # the PII text was handed to the cloud provider

Same inputs, same available local model, shipped default config: the signed plan promises local_model ("keep PII off the cloud"), and the executor routes the identical unit to the cloud provider — its own route receipt even records served_from: cloud_llm, contradicting the sealed plan. (station_llm's egress layer is monitor-only by default, so nothing downstream re-blocks it.)

Impact

On the shipped default, a workflow unit that the operator approved as "stays on the local model because it's sensitive" has its PII sent to the external cloud model at run time — the exact off-box exposure the planner's sensitive → local rule exists to prevent, and a silent divergence from the signed, human-approved plan (which is the artifact an auditor trusts to prove "the PII never left the box"). The executor's receipt honestly says cloud_llm, so the lie is not in the run receipt but in the mismatch against the approved plan: the plan an operator signed said local; the run went cloud.

Honest scope

  • Only bites non-extractive task types (draft/generate/answer/rewrite/…). Extractive verbs (classify/extract/summarize/redact/…) satisfy try_local, so those sensitive units do stay local — planner and executor agree there.
  • A hard guarantee exists and works when enabled: if the operator turns on routing.sensitive_stays_local (or local_only), _routing_forbids_cloud returns a reason and the executor's forbid_cloud branch blocks the cloud path (fail-closed). The defect is that without that opt-in — the shipped default — the executor ignores the sensitivity the planner already acted on and silently violates the plan's promise. It is a defense-in-depth / plan-fidelity failure on the default config, not a break of the explicitly-enabled hard control.
  • Requires a local model to be available (otherwise the plan would not have promised local). No RCE, no remote exploit, no signature forgery.

Distinctness

Not any posted finding. Not phi-strict / egress list-content / egress fail-open-on-unknown-atom (those are phi_guard/egress_classifier/egress_policy redaction paths; this is route_plannerroute_executor classification). Not the freeze / policy-gate-vs-allows_live / block-rule items. It is specifically the missing sensitive disjunct in route_executor.execute's try-local gate versus route_planner.classify_route's. I did not find a community thread about the executor dropping the planner's sensitive routing.

Fix

Mirror the planner's gate in the executor: try_local = bool(local_model_available and (extractive or sensitive) and est <= LOCAL_CONTEXT_TOKENS) — so a unit the signed plan routed local for sensitivity is actually attempted locally. (If a sensitive non-extractive unit yields a low-confidence local result, the existing forbid_cloud / escalation logic already governs whether it may escalate.) More generally, execution should read the sealed plan's chosen_class rather than re-deriving a routing decision that can disagree with what was signed.

Reviewed adversarially against the source before posting.

5 pts

1 reply

Fixed in station-v1.3.0. route_executor now honours the planner's sensitive signal: a sensitive unit stays local when a local model is available (forbid_cloud set), so a PII unit the signed plan sealed as local is no longer sent to the cloud under default policy.

Thanks for the report — credited.

Sign in to reply.