initializdocs
DeveloperForge runtimeSecurity

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 PolicyDecisionDEFER — 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

  1. 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
  2. BeforeToolExec hook fires on a matching tool call:

    • Registers a pending deferral with the defer engine.
    • Flips the task's A2A status to deferred in the store, so parallel GET /tasks/{id} polls see the pause.
    • Emits task_deferred audit 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.
  3. 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 approve or reject (400 if not). On success the pending deferral is resolved and the blocked executor goroutine wakes.

  4. Resume behavior:

    • approve → task status restored to working, 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 with defer: rejected by <approver>: <note>; task ends failed. Audit event: task_deferred_decision {decision:"reject", ...}.
    • timeout (no decision within timeout) → tool call fails with defer: 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).

  • BeforeToolExec pause — 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/send requires the caller to hold one HTTP request open for the entire timeout. That's fine for short (~seconds) approvals in interactive tools, but any window measured in minutes should use tasks/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 deferred for 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 needs chat:write; updating the message after a decision needs chat:write as 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 approvers allowlist, the approval authority is channel membership. Any user who can see the message and click a button resolves the deferred call. So unless you set approvers (below), the target channel's membership IS the approval ACL. Consequences:

  • Route approvals to a tightly-scoped private channel, not a broad #oncall that 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 the task_deferred_decision audit event and the defer: rejected by … tool error). If the modal can't be opened (a transient views.open failure), 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's users:read.email scope.
  • Enforced in the shared runtime at POST /tasks/{id}/decisions (adapter-agnostic — a direct curl must send approver_email too), and fails closed: an approver whose email isn't listed — or can't be resolved (guest without email, missing scope) — is refused with 403 and the deferral stays pending for a real approver. The refusal is audited (task_deferred_decision with authorized: false).
  • Empty approvers (and empty default_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 the approver_email the 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 any approvers/default_approvers entry 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 a corpcrop domain 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/send execution 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 own timeout, and resumes intact on approval. It remains retrievable via GET /tasks/{id} and explicitly cancellable via tasks/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, keeping timeout ≤ 6m still gives the cleanest synchronous UX; Forge warns at startup if a channel target's timeout exceeds the sync window. Direct A2A clients that hold the connection (or poll tasks/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 (carries task_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.

On this page