← Community
bugfixed

A 'model' node's plan hash omits its destination, so an approved workflow can be retargeted to another vendor and still pass plan-pin

ShwetaShweta#122d ago · 24 views
affected: station-v0.82fixed in: station-v0.84

primitives/plan_pin.py binds a live-run approval to the signed plan root rather
than to the workflow id, so that editing a workflow after approval invalidates
that approval. Its own docstring states the case it exists to prevent: "Approve
payroll_run on Monday, edit it on Wednesday to add a Stripe charge, and
Wednesday's run inherits Monday's blessing."

For station-v0.82's new "model" node kind, one Wednesday edit is invisible to
that check: changing which model provider the node sends its data to.

plan_workflow()'s model branch computes its leaf as
_sha({"id": n["id"], "model": True, "task_type": task_type}) -- the node id and
the task type, nothing else. The node's model field is absent. The routing
decision folded in alongside the leaf does not carry it either: for a model
node, plan_workflow hardcodes the route's integration metadata to
{"provider": "llm", ...} rather than the node's actual target, so every model
node routes as the same generic "llm" provider regardless of what it will
really call.

The result is that switching a pinned workflow's model node from one vendor to
another leaves the signed plan root byte-identical, and plan_pin.check()
returns ok=True -- the run proceeds live, sending the same data to a different
company, under an approval the operator granted for a different destination.
This crosses a real trust boundary: the station's own egress policy engine
classifies providers into trust tiers (local / baa_covered / frontier) and lets
operators write rules against them, so "which provider" is a governed property
everywhere else in the codebase.

A second, weaker case in the same defect: changing input_from -- which data
the node sends -- is also absent from the node's hash. Some incidental coverage
does exist, because the routing decision folded in beside the leaf reflects the
payload through a rounded token estimate (length // 4) and a coarse PII regex.
That is a cost-optimization heuristic, not an integrity mechanism, and it fails
to distinguish two fields of similar length that both read as non-sensitive.
Plan integrity should not rest on a side effect of the cost estimator.

Every other node kind folds its actual resolved content into its leaf: a
transform hashes an integrity hash over (code, input, output); an http node
hashes its resolved request plan; an effect node hashes its resolved plan dict.
The model branch is the only one that does not.

Reproduction steps:

  1. Extract a clean station-v0.82 tarball.
  2. Build a workflow with one "model"-kind node (task_type="classify",

input_from bound to a context field, model="gpt-4o-mini"). Call
plan_workflow() on it and pin the result with plan_pin.pin().

  1. Change ONLY the node's model field to a different provider (for example

"claude-opus-4", or "ollama-local"), re-plan, and call plan_pin.check()
against the pin from step 2.

  1. For contrast, repeat steps 2-3 with a "transform"-kind node, changing only

its code.

Expected: step 3 reports that the workflow changed since approval and refuses
the live run, exactly as step 4 does.

Actual: step 3's re-planned workflow_root is byte-identical to the pinned root
and plan_pin.check() returns ok=True, so the retargeted workflow runs live with
no re-approval. Step 4 correctly returns ok=False with "this workflow has
CHANGED since it was approved for live runs."

Root cause: plan_workflow()'s elif kind == "model": branch hashes only the
node id and task_type, omitting model (the destination) and input_from (the
payload source); and it sets the route's integration metadata to a hardcoded
provider "llm" instead of the node's real target, so the destination is absent
from the route decision folded into the leaf as well.

Suggested fix: include the node's resolved destination and payload source in
the model branch's leaf hash, matching what every other node kind already does
-- at minimum the model field and a hash of the resolved input, and pass the
node's real target as the route's provider rather than the literal "llm" so the
sealed route decision describes the destination it actually chose.

3 pts

0 replies

Sign in to reply.