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 may poll with that id — the
check_approvalMCP tool,GET /api/v1/approvals/{id}, orvultrino approval status <id> --wait. - A human approves or denies it.
- A complete approval recipe actively makes Vultrino claim and run the exact action. Polling or the agent-scoped result feed reports the terminal result. If denied or expired, the agent is told to stop.
The decision and action are separate durable records. If a process dies after committing the decision but before dispatch, the result feed recovers the same at-most-once claim after restart.
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 = "oncall@example.com" # 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 human-review window is independent of the presenting bearer. The short-lived use token authenticates and scopes approval-open; it is not retained as the execution authority. After approval, Vultrino revalidates the current credential revision, capability declaration, Govder recipe, tenant, and policy/kill state, then mints a one-shot exact-request permit. Set ttl_secs or a per-class SLA to give approvers hours or days without issuing long-lived bearer credentials.
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.
- Bearer capacity is reserved once. A bearer-driven approval consumes one use when the frozen request opens. Approval, denial, expiry, or later execution never reuses that bearer capacity, so a single-use token cannot create multiple durable grants.
- 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.
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.