This codebase's own policy-routing file states: "a policy change is one
of the highest-consequence writes in Studio, so /api/policy/approve
dual-controls (session + approve code)." That is true for
/api/policy/approve. It is not true for /api/policy/apply-scenario,
which reaches the exact same commit function and requires only a normal
session token.
Both routes ultimately call primitives.approval_policy.approve_change()
(via policy_endpoints.handle_approve()) -- the function that bumps the
policy version, signs it, and writes it to disk as the new live policy,
with no gate of its own; the dual-control check exists only at the HTTP
route level:
_handle_policy_approve() (the "real" approve endpoint):
if not handler._require_session(): return
if not handler._require_approve(): # dual-control
return
return handler._send(200, policy_endpoints.handle_approve(WS, body))
_handle_policy_apply_scenario() (apply a named policy template):
if not handler._require_session(): return
...
staged = policy_endpoints.handle_stage(WS, {"rules": new_rules})
approved = policy_endpoints.handle_approve(WS, {"staging_id": ...})
# no _require_approve() call anywhere in this function
Reproduction steps:
- Extract a clean station-v0.80 tarball, sys.path.insert(0, "workbench").
- Build a handler stub that implements _require_session() (returning
True) but has NO _require_approve() method at all -- if the code path
ever tried to call it, this raises AttributeError instead of silently
succeeding, so a clean run to completion is itself the proof no
dual-control check happens.
- Call routes.dispatch_policy._handle_policy_apply_scenario(
{"scenario_id": "solo_developer"}, handler) -- any of the built-in
scenario ids reproduces this.
- Read the resulting WS/approval_policy.json.
Expected: applying a scenario is refused, or itself requires the same
dual-control second credential /api/policy/approve requires, since it
performs the identical commit.
Actual: the handler stub runs to completion with ok: true. The station's
live approval_policy.json is replaced and committed -- version bumped,
newly signed, written to disk as the governing policy for every future
action on the station -- with only a session token and no approve
credential anywhere in the call path.
Root cause: workbench/routes/dispatch_policy.py_handle_policy_apply_scenario() callspolicy_endpoints.handle_approve() directly to finalize the scenario
swap, without the handler._require_approve() check its sibling_handle_policy_approve() performs before calling the identical
function. primitives/approval_policy.py approve_change() (whathandle_approve() wraps) has no internal caller-identity check of its
own -- the dual-control guarantee exists solely at the route layer, and
only one of the two routes that can trigger it has it.
Suggested fix: require handler._require_approve() in_handle_policy_apply_scenario() before callingpolicy_endpoints.handle_approve(), exactly as _handle_policy_approve()
already does -- or route apply-scenario through a stage-only step and
require the operator to hit the existing /api/policy/approve endpoint
to finalize it, so there is exactly one code path that can ever commit a
policy change.