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 distinctno_policyreason — fail-closed. Grant access by adding a policy whosecredential_patternmatches 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
| Field | Type | Description |
|---|---|---|
name | string | Unique policy name |
credential_pattern | string | Glob pattern for credentials this policy applies to |
principal_pattern | string (optional) | Glob over the presenting principal (key/token id or its agent_label). When set, the policy applies only to matching principals (V4). |
default_action | string | Action when no rules match: allow, deny or prompt (default deny when omitted) |
rules | array | List 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
| Field | Type | Description |
|---|---|---|
condition | object | Condition to evaluate |
action | string | Action 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
Denypolicy 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
denydefault.
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
- RBAC check: Does the API key have permission?
- Credential scope: Is the credential in scope for this role?
- Policy match: Find policies matching the credential alias (and principal)
- 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.
- Default action: If no rule matched, a
denydefault of any matching policy wins overprompt, andpromptoverallow. 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).