Deferred authorization (governance R4c)
Forge can pause the executor mid-task on a high-risk tool call, notify an external decision-maker (typically a human on Slack / Telegram / Teams), and resume wh
Forge can pause the executor mid-task on a high-risk tool call,
notify an external decision-maker (typically a human on Slack /
Telegram / Teams), and resume when a decision arrives. This closes
the fifth and final PolicyDecision — DEFER — from the R4
governance taxonomy.
Where the other four decisions are terminal (ALLOW proceeds, DENY refuses, MODIFY rewrites, STEP_UP demands re-authentication), DEFER is unique: the executor is paused — not failed — until an operator or approver arrives to make the call.
When to use it
DEFER is the right tool when:
- The action is legitimate under the right circumstances but too
dangerous to automate (
rm -rf /var/prod,aws s3 rm --recursive). - The policy engine can't decide from static rules and needs a human in the loop.
- Auditability requires a named approver on the action.
DEFER is not the right tool for:
- Actions the agent should never take (use DENY / guardrail
deny_commands). - Actions that need rewriting for safety (use MODIFY /
deny_output). - Sessions where the whole flow needs higher-assurance auth (use STEP_UP / R4b).
How it works
-
Config (
forge.yaml) — declare per-tool defer parameters:security: defer: enabled: true tools: cli_execute: to: channel:slack:#oncall timeout: 5m # keep <= 6m for channel-routed approvals (see the window note below) context_template: "Agent wants to run {tool} with args: {args}" default_timeout: 5m -
BeforeToolExechook fires on a matching tool call:- Registers a pending deferral with the
deferengine. - Flips the task's A2A status to
deferredin the store, so parallelGET /tasks/{id}polls see the pause. - Emits
task_deferredaudit event (fields:tool,to,timeout_ms,context). - Blocks the calling goroutine on the deferral's decision channel. The HTTP request handling the current tasks/send stays open through the wait.
- Registers a pending deferral with the
-
Approver decides via
POST /tasks/{id}/decisions:POST /tasks/task-abc/decisions Content-Type: application/json {"decision":"approve","approver":"alice@example.com","note":"ok, one-off"}Endpoint validates the task exists in a deferred state (404 if not) and the decision string is
approveorreject(400 if not). On success the pending deferral is resolved and the blocked executor goroutine wakes. -
Resume behavior:
approve→ task status restored toworking, the tool runs normally, the tasks/send response returns as if nothing paused. Audit event:task_deferred_decision {decision:"approve", approver, note, wait_ms}.reject→ tool call fails withdefer: rejected by <approver>: <note>; task endsfailed. Audit event:task_deferred_decision {decision:"reject", ...}.timeout(no decision withintimeout) → tool call fails withdefer: no decision within <duration> (auto-deny). Audit event:task_deferred_timeout {timeout_ms, wait_ms}.
Context template
The context_template string is rendered with {tool} and {args}
placeholders substituted at hook time. The result is what the
notify adapter shows the approver. Example:
context_template: "Agent {tool} wants to execute: {args}"Rendered for cli_execute with args={"binary":"aws","args":["s3","rm"]}:
Agent cli_execute wants to execute: {"binary":"aws","args":["s3","rm"]}Truncated to 512 runes on the audit event to bound sink pressure.
Wire coverage
-
POST /tasks/{id}/decisions — REST endpoint that accepts
{decision, approver, note}. Returns 200 on resolve, 404 on unknown task, 400 on invalid decision, 409 on race (another decision landed first). -
BeforeToolExecpause — fires from the same three tasks/send paths as R3/R4b (REST, JSON-RPC, SSE). The HTTP client's connection stays open through the wait; adjust reverse-proxy timeouts (~timeout+ a margin) accordingly.Choose your transport for the approval window. Synchronous
tasks/sendrequires the caller to hold one HTTP request open for the entiretimeout. That's fine for short (~seconds) approvals in interactive tools, but any window measured in minutes should usetasks/sendSubscribe(SSE) or the async A2A envelope so the caller can drop and reconnect. If either the client's read-timeout or the server's write-timeout fires before the approver responds, the ctx cancels — the executor cleans up correctly (Handle deregistered, status restored, audit line for the abandoned wait), but the approval is effectively lost and the operator has to re-drive the task. Rule of thumb:min(client_read_timeout, server_write_timeout, reverse_proxy_idle_timeout) > defer.timeout + margin. -
Task-status transitions — the a2a task store flips to
deferredfor the duration of the wait; parallel GETs on/tasks/{id}observe it.
In-process only (today)
The pause mechanism is a goroutine block — the goroutine's stack IS
the persisted state. A Forge process restart mid-defer abandons all
pending deferrals; the caller's HTTP request will fail cleanly. For
deployments needing pause-across-restart, the defer.Engine
interface is the seam a future persistent implementation will
replace.
Audit event shape
{
"event": "task_deferred",
"task_id": "task-abc",
"correlation_id": "req-xyz",
"fields": {
"tool": "cli_execute",
"to": "channel:slack:#oncall",
"timeout_ms": 600000,
"context": "Agent cli_execute wants to execute: {\"binary\":\"aws\"…"
}
}{
"event": "task_deferred_decision",
"task_id": "task-abc",
"fields": {
"tool": "cli_execute",
"decision": "approve",
"approver": "alice@example.com",
"note": "ok, one-off",
"wait_ms": 42371
}
}{
"event": "task_deferred_timeout",
"task_id": "task-abc",
"fields": {
"tool": "cli_execute",
"timeout_ms": 600000,
"wait_ms": 600000
}
}Never carries token bytes or full tool inputs beyond the truncated context template.
Notify integration
Native Slack approvals (#310)
When the agent runs with --with slack and a deferred tool's to is
channel:slack:<channel>, Forge delivers the approval natively: on a
deferral the Slack adapter posts a Block Kit message with Approve /
Reject buttons to that channel. Approve resolves the deferral
immediately; Reject opens a modal that prompts for a reason (recorded
as the decision note). Either way the tool then proceeds or fails and
the message updates with the outcome + approver.
security:
defer:
enabled: true
tools:
atlassian__jira_create_issue:
to: channel:slack:#oncall # channel:<adapter>:<target>
timeout: 5m # <= 6m for channel-routed approvals (see the window note below)
context_template: "Agent wants to run {tool} with args: {args}"The <target> may be a channel id (C0123ABC5) or a name (#oncall
or oncall). A name is resolved to its id via conversations.list and
cached; an id is used directly. Requirements for the target channel:
- The bot must be a member of the channel (invite it) — otherwise
Slack rejects the post with
not_in_channel. - Resolving a name needs the bot's
channels:read(public) +groups:read(private) scopes; posting needschat:write; updating the message after a decision needschat:writeas well. - Name resolution fails closed — if the name can't be resolved (wrong scope, bot not a member, no such channel) the delivery errors (logged, non-fatal) rather than posting to the wrong place. If in doubt, use the channel id directly (right-click the channel → View channel details → the id is at the bottom).
This needs no inbound exposure to Forge: the Slack adapter uses
Socket Mode (outbound WebSocket), so the button click arrives over the
agent's existing outbound connection. Under the hood the click is routed
to the same POST /tasks/{id}/decisions endpoint an operator would curl.
Delivery is best-effort — if Slack is unreachable the deferral still
holds and an approver can POST the decision directly; a delivery failure
never auto-denies.
The to value must be channel:<adapter>:<target>; an adapter that
doesn't implement interactive approvals (channels.ApprovalDeliverer)
can't be a target. Telegram / MS Teams interactive approvals are a
follow-up (same interface).
⚠️ Without an
approversallowlist, the approval authority is channel membership. Any user who can see the message and click a button resolves the deferred call. So unless you setapprovers(below), the target channel's membership IS the approval ACL. Consequences:
- Route approvals to a tightly-scoped private channel, not a broad
#oncallthat includes guests, contractors, bots, or integrations.- A compromised member account grants approval authority over every agent action routed there — treat channel membership as a privileged grant.
- There is no requester≠approver (four-eyes) gate yet.
Reject captures a reason. A button click carries no free text, so clicking Reject opens a modal that prompts for a justification; the typed reason is recorded as the decision
note(and surfaces in thetask_deferred_decisionaudit event and thedefer: rejected by …tool error). If the modal can't be opened (a transientviews.openfailure), Forge falls back to a reason-less reject so the decision is never lost. Approve resolves immediately, with no modal.
If the target adapter isn't running, Forge warns at startup (e.g.
security.defer routes cli_execute to channel:slack:… but slack is
not active — start with --with slack). The deferral still holds; the
approval just won't be delivered until an approver POSTs directly.
Approver allowlist (approvers)
To require that only specific people can approve — rather than anyone in the channel — set a per-tool (or default) email allowlist:
security:
defer:
default_approvers: [sec-lead@corp.com] # applied when a tool omits its own
tools:
cli_execute:
to: channel:slack:#sec-approvals
timeout: 5m
approvers: [alice@corp.com, oncall-lead@corp.com] # only these may resolve- Email, not platform ids — a portable identity that works the same across Slack / Teams / a web console and matches Forge's OIDC model.
- The Slack adapter resolves the clicker's email via
users.info(cached) — this needs the bot'susers:read.emailscope. - Enforced in the shared runtime at
POST /tasks/{id}/decisions(adapter-agnostic — a directcurlmust sendapprover_emailtoo), and fails closed: an approver whose email isn't listed — or can't be resolved (guest without email, missing scope) — is refused with403and the deferral stays pending for a real approver. The refusal is audited (task_deferred_decisionwithauthorized: false). - Empty
approvers(and emptydefault_approvers) → no allowlist; channel membership is the ACL (see the warning above).
🔑 The allowlist authorizes an asserted email — its strength depends on who asserts it.
- Slack path (the one #311 left open): the runtime resolves the real email from
users.info, bound to the authenticated Slack identity. Here the allowlist genuinely restricts which channel members can approve.- Direct
POST /tasks/{id}/decisions: the endpoint trusts theapprover_emailthe caller supplies. Whoever holds the runtime bearer token can assert any listed email — so for direct posts the real gate is the endpoint's authentication, and the allowlist is advisory on top. That's acceptable (the token holder is already trusted), but it means--no-auth+ an allowlist does not hold: with no auth, anyone who can reach the port can assert any email. Never rely on the allowlist without the runner's auth middleware enabled.A malformed approver entry — a bare handle (
alice), a missing@(alicecorp.com), or a no-dot domain (alice@corp) — can never match and silently locks that person out; fail-closed, not insecure. Forge warns at startup for anyapprovers/default_approversentry that isn't email-shaped so the mistake is caught before it's needed. (This is a structural check only — it can't catch a valid-but-wrong address like acorp↔cropdomain typo, which needs a directory to detect.)
Group/role-based approver policies and a requester≠approver (four-eyes) gate are future work.
⏱ Approval window for channel-initiated conversations. A conversation that arrives through a channel adapter (Slack/Telegram → agent) is served synchronously: the channel router holds the request open for
channels.SyncRequestTimeout(6 minutes).The deferred task now survives a caller disconnect (#402). The non-streaming
tasks/sendexecution context is detached from the request (context.WithoutCancel), so when the synchronous caller (including the channel router at the 6-minute mark) gives up, the task is no longer cancelled — it stays alive, parked on the deferral until its owntimeout, and resumes intact on approval. It remains retrievable viaGET /tasks/{id}and explicitly cancellable viatasks/cancel. So a click after 6 minutes no longer hits a dead task.What the 6-minute window still bounds is synchronous result delivery back over that held request: if the approval lands after the channel request has timed out, the task completes but its result isn't pushed back over the original (now-closed) channel connection — you'd read it via
GET /tasks/{id}. Delivering a long-approval result back to the channel asynchronously (and the same for the streaming/SSE path) is the remaining follow-up (this is defer piece 1; see #404 for the SSE path). For approvals you expect to resolve within the window, keepingtimeout≤ 6m still gives the cleanest synchronous UX; Forge warns at startup if a channel target'stimeoutexceeds the sync window. Direct A2A clients that hold the connection (or polltasks/get) aren't bound by this at all.
Custom notify path
For any other target (or without the Slack adapter), wire your own:
- Poll the audit stream for
task_deferred(carriestask_id,to,context), forward to your tool of choice. - Have it POST
{decision, approver, note}to/tasks/{id}/decisions(add an auth token — the endpoint honors the runner's auth middleware).
Combining with other governance controls
- R3 intent alignment (#208) fires first; alignment DENY skips the defer path.
- R4b step-up (#210) — deployments needing "high-assurance for ALL sessions" prefer step-up; DEFER is per-action.
- R5 hash chain (#212) / R6 signing (#213) — the three defer audit events participate in both when enabled.
Combined governance posture: R3 alignment → R4b step-up → R4a MODIFY → R4c DEFER → tool executes. A DENY at any layer short-circuits everything downstream.
Audit event signing
Forge can Ed25519-sign every audit event it emits so an offline verifier can prove which events came from a given Forge instance and that the payloads haven't b
Intent-alignment check (governance R3)
Forge can score every tool call against the user's stated intent using cosine similarity between embeddings and refuse the call when the score falls below a con