Reproduction steps:
- Extract a clean station-v0.71 tarball, sys.path.insert(0, "workbench").
- import routes.team as T; import primitives.team_approval as ta
- Monkeypatch ta.gate to a stub that records every call it receives and
returns ("pending", {...}) -- standing in for a team policy that requires
quorum=1 approval on external-send actions (exactly what Dave #03/v0.71
was built to enforce).
- gate_fn = T.build_workflow_team_gate(ws)
- Call gate_fn(wf, run_id) on two equivalent one-node workflows that both
perform an external POST:
a. {"id": "n1", "type": "effect", "provider": "slack",
"verb": "post_message", "action_id": "slack.post_message",
"args": {...}}
b. {"id": "n1", "type": "http", "request": {"method": "POST",
"url": "https://hooks.example.com/...", "body": {...}}}
Expected: both (a) and (b) are external sends and should be evaluated by the
gate the same way -- either both hit ta.gate() and come back "pending", or
neither does.
Actual:
[effect node] verdict='pending' ta.gate() invoked with=[('slack', 'message_post', 'reversible')]
[http node] verdict='proceed' ta.gate() invoked with=[]
The "http" node returns "proceed" immediately. ta.gate() is never called for
it -- not "no policy matched," but never even asked the question.
Root cause: workbench/routes/team.py, build_workflow_team_gate() ->
_iter_actions() (defined ~line 394, walked from ~line 407). The node-walk is:
if k == "subworkflow" and isinstance(n.get("workflow"), dict):
yield from _iter_actions(n["workflow"])
elif k == "effect":
integ = _R.resolve_node(n)
if integ is not None:
yield (integ.provider, integ.verb, integ.action_class)
There is no branch for k == "http". But "http" is a first-class, fully
governed node type elsewhere in the same release: workflow_engine.py's
plan_workflow() (~line 284) and run_workflow()'s per-node executor (~line
863) both treat "http" nodes as live effects that can be action_class ==
"external_send" (workflow_http.py:plan_http(), ~line 126: "external_send" if
method in _MUTATING else "read_only"), add to the irreversible/egress blast
radius at plan time, and -- when live_http is true -- actually open a socket
via workflow_http.apply_http_plan() (workflow_http.py:154-184). Nothing else
in the request path gates "http" nodes on team approval; grepping
workflow_http.py, workflow_engine.py, dispatch_workflow.py, and
workflow_mcp.py for team_approval/team_gate turns up only the single
enumerator in team.py, and that enumerator silently skips this node type.
This is live-reachable today via the MCP execution path: workflow_mcp.py
wires live_http=(want_live and allow_live) (~line 331) into run_workflow,
so a workflow containing an "http" node, run with want_live and allow_live
both true, fires a real external HTTP request while the team's
approval_policy.json (providers/action_classes quorum requirement) is never
consulted -- the exact governance gap the v0.71 patch was written to close,
reopened for a different node type. (The HTTP dag/run route,
dispatch_workflow.py, currently hardcodes live_http's default of False for
this call, so "http" nodes can't yet go live through that specific endpoint
-- but the gate is still skipped there too, so that's one hardcoded value
away from the same live exposure.)
Suggested fix: give _iter_actions an "http" branch that mirrors what
plan_workflow() already computes, e.g.:
elif k == "http":
try:
from workbench import workflow_http as _WH
except ImportError:
import workflow_http as _WH
req = n.get("request") or {}
plan = _WH.plan_http(req, n.get("policy"),
allow_private=n.get("allow_private", False))
yield ("http:" + plan["host"], req.get("method", "GET"),
plan["action_class"])
and add a corresponding entry (or a documented default) to
approval_policy.json's action_classes map so "external_send" from an http
node is coverable by the same policy that already covers effect nodes.