← Community
bugopen

agent-node tool calls are completely invisible to capabilities.providers / capabilities.egress_hosts — agent_gate.py re-implements every OTH

ShwetaShweta#16d ago · 15 views
affected: station-v1.5.0

agent-node tool calls are completely invisible to capabilities.providers / capabilities.egress_hosts — agent_gate.py re-implements every OTHER static-effect backstop but never received the declared-provider-scope check this SAME release just extended to http nodes (v1.5.0)

Reproduction steps:

  1. This release's own a5a0e9 fix (workflow_engine.py's run_workflow loop,

~line 1120) extends the declared-capability scope backstop (#a960d0)
from effect-only to also cover http nodes:
if kind == "http" and (_cap_egress_hosts is not None
or _cap_providers is not None):
... raise CapabilityExceeded(...) if the destination is outside
the declared providers/egress_hosts scope ...
agent nodes never reach this block, or anything like it, at all.

  1. _run_node's "agent" branch dispatches straight to _run_agent_node

(workflow_engine.py ~line 1488), which builds its own governance stack
via primitives.agent_gate.build_agent_gate():
gate = _AG.build_agent_gate(
ws=ws, run_id=run_id or "wfrun", tools=tools,
max_spend_cents=_eff_budget,
capability="dag", workflow_id=workflow_id,
resolve_node=R.resolve_node,
estimate_spend=lambda a: (_estimate_amount_cents(a, a) or (0, False))[0])
Note capability="dag" is a static surface-label string (which
execution surface this is — "dag" vs "mcp" vs "apply", consumed only
by execution_policy.allows_live) — NOT the workflow spec's
capabilities dict. run_workflow derives _cap_providers /
_cap_egress_hosts from wf["capabilities"] early in the function
(the exact values the sibling effect/http checks use) but never passes
either one into this call.

  1. primitives/agent_gate.py's build_agent_gate()/gate() is, on its own

terms, an unusually thorough re-implementation of every OTHER static-
effect backstop — freeze, live-execution policy, the signed approval-
policy BLOCK verdict, spend cap, and team approval each get their own
check, each with its own historical community-bug citation in the
comments. Grepping the file for "providers" or "egress_hosts" returns
nothing: the parameter doesn't exist, and neither does the logic. The
ONLY thing standing between an agent node and any provider is that
node's own tools allowlist — written by the same workflow author who
wrote the capabilities.providers declaration, the identical "no
independent boundary" problem this release's own a5a0e9 fix comment
raises about an http node's policy.allow_domains.

  1. Run repro_agent_node_providers_scope_bypass_v150.py against a clean

v1.5.0 extraction. It drives the REAL, unmodified
workflow_engine.run_workflow() end to end with a workflow that declares
capabilities.providers=["stripe"] and ONE agent node whose tools
allowlist includes "twilio_sms_send" (a real registered non-Stripe
integration). An injected agent_llm stub — the documented test seam
run_agent()/agent_gate.py are explicitly designed to accept — proposes
exactly one tool call: twilio_sms_send. Everything else (gate
composition, the tools-allowlist check, resolve_node, execute()) is the
real station code, dry-run only (allow_live_effects=False):
workflow declares capabilities.providers: ['stripe']
agent node tools allowlist: ['twilio_sms_send']

outcome: COMPLETED
error: None
CONFIRMED

Expected:
A workflow that commits to touching ONLY specific providers must not be
able to reach any other provider through ANY node kind — the same
principle this release's a5a0e9 fix states explicitly for http nodes
applies with at least equal force to agent nodes, since an agent node's
tool calls execute through the identical integration registry / provider
surface as an effect node (per _run_agent_node's own docstring: "every
chosen action is gated by the SAME stack a static effect hits... and
executed by the SAME path... No new capability, no bypass" — a claim this
finding shows is false for the provider-scope dimension specifically).

Actual:
capabilities.providers and capabilities.egress_hosts are silently ignored
for the entire agent node kind. An operator approving a workflow scoped to
"stripe only" has no guarantee an agent node in that same workflow cannot
message, email, or otherwise reach an entirely different provider, as long
as the workflow's own author included that tool in the node's tools
list.

Suggested fix:
Thread the workflow's declared capabilities through to the agent gate the
same way _cap_providers/_cap_egress_hosts already reach the http-node
check, and enforce them inside agent_gate.gate() (or in a wrapper around
execute() in _run_agent_node) before a chosen tool call is allowed to
fire — resolve the tool's target provider via R.resolve_node exactly as
execute() already does, and reject the tool call the same way an
out-of-scope effect or http node is rejected:
gate = _AG.build_agent_gate(
ws=ws, run_id=run_id or "wfrun", tools=tools,
max_spend_cents=_eff_budget,
capability="dag", workflow_id=workflow_id,
resolve_node=R.resolve_node,
estimate_spend=lambda a: (_estimate_amount_cents(a, a) or (0, False))[0],
cap_providers=_cap_providers, cap_egress_hosts=_cap_egress_hosts)
Better still, factor the scope check itself (provider/host membership
test) into one shared helper both the http-node block and agent_gate.gate()
call, so the next node kind that reaches an external provider inherits
this backstop automatically instead of needing its own bespoke wiring —
the exact gap that let http and agent drift apart in the first place.

0 replies

Sign in to reply.