AuthzGate can return AuthzDecision::RequireApproval, and authz_verdict folds it to GateVerdict::RequireApproval correctly — but GatedToolSet::execute (PR #3281) turns that verdict into a refusal:
`{name}` needs a human's approval ({reason}), and this session has no approval route attached — it cannot be asked from here
That matches the documented headless posture and is safe, but it means a gate cannot actually ask anyone anything. The ask half of the vocabulary is inert.
The route already exists
#2676 shipped ApprovalBroker with emit-before-park, a bounded TTL wait, and approval.granted / denied / expired audit events. stella-core's HookApprovalRoute port is how the shell-hook surface reaches it without stella-core depending on stella-tools, and stella_tools::hook_bridge::BrokerApprovalRoute is the production implementation. A gate should reach the same broker through the same port rather than growing a second approval path.
Definition of done
GatedToolSet takes an optional approval route and resolves RequireApproval through it.
- With no route attached, keep today's refusal wording — an engine that cannot ask must not silently allow, and the message should name the grant path.
- Reuse
HookApprovalRequest / HookApprovalResolution rather than inventing a parallel pair. If the naming is too hook-specific once a second producer exists, rename in the same PR and chase it through — a stale name is a bug in review here.
- Honour
stella-tty's human_can_answer(...) derivation for whether there is anybody to ask, rather than re-deriving presence.
Related, and probably worth doing together
#2793 — MCP and custom tools bypass the blocking policy chains and the #2676 approval flow. GatedToolSet sits above both layers by construction, so it is the natural place to close that bypass for the approval path too. Check whether #3283's wiring already resolves part of it before scoping.
Witness
A gate returning RequireApproval parks and runs the call when a scripted route approves; the same gate and a denying route refuses without executing. Anti-vacuity: with NoAuthz the call runs and no approval is ever requested.
Refs #2716, #2676, #2793, #3281.
AuthzGatecan returnAuthzDecision::RequireApproval, andauthz_verdictfolds it toGateVerdict::RequireApprovalcorrectly — butGatedToolSet::execute(PR #3281) turns that verdict into a refusal:That matches the documented headless posture and is safe, but it means a gate cannot actually ask anyone anything. The ask half of the vocabulary is inert.
The route already exists
#2676 shipped
ApprovalBrokerwith emit-before-park, a bounded TTL wait, andapproval.granted/denied/expiredaudit events.stella-core'sHookApprovalRouteport is how the shell-hook surface reaches it withoutstella-coredepending onstella-tools, andstella_tools::hook_bridge::BrokerApprovalRouteis the production implementation. A gate should reach the same broker through the same port rather than growing a second approval path.Definition of done
GatedToolSettakes an optional approval route and resolvesRequireApprovalthrough it.HookApprovalRequest/HookApprovalResolutionrather than inventing a parallel pair. If the naming is too hook-specific once a second producer exists, rename in the same PR and chase it through — a stale name is a bug in review here.stella-tty'shuman_can_answer(...)derivation for whether there is anybody to ask, rather than re-deriving presence.Related, and probably worth doing together
#2793 — MCP and custom tools bypass the blocking policy chains and the #2676 approval flow.
GatedToolSetsits above both layers by construction, so it is the natural place to close that bypass for the approval path too. Check whether #3283's wiring already resolves part of it before scoping.Witness
A gate returning
RequireApprovalparks and runs the call when a scripted route approves; the same gate and a denying route refuses without executing. Anti-vacuity: withNoAuthzthe call runs and no approval is ever requested.Refs #2716, #2676, #2793, #3281.