Policy Configuration

Policies add fine-grained control over how credentials can be used, including URL restrictions, method limits, and rate limiting.

Overview

Policies are evaluated for every credential use:

Request → RBAC Check → Policy Check → Credential Injection → Forward
                            │
                            └─ Deny if policy fails

Default posture for un-policied credentials

A policy's default_action only governs credentials its credential_pattern matches. What happens to a credential that matches no policy is set engine-wide by [enforcement] default_action (see Configuration):

  • deny (default): a credential matched by no policy is denied with a distinct no_policy reason — fail-closed. Grant access by adding a policy whose credential_pattern matches it.
  • allow: a credential matched by no policy is permitted — the legacy fail-open behavior.

Policy Structure

Policies are defined in the configuration file:

[[policies]]
name = "github-readonly"
credential_pattern = "github-*"
default_action = "deny"

[[policies.rules]]
condition = { url_match = "https://api.github.com/*" }
action = "allow"

[[policies.rules]]
condition = { method_match = ["GET", "HEAD"] }
action = "allow"

Configuration Fields

Policy Definition

FieldTypeDescription
namestringUnique policy name
credential_patternstringGlob pattern for credentials this policy applies to
principal_patternstring (optional)Glob over the presenting principal (key/token id or its agent_label). When set, the policy applies only to matching principals (V4).
default_actionstringAction when no rules match: allow, deny or prompt (default deny when omitted)
rulesarrayList of policy rules

Per-agent policies (principal_pattern)

A policy with principal_pattern applies only to requests from a matching principal — the presenting key/token id, or an agent_label bound to the token (via the admin API). This makes a per-agent kill expressible: push a Deny scoped to one agent without affecting other agents sharing the same credential.

[[policies]]
name = "kill-refund-bot"
credential_pattern = "payments-*"
principal_pattern = "refund-bot"   # only this agent
default_action = "deny"

A request that carries no principal never matches a policy that sets principal_pattern.

Rule Definition

FieldTypeDescription
conditionobjectCondition to evaluate
actionstringAction if condition matches: allow, deny or prompt

Conditions

URL Match

Restrict to specific URLs or patterns:

# Exact match (for an allow rule only: on a deny rule, and on a prompt rule in a policy
# whose default is not deny, an exact URL with no query is refused when the policy is
# written, and a stored one stops `vultrino web` from starting. Write the prefix form,
# for example "https://api.github.com/user*")
condition = { url_match = "https://api.github.com/user" }

# Wildcard pattern
condition = { url_match = "https://api.github.com/repos/*" }

# Multiple paths: one url_match per path inside an `or`
condition = { or = [
  { url_match = "https://api.github.com/user/*" },
  { url_match = "https://api.github.com/repos/*" },
  { url_match = "https://api.github.com/gists/*" }
]}

A url_match glob supports *, ? and [...]. It is matched part by part: a wildcard in the scheme or host stays in that part (never a /, ? or the port), so https://*.example.com/x matches https://api.example.com/x but not https://evil.net/a.example.com/x. In the path and query a glob * also matches /. A glob without a port matches only the default port. A pattern that ends in * is a literal prefix instead, so a * earlier in it is a plain character. There is no {a,b} alternation: braces are literal characters, so a pattern such as https://api.github.com/{user,repos}/* matches no request (and logs no warning). Use an or of separate url_match conditions instead.

Vultrino logs a warning at load for a risky pattern: * right after or inside a host (https://api.example.com*, https://api.*), * in the host, a pattern whose meaning canonical matching changes (dot segments, a * that cuts a percent escape), or one that is not a parseable URL or path. When a policy is written through the admin API, a deny or prompt rule with such a pattern is refused (400), because the rule can match less than it reads; write the host and a path instead (https://api.example.com/*), one rule per host. An allow rule with such a pattern is accepted with the warning.

Method Match

Restrict to specific HTTP methods:

# Single method
condition = { method_match = ["GET"] }

# Multiple methods
condition = { method_match = ["GET", "HEAD", "OPTIONS"] }

# All read operations
condition = { method_match = ["GET", "HEAD"] }

# Write operations
condition = { method_match = ["POST", "PUT", "PATCH", "DELETE"] }

Time Window

A time window is not accepted in config.toml [[policies]]: a rule that uses time_window fails to parse, so the config does not load. It can be set only on a policy written through the admin API (POST /api/v1/policies, PUT /api/v1/policies/{id}). It is evaluated against the server's local clock and timezone; a window whose start is later than its end wraps past midnight, and start equal to end is refused.

Rate Limit

Limit request frequency:

# 100 requests per minute
condition = { rate_limit = { max = 100, window_secs = 60 } }

# 1000 requests per hour
condition = { rate_limit = { max = 1000, window_secs = 3600 } }

# 10 requests per second (burst protection)
condition = { rate_limit = { max = 10, window_secs = 1 } }

Put it inside an and with the conditions that scope the access (url_match, method_match, action_match) and use that as the condition of an allow rule. A rule whose only condition is rate_limit allows every request on the credential until the limit is spent. A policy that contains a rate_limit anywhere (including inside and/or/not) must use default_action = "deny", which is enforced at config load and by the admin API. Once the limit is spent the rule stops matching, and the request is decided by the other rules and then the policy defaults, so a non-deny default would turn an over-limit request into an allow. Default deny does not make every over-limit request a denial: another allow rule that matches the request, in this policy or another matching policy, still allows it.

Spend Cap

Cap the value an agent can spend in a single call, in minor units (e.g. cents) (V3). The amount is read from the request body by a spend extractor; a missing/unparseable amount fails closed (deny).

# Refunds: at most $50.00 per call, in USD.
condition = { spend_cap = { asset = "usd", per_action_max = 5000 } }

Use it as the condition of an allow rule (with default_action = "deny"): the call is allowed only while within the per-call cap. A SpendCap must be a rule's top-level condition (not nested in and/or/not), and its policy must be fail-closed (default_action = "deny") — both are enforced at load.

Per-action only — the spend check is stateless. Vultrino does not keep a cumulative/windowed ledger: a single call is the unit it bounds. Cumulative or budget enforcement is the book-of-record's plane (e.g. a spend ledger service) — it arrives as a per-agent/credential Deny policy pushed through the admin write API when a budget is exhausted, not as engine state inside vultrino. This keeps the proxy's decision stateless and the same across the web and MCP processes.

Notes:

  • Approval-gated spends are re-checked when the approval finally executes via the read-only resume path, which treats the already-checked per-action amount as admitted (it does not re-deny on resume).
  • Multiple assets: put per-asset caps as multiple rules in a single policy (first match wins). Two separate per-asset policies would deny each other's asset, because a non-matching asset falls through to that policy's deny default.

Combined Conditions

Use and and or for complex logic:

# URL AND method match
condition = { and = [
  { url_match = "https://api.github.com/repos/*" },
  { method_match = ["GET"] }
]}

# Allow GET to anything OR POST to specific endpoint
condition = { or = [
  { method_match = ["GET"] },
  { and = [
    { method_match = ["POST"] },
    { url_match = "https://api.github.com/repos/*/issues" }
  ]}
]}

Complete Examples

Read-Only API Access

[[policies]]
name = "github-readonly"
credential_pattern = "github-readonly-*"
default_action = "deny"

[[policies.rules]]
condition = { url_match = "https://api.github.com/*" }
action = "allow"

[[policies.rules]]
condition = { method_match = ["POST", "PUT", "PATCH", "DELETE"] }
action = "deny"

Rate-Limited Production Access

[[policies]]
name = "stripe-production"
credential_pattern = "stripe-live-*"
default_action = "deny"

# Allow only the Stripe API, at most 100 requests per minute. The rate limit
# sits inside the same rule: a separate rule whose only condition is rate_limit
# would allow any URL until the limit is spent.
[[policies.rules]]
condition = { and = [
  { url_match = "https://api.stripe.com/*" },
  { rate_limit = { max = 100, window_secs = 60 } }
]}
action = "allow"

Business Hours Only

This pattern cannot be written in config.toml: time_window is accepted only on a policy written through the admin API (see Time Window). Push a policy with default_action deny and an allow rule whose condition is a time_window through POST /api/v1/policies.

Multi-Service Policy

[[policies]]
name = "payment-processing"
credential_pattern = "payment-*"
default_action = "deny"

# Allow Stripe
[[policies.rules]]
condition = { url_match = "https://api.stripe.com/*" }
action = "allow"

# Allow PayPal
[[policies.rules]]
condition = { url_match = "https://api.paypal.com/*" }
action = "allow"

# Allow Braintree
[[policies.rules]]
condition = { url_match = "https://api.braintreegateway.com/*" }
action = "allow"

# Block everything else by default

AI Agent Restrictions

[[policies]]
name = "ai-agent-safety"
credential_pattern = "ai-*"
default_action = "deny"

# Only read operations, at most 60 per minute
[[policies.rules]]
condition = { and = [
  { method_match = ["GET", "HEAD"] },
  { rate_limit = { max = 60, window_secs = 60 } }
]}
action = "allow"

# Allow POST only to specific safe endpoints
[[policies.rules]]
condition = { and = [
  { method_match = ["POST"] },
  { or = [
    { url_match = "https://api.github.com/repos/*/issues" },
    { url_match = "https://api.github.com/repos/*/comments" }
  ]}
]}
action = "allow"

# Block dangerous operations
[[policies.rules]]
condition = { url_match = "https://api.github.com/repos/*/delete" }
action = "deny"

Policy Evaluation Order

  1. RBAC check: Does the API key have permission?
  2. Credential scope: Is the credential in scope for this role?
  3. Policy match: Find policies matching the credential alias (and principal)
  4. Rule evaluation: A matching policy with the kill flag denies before any rule is looked at. Otherwise the deny rules of every matching policy are checked first, then the prompt rules, then the allow rules, and the first rule that matches in that order decides.
  5. Default action: If no rule matched, a deny default of any matching policy wins over prompt, and prompt over allow. If no policy matches the credential, the engine default ([enforcement] default_action) applies.

The order in which rules and policies are written does not change whether a request is allowed, prompted or denied: a deny rule wins over an allow rule wherever it is written. Within one tier the order only picks which rule is named in the denial reason, or which rate_limit is charged (which can change the decision for later requests).

Debugging Policies

Verbose Logging

The policy engine does not log a per-rule evaluation trace, even at RUST_LOG=vultrino=debug. A denial returns its reason to the caller. In enforce mode a policy denial emits a policy.denied event to the event outbox, coalesced to at most one event per subject (agent label, else principal id, else credential) every 10 seconds per process, so the feed is a detection signal, not a count of denied calls. The per-process count of denied attempts is unauthorized_attempts in GET /api/v1/metrics.

Audit Log

There is no audit log file. In enforce mode a policy denial emits a policy.denied event to the event outbox, coalesced to at most one event per subject (agent label, else principal id, else credential) every 10 seconds per process, so the feed is a detection signal, not a count of denied calls. The per-process count of denied attempts is unauthorized_attempts in GET /api/v1/metrics. Read the events with GET /api/v1/events?after=<cursor> (global admin key).

Common Patterns

Deny by Default

Start restrictive, add specific allows:

[[policies]]
name = "strict-access"
credential_pattern = "*"
default_action = "deny"

[[policies.rules]]
condition = { url_match = "https://api.company.com/*" }
action = "allow"

Allow by Default with Blocklist

Allow most things, block specific patterns:

[[policies]]
name = "open-access"
credential_pattern = "dev-*"
default_action = "allow"

# Block production endpoints
[[policies.rules]]
condition = { url_match = "https://api.company.com/admin/*" }
action = "deny"

[[policies.rules]]
condition = { url_match = "https://api.company.com/billing/*" }
action = "deny"

Environment Separation

Different policies per environment:

# Production: strict
[[policies]]
name = "production"
credential_pattern = "*-prod"
default_action = "deny"

[[policies.rules]]
condition = { url_match = "https://api.production.com/*" }
action = "allow"

# Development: permissive
[[policies]]
name = "development"
credential_pattern = "*-dev"
default_action = "allow"

Best Practices

1. Start Restrictive

Default to deny and add specific allows:

default_action = "deny"

2. Use Specific URL Patterns

# Bad: too broad
condition = { url_match = "*" }

# Good: specific
condition = { url_match = "https://api.github.com/repos/myorg/*" }

3. Combine with RBAC

Policies complement RBAC, not replace it:

  • RBAC: Who can access which credentials
  • Policies: How credentials can be used

4. Document Policies

Use clear names and comments:

[[policies]]
# SECURITY: Prevents AI agents from deleting repositories
name = "ai-no-destructive"
credential_pattern = "ai-*"

5. Verify Before Deploying

Confirm a policy behaves as intended before relying on it in production: exercise the credential, then check the denial reasons returned to the caller and the policy.denied events in the event outbox (coalesced per subject, so do not count events to count denials; there is no per-rule evaluation trace).