Action Approvals
Some actions are too consequential to let an agent run unsupervised — a production refund, a DROP TABLE, a deploy. Action approvals put a human in the loop: Vultrino pauses before the action executes, and the agent never sees a result until someone signs off. The decision can be made in the admin panel, from a Telegram button, or via a link delivered by webhook/email.
What triggers an approval
An action is gated if any of these match:
- The credential is flagged:
vultrino meta set <alias> require_approval true - The request is authorized by a use token created with
--require-approval - A policy rule matches with
action = "prompt"
What the agent experiences
The flow is designed so the agent clearly understands it is waiting, not failing, and knows how to check back:
- The agent calls a tool. Instead of a result it receives an "APPROVAL REQUIRED" message containing an
approval_id. The action has not run. - The agent polls with that id — the
check_approvalMCP tool,GET /api/v1/approvals/{id}, orvultrino approval status <id> --wait. - A human approves or denies it.
- On the next poll after approval, Vultrino runs the action and returns the real result. If denied or expired, the agent is told to stop.
Execution happens lazily on that poll, so no background worker is required and the result is delivered the moment the agent next checks.
Action detail — what the approver sees (approval_preview)
A human should approve on the substance of an action, not just its verb: who the money goes to and why, or the actual message and its recipient — not merely "send a payout" or "send a message". Each capability declares which of its call params are worth showing, with an approval_preview spec. Vultrino extracts those field values at approval-open (from the exact params that will execute) and surfaces them on the approval; the operator console and the feir-os Approvals inbox render them under "Requested action details."
Declare it per capability (in the capability upsert, or in feir-os deploy/connectors/capabilities.yaml, which govder carries here verbatim):
approval_preview:
title: Payout # heading for the detail block
fields:
- { label: "To", path: body.to } # inline value (default)
- { label: "Amount", path: body.amount }
- { label: "Memo", path: body.memo, format: text } # wrapped block (a body)
pathis a dot-path into the tool's call params —body.toreadsparams["body"]["to"]. Only scalar leaves (string / number / bool) are shown; objects, arrays, and missing keys are skipped, and arrays are never indexed into.formatistextfor a wrapped block (a message body, a reason) or omitted /inlinefor a one-line value. Fields render in the order listed.- Omit the block entirely and the approval falls back to its one-line summary — no change from today.
What is and isn't exposed. The preview carries only the declared field values — never the raw params blob, and never the credential (the machine GET /api/v1/approvals JSON deliberately withholds params; only the operator HTML console dumps the full blob). Field values are agent-authored and untrusted — an agent controls what ends up in a message body — so every consumer escapes them and keeps them visually distinct from the trusted risk / spend / identity facts. Only name paths to fields that are safe to show a human; never point a path at a secret, token, or api_key. When in doubt, show less — the operator can always open the full record.
No code changes are needed to give a new action type its detail view: declaring the fields is the whole job. See PreviewFieldSpec / extract_preview in src/capability/mod.rs.
Configuration
Enable approvals and configure out-of-band notifiers under [approvals] in config.toml:
[approvals]
enabled = true
ttl_secs = 3600 # default Medium-class total window
public_base_url = "https://vultrino.example.com" # base for approve/deny links
oob_approver_identity = "[email protected]" # REQUIRED with a notifier (V5): identity OOB links are bound to
reauth_interval_secs = 900 # optional continuous re-auth (V5)
enforce_separation_of_duty = false # hard-reject self-approvals (V5)
dual_control_approvers = 2 # distinct approvers for dual control (V12)
[approvals.telegram] # inline Approve / Deny buttons
bot_token = "123456:ABC-DEF..."
chat_id = "987654321"
[approvals.webhook] # POST to any URL (email / Slack / ...)
url = "https://hooks.example.com/vultrino-approvals"
auth_header = "Bearer your-webhook-secret"
# Per-criticality SLA windows (V5): window 1 = Pending→Escalated, window 2 =
# Escalated→Expired. Omitted classes use built-in defaults.
[[approvals.sla]]
class = "critical"
escalate_after_secs = 300
escalate_window_secs = 300
# Assign a criticality class to a (credential, action). First match wins;
# unmatched actions are "medium".
[[approvals.criticality_rules]]
credential_pattern = "pay-*"
action_pattern = "*"
class = "critical"
If approvals are enabled but no notifier is configured, decisions can still be made from the admin panel; Vultrino logs a warning that out-of-band approval is unavailable.
SLA, escalation, and continuous re-authorization (V5)
Every request is assigned a criticality class (low | medium | high | critical) from the first matching [[approvals.criticality_rules]] rule, defaulting to medium. The class drives a two-phase SLA:
- First window — while undecided, the request is
pending. When the first window elapses it moves toescalatedand the configured notifiers are re-pinged (with a panel link; the original one-time decision token is not re-issued). - Second window — an
escalatedrequest that is still undecided when the final deadline passes auto-expires (a fail-closed deny). A high/critical request therefore escalates fast and then denies, rather than lingering open indefinitely.
Higher criticality uses shorter windows (built-in defaults: critical 5m+5m, high 15m+15m, low 4h+4h; medium splits the legacy ttl_secs across both phases). Override any class with [[approvals.sla]]. Lifecycle advancement happens both on each agent poll and via a background sweep, so a request nobody is polling still escalates and expires on time. From the agent's side escalated behaves exactly like pending — keep polling.
The credential can shorten the window, and it wins. Whatever the class SLA says, an approval's final deadline is clamped to the remaining life of the use token that will execute the action — an approval must never be offerable past the point where the credential can still honour it. Both phases scale proportionally, so a clamped request still escalates before it expires. If a request arrives with a credential that has under a second left, the approval is refused rather than opened (nothing runs, and nobody is asked to authorize an impossible action). So: to give approvers more time, lengthen the credential, not ttl_secs.
Set reauth_interval_secs to require continuous re-authorization: an approved grant that has not yet run within that window is treated as lapsed and must be re-approved before it can execute, rather than running on a stale decision.
Approver identity and separation of duty (V5)
Every human decision records an authenticated approver identity, not just the channel:
- Admin panel — the logged-in session user.
- Out-of-band link — the named
oob_approver_identitythe link is bound to (rather than an anonymous capability token). This is required when a notifier is configured (enforced at config load); an OOB verdict can never be recorded as the anonymous literalout-of-band— a link with no named identity bound is refused and the action must be decided in the admin panel. - CLI — the local OS user (
cli:<user>).
A decision with a blank identity is rejected. Because both the requester's owner and the approver are recorded, separation of duty ("the approver must not be the requesting agent") is computed and recorded on every decision (and logged when violated) — an agent self-approving its own request is flagged. Set enforce_separation_of_duty = true to hard-reject a self-approval rather than only recording it (a self-denial is always allowed). The CLI decides as a trusted local admin, so its OS-user identity is advisory.
Dual control (M-of-N) (V12)
A high-risk action can require more than one distinct approver before it runs. A use token minted with strictness: direct (or any token flagged dual_control) opens an approval that needs [approvals] dual_control_approvers distinct sign-offs (default 2) — the action does not execute until the threshold is met:
- Each approval records a distinct approver sign-off; the same identity can't satisfy two of the required slots (rejected as a duplicate).
- The request stays
pending(the poll response carriesapprovals_received/approvals_remaining) until enough distinct approvers sign off, then flips toapprovedand runs on the next poll. - A single denial vetoes the whole request regardless of how many approvals were gathered.
- Separation of duty composes: with
enforce_separation_of_duty, a self-approval by the requester is rejected and does not count toward the M-of-N threshold.
[approvals]
dual_control_approvers = 2 # distinct approvers a dual-control request needs (default 2)
Metrics read-back (V12)
GET /api/v1/metrics (admin) returns a structured point-in-time read-back: unauthorized_attempts (tool-call attempts denied by the policy engine or by cross-tenant isolation — counted whether the denial was enforced or, for an observe-mode tenant, merely observed; a per-process in-memory counter, like the rate-limit counters, that resets on restart and counts only this process — not partitioned by tenant), approval counts by state (approvals.by_status, plus dual_control_awaiting), and approval-decision latency percentiles (approval_latency_secs.{count,avg,p50,p95,max}). The approval counts are scoped to the calling key's tenant (approval partitioning) and the response echoes the tenant_scope applied (null for a global admin). The durable, cross-process event history is the signed event outbox.
Out-of-band decision links
Telegram/webhook/email links carry a single-use capability token and open a confirmation page rather than deciding on load — so a link prefetch or scanner can't silently approve an action, and the admin session is never required to act on a notification.
Set
public_base_urlto an HTTPS address so these links stay confidential, and avoid running the web server atDEBUGlog level in production: request URIs (which carry the link's capability token) are logged atDEBUG.
Guarantees
- At most once. An approved action's execution is claimed atomically and fenced by a monotonic execution epoch, so two racing polls can't both run it. A claim left behind by a process that crashed mid-execution is recovered after a timeout fail-closed: because the crashed attempt's side effect may already have fired, the action is not re-run — the approval is finalized terminally with
outcome unknown — original worker lost mid-execution; re-approve to retry, so retrying is an explicit human decision rather than a silent double-fire. A transient pre-execution failure (e.g. a plugin not yet loaded), where nothing ran, is still retried rather than marked done. - Ownership. An agent may only poll approvals created by the same principal (API key or use token) that made the original request — checked before any execution.
- Bounded pending approvals. A use token's
uses + outstanding pending approvalscan never exceedmax_uses, enforced atomically under the vault lock — so a single-use token can't flood the approval queue (or the notifier) with requests it could never run. - Policy still applies at run time. Policy is re-evaluated when the action finally executes, so an explicit deny rule (URL / method / time-window) blocks even a human-approved action — a human approval is not a policy bypass. Rate limits are charged once, at request time; the deferred re-check never re-charges or re-denies an approved action against the rate limiter. When a deny does fire on resume, the use token is left unconsumed.
Managing approvals
vultrino approval list # pending and recent decisions
vultrino approval status <id> [--wait] # poll one approval (optionally block)
vultrino approval approve <id>
vultrino approval deny <id>
The Approvals page of the web UI shows pending requests with their requester, credential, action, and parameters, and offers Approve / Deny actions.