Vultrino
A credential proxy for the AI era — enabling AI agents to use credentials without seeing them.
What is Vultrino?
Vultrino keeps raw credential fields out of agent-facing requests and performs authenticated operations inside trusted connectors. An agent receives aliases and action results rather than direct access to the stored secret.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ AI Agent │────▶│ Vultrino │────▶│ External API │
│ (Claude, etc) │ │ (uses secrets) │ │ or Operation │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
│ "Use my-credential" │ Injects auth, signs data, etc.
│ │
▼ ▼
Alias-only request Injects inside a trusted connector
Key Features
- Credential Isolation — raw fields are absent from agent/MCP response schemas
- Role-Based Access Control — Fine-grained permissions for different applications
- Multiple Credential Types — API keys, Basic Auth, OAuth2, signing keys, and more
- Plugin System — Extend public-data actions through the credential-confining WASM ABI v2
- MCP Integration — Native Model Context Protocol support for LLM tools
- Web UI — Clean admin interface for managing credentials and keys
- Encrypted Storage — AES-256-GCM encryption with Argon2 key derivation
- Policy Engine — URL patterns, method restrictions, rate limiting
- Audit Logging — Track all credential usage
Use Cases
AI Agent Security
Give Claude, GPT, or other AI agents the ability to call APIs without exposing credentials. The agent requests actions through Vultrino, which handles authentication transparently.
Team Credential Management
Centralize API credentials for your team. Create scoped API keys for different applications with specific permissions.
Development Environments
Safely share credentials across development, staging, and production without exposing secrets in code or environment variables.
Quick Example
# Add a credential
vultrino add --alias github-api --key ghp_your_token_here
# Make an authenticated request
vultrino request github-api https://api.github.com/user
# Or use with AI agents via MCP
vultrino serve --mcp
Components
| Component | Description |
|---|---|
| CLI | Command-line interface for all operations |
| Web UI | Browser-based admin dashboard |
| HTTP API | POST /api/v1/execute runs authenticated requests on behalf of agents (served by vultrino web) |
| MCP Server | Model Context Protocol server for LLM integration |
Next Steps
- Installation — Get Vultrino running
- Quick Start — Add your first credential
- Using with AI Agents — Configure LLM integration
- Plugin System — Extend with custom credential types
Installation
Requirements
- Rust 1.94.0 (pinned in
rust-toolchain.toml;rustupinstalls it) - No system OpenSSL packages — TLS uses rustls
From Source (Recommended)
# Clone the repository
git clone https://github.com/FeirAI/vultrino.git
cd vultrino
# Build in release mode (uses committed Cargo.lock)
cargo build --release --locked
# The binary will be at target/release/vultrino
# Optionally, copy to your PATH
cp target/release/vultrino /usr/local/bin/
Using Cargo
cargo install --git https://github.com/FeirAI/vultrino --locked --bin vultrino
Pre-built Binaries
Download pre-built binaries from the GitHub Releases page (published when a v* tag is pushed).
macOS
# Intel
curl -L https://github.com/FeirAI/vultrino/releases/latest/download/vultrino-x86_64-apple-darwin.tar.gz | tar xz
sudo mv vultrino /usr/local/bin/
# Apple Silicon
curl -L https://github.com/FeirAI/vultrino/releases/latest/download/vultrino-aarch64-apple-darwin.tar.gz | tar xz
sudo mv vultrino /usr/local/bin/
Linux
# x86_64
curl -L https://github.com/FeirAI/vultrino/releases/latest/download/vultrino-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv vultrino /usr/local/bin/
# ARM64
curl -L https://github.com/FeirAI/vultrino/releases/latest/download/vultrino-aarch64-unknown-linux-gnu.tar.gz | tar xz
sudo mv vultrino /usr/local/bin/
Docker / GHCR
docker pull ghcr.io/feirai/vultrino:latest
docker run --rm -p 7879:7879 \
-e VULTRINO_PASSWORD=your-secure-password \
ghcr.io/feirai/vultrino:latest
See Docker deployment for compose and volume layout.
Verify Installation
vultrino --version
# vultrino 0.1.0
Next Steps
Continue to Quick Start to initialize Vultrino and add your first credential.
Quick Start
This guide will get you up and running with Vultrino in under 5 minutes.
1. Initialize Vultrino
First, initialize the configuration and set up your admin account:
vultrino init
You'll be prompted to:
- Set a storage password (encrypts your credentials at rest)
- Create an admin username for the web UI
- Set an admin password for the web UI
Tip: Set the
VULTRINO_PASSWORDenvironment variable to avoid password prompts:export VULTRINO_PASSWORD="your-secure-password"
2. Add Your First Credential
Add an API key credential:
vultrino add --alias github-api --key ghp_your_github_token
Add a Basic Auth credential:
vultrino add --alias my-service --type basic_auth --username admin --password secret123
3. List Your Credentials
vultrino list
Output:
ALIAS TYPE ID DESCRIPTION
github-api api_key a1b2c3d4-... -
my-service basic_auth e5f6g7h8-... -
4. Make an Authenticated Request
Use the request command to make API calls with your stored credentials:
vultrino request github-api https://api.github.com/user
The credential is automatically injected — you never need to expose the actual token.
5. Start the Web UI
Launch the admin dashboard:
vultrino web
Open http://127.0.0.1:7879 and log in with your admin credentials.
6. Create an API Key for AI Agents
Before AI agents can use Vultrino, create a scoped API key:
vultrino key create my-agent --role executor
Output:
API key created successfully!
Key: vk_abc123...
*** SAVE THIS KEY - IT WILL NOT BE SHOWN AGAIN ***
Name: my-agent
Role: executor
Expires: Never
Important: Save this key securely. It provides scoped access to your credentials.
7. Start the Server
Start the web server (required for CLI with API key and web UI):
vultrino web
This starts:
- Web UI at http://127.0.0.1:7879
- JSON API for CLI and external apps
8. Use CLI with API Key (No Password)
Once the server is running, use CLI commands with just the API key:
# List credentials (no VULTRINO_PASSWORD needed!)
vultrino --key vk_abc123... request github-api https://api.github.com/user
9. Use with MCP (AI Agents)
Start the MCP server:
vultrino mcp
AI agents use your API key in every tool call:
{
"tool": "http_request",
"arguments": {
"api_key": "vk_abc123...",
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user"
}
}
See Using with AI Agents for detailed setup.
Common Commands
| Command | Description |
|---|---|
vultrino init | Initialize configuration and admin account |
vultrino add --alias NAME --key TOKEN | Add an API key credential |
vultrino list | List all credentials |
vultrino remove <alias> | Remove a credential |
vultrino request <alias> <url> | Make authenticated request (needs password) |
vultrino --key KEY request <alias> <url> | Make request with API key (no password) |
vultrino web | Start web UI and API server |
vultrino mcp | Start MCP server for AI agents |
vultrino key create NAME --role ROLE | Create API key |
vultrino key list | List API keys |
vultrino key revoke NAME | Revoke an API key |
vultrino role list | List available roles |
Next Steps
- Configuration — Customize Vultrino settings
- Managing Credentials — Advanced credential management
- Roles & API Keys — Set up access control
- Using with AI Agents — MCP integration guide
Configuration
Vultrino uses a TOML configuration file located at:
- macOS:
~/Library/Application Support/vultrino/config.toml - Linux:
~/.config/vultrino/config.toml - Windows:
%APPDATA%\vultrino\config.toml
Default Configuration
# Vultrino Configuration
[server]
bind = "127.0.0.1:7878"
mode = "local"
[storage]
backend = "file"
[storage.file]
path = "~/.local/share/vultrino/credentials.enc"
[logging]
level = "info"
# audit_file = "~/.local/share/vultrino/audit.log"
[mcp]
enabled = true
transport = "stdio"
Configuration Options
Server Section
[server]
bind = "127.0.0.1:7878" # legacy `serve` default; see note below
mode = "local" # "local" or "server"
| Option | Description | Default |
|---|---|---|
bind | Listen address for the serve subcommand. vultrino web (the HTTP JSON API + admin UI) defaults to 127.0.0.1:7879 and the --bind flag overrides per process. | 127.0.0.1:7878 |
mode | Deployment mode: server sets require_auth = true on the in-process default (local otherwise). | local |
The JSON API and admin UI are served by
vultrino webon7879, not by this[server].bind.vultrino serve(which this key configures) no longer starts an API server on its own — see the CLI reference.
Storage Section
[storage]
backend = "file" # Storage backend: "file", "keychain", or "vault"
[storage.file]
path = "~/.local/share/vultrino/credentials.enc"
| Option | Description | Default |
|---|---|---|
backend | Storage backend type | file |
path | Path to encrypted credentials file | OS-specific |
Logging Section
[logging]
level = "info" # Log level: error, warn, info, debug, trace
# audit_file = "~/.local/share/vultrino/audit.log" # Optional audit log
| Option | Description | Default |
|---|---|---|
level | Logging verbosity | info |
audit_file | Path to audit log (optional) | disabled |
MCP Section
[mcp]
enabled = true
transport = "stdio" # "stdio" or "http"
| Option | Description | Default |
|---|---|---|
enabled | Enable MCP server | true |
transport | Transport method | stdio |
Enforcement Section
Controls what the policy engine decides for a credential that matches no policy at all.
[enforcement]
default_action = "deny" # "deny" (fail-closed, default) or "allow" (fail-open)
| Option | Description | Default |
|---|---|---|
default_action | Decision for a credential matched by no policy: deny or allow | deny |
With deny (the default, and the recommended posture for shared/server
deployments), an un-policied credential is denied with a distinct no_policy
reason — closing the historical fail-open gap. Use allow for the legacy
behavior where an un-policied credential is permitted. If the section is omitted
the built-in default is deny. The config produced by vultrino init also
ships with deny and prints a reminder that you must add an allow policy (or
switch to allow) before credentials will work.
When
default_action = "deny"and no policies are configured, every credential is denied. Vultrino logs a loud warning at startup in this case (and the symmetricallow+ no-policies fail-open case is warned about too).
Upgrading (breaking change)
Before this change the engine was fail-open: a credential matching no policy
was allowed. It is now fail-closed by default. A config that has no
[enforcement] section will start denying un-policied credentials after upgrade.
To preserve the pre-upgrade behavior, add:
[enforcement]
default_action = "allow"
Otherwise, add allow policies for the credentials your agents legitimately use.
Spend Extractors
For SpendCap policies (V3), Vultrino needs to know where the amount lives in
the request body. Each extractor matches an action + credential and reads the
amount (an integer in minor units, e.g. cents) from a JSON pointer, plus an
asset (literal or a second pointer).
[[spend_extractors]]
action_pattern = "http.request" # glob over plugin.action
credential_pattern = "stripe-*" # glob over credential alias
amount_pointer = "/body/amount" # JSON pointer to the integer amount
asset = "usd" # literal asset...
# asset_pointer = "/body/currency" # ...or read it from the body
If a SpendCap policy applies to a credential but no extractor yields an amount
(missing extractor or unparseable body), the request is denied (fail-closed)
and a spend_unparseable warning is logged.
Egress Controls
Vultrino keeps proxied responses from carrying secrets back to the agent (V7), applied at the execution seam for every plugin:
- Always-on secret-material redaction. If an endpoint reflects the
credential's own injected secret in its response (a header-echoing reflector,
an open redirect, etc.), the secret — and its common re-encoded forms
(percent-encoded, JSON-escaped) — is scrubbed from the body and headers and
replaced with the constant
[REDACTED]marker before the response is returned. This is not configurable. It is defense-in-depth, not absolute: an endpoint that transforms the secret (base64, hashing, splitting it) — or returns a compressed body (the http plugin requestsAccept-Encoding: identity, but a server may compress anyway) — can still leak it. Use ablockrule for endpoints you don't trust. Secrets shorter than 5 bytes are not scrubbed (too little entropy to match safely); a warning is logged when such a credential is created. - Egress classification. For endpoints whose response is itself a secondary
secret (an STS/login/secret-read endpoint), configure
[[egress]]rules:
[[egress]]
credential_pattern = "sts-*" # glob over credential alias
action_pattern = "http.request" # glob over plugin.action (default "*")
block = true # withhold the body + headers entirely
[[egress]]
credential_pattern = "secrets-api-*"
redact_patterns = ['"token":\s*"[^"]+"', "AKIA[0-9A-Z]{16}"] # extra regexes to redact
The first matching rule applies. block = true replaces the body with a marker
and drops the headers; otherwise any redact_patterns (regexes) are scrubbed
from the body (on top of the always-on redaction).
Downstream credentials. Blocking/redacting prevents an agent from reading a downstream secret out of a response. Deleting an OAuth2 credential that carries a
revocation_urlmetadata key now propagates the revoke to the provider (R5/V7): Vultrino calls the RFC 7009 revocation endpoint for the credential's issued access and refresh tokens before removing it locally, so an already-issued downstream secret is actively revoked rather than left to expire, and acredential.revokedevent is emitted to the signed outbox. Set it withvultrino meta set <oauth-cred> revocation_url https://idp/oauth/revoke(HTTPS required). Prefer credential types that mint short-lived, revocable downstream credentials (OAuth2 client-credentials, STS, SVIDs) so a revoke maps to a real resource-side revoke. OAuth2 in-path token rotation also emits acredential.rotatedevent.
Action Labels
Map a govder business verb to a canonical plugin.action (V8), so use-token
scopes and the approval/audit trail can speak in business terms while vultrino
executes the underlying plugin action. (Policy rules match on
URL/method/credential/principal/spend — not on the action label — so the verb is
a scoping and audit concept, not a policy-condition one.)
[[action_labels]]
label = "payments.refund" # what govder / a token scopes against
action = "http.request" # the canonical plugin.action vultrino runs
A request (or use-token action_scope) may then use the label payments.refund;
it resolves to http.request for execution, the use-token scope is satisfied by
either the label or the canonical action, and the approver sees the business
verb in the approval. The typed /api/v1/execute endpoint also accepts an
optional action field (default http.request) so it is no longer hardwired.
Event Outbox (V9)
Vultrino records security-relevant events to a durable, ordered, replayable,
signed outbox: approval requested/approved/denied/escalated/expired,
agent.halted, policy.changed, credential.rotated, credential.revoked (a
downstream revoke propagated to the provider on delete), policy.observed_denial
(an observe-only tenant's un-enforced denial), and policy.denied (an
enforce-mode denial — a DETECT signal whose created_at is a per-incident
detected_at that pairs, on the same subject, with the agent.halted
contained_at for an MTTD/MTTC measurement). Configure push delivery with
[outbox]:
[outbox]
url = "https://govder.example.com/vultrino/events" # delivery endpoint
hmac_secret = "shared-signing-secret" # required to push (deliveries are signed)
max_attempts = 8 # retries before dead-lettering (default 8)
retention_secs = 604800 # replay window, default 7 days
- Ordered + monotonic. Every event gets a process-global, gap-free
sequence. Events for the samesubject(e.g. an approval id) are delivered in order. - Signed. Each delivery carries
Govder-Signature: sha256=<hex>=HMAC-SHA256(hmac_secret, body). A consumer recomputes it over the raw body to verify authenticity. Enabling the outbox requires bothurlandhmac_secret(an unsigned/undeliverable outbox is rejected at load). - Exactly-once-ish delivery across processes. Each event is atomically claimed (leased) under the vault lock before it is POSTed, so the web and MCP processes can't both deliver it; a failed delivery backs off (the lease holds it off the retry queue) before re-attempting, and a crashed deliverer's lease is reclaimed once stale.
- Replayable. A consumer that drops offline replays from its last-seen
sequence:
GET /api/v1/events?after=<cursor>returns the next events — each as{ "body": …, "signature": "sha256=…" }, the same body a push carries plus its signature — with no gaps and no dupes, within the retention window. - Dead-letter queue. An event that fails
max_attemptsdeliveries is parked (GET /api/v1/events/dead) and re-queued withPOST /api/v1/events/{sequence}/replay— it stops blocking its subject. - Events are appended even when push is unconfigured (still replayable via the
API). GC prunes the oldest contiguous prefix past
retention_secs(keeping the retained window gap-free); the window is the replay + dead-letter-resolution SLA.
Inbound Workload Identity (V10)
Resolve the principal vultrino evaluates from an inbound SPIFFE SVID or OIDC
claims document instead of only the static vk_/vut_ id. A request carrying
the configured header (an already transport-verified document — terminate
mTLS / verify the token at the edge) has its principal resolved before policy
evaluation.
[identity]
kind = "spiffe" # spiffe | oidc (the wireable resolvers)
header = "x-spiffe-verified" # inbound header carrying the verified document
allowed = ["example.org"] # SPIFFE trust domains (or OIDC issuers); empty = any
The resolved subject becomes the Principal.id a policy principal_pattern
matches (and the SoD owner from an OIDC email/preferred_username). A
malformed/untrusted document is ignored (the static principal stands). See
Workload Identity.
Environment Variables
| Variable | Description |
|---|---|
VULTRINO_PASSWORD | Storage encryption password (avoids prompts) |
VULTRINO_CONFIG | Path to config file |
RUST_LOG | Override log level (e.g., vultrino=debug) |
Policy Configuration
Policies control which requests are allowed for each credential:
[[policies]]
name = "github-readonly"
credential_pattern = "github-*" # Glob pattern for credential aliases
default_action = "deny"
[[policies.rules]]
condition = { url_match = "https://api.github.com/*" }
action = "allow"
[[policies.rules]]
condition = { method_match = ["POST", "PUT", "DELETE"] }
action = "deny"
See Policy Configuration for detailed policy options.
Using a Custom Config File
vultrino --config /path/to/config.toml list
Regenerating Configuration
To reset to defaults:
vultrino init --force
Warning: This will overwrite your existing configuration and require re-entering admin credentials.
Deployment Overview
Vultrino can be deployed in several ways depending on your needs:
Deployment Options
| Method | Best For | Complexity |
|---|---|---|
| Local Development | Personal use, testing | Simple |
| VPS / Server | Team deployment, production | Moderate |
| Cloudflare Workers | Edge deployment, serverless | Moderate |
| Docker | Containerized environments | Simple |
Architecture Considerations
Single Binary
Vultrino is distributed as a single binary with no external dependencies. This includes:
- CLI commands
- HTTP proxy server
- Web UI (embedded)
- MCP server
Storage
Credentials are stored encrypted using AES-256-GCM. Storage options:
- File (default) — Encrypted JSON file on disk
- Keychain — OS keychain integration (coming soon)
- Vault — HashiCorp Vault integration (coming soon)
Network Security
Local Mode (default):
- Binds to
127.0.0.1only - No external access
- Suitable for single-machine use
Server Mode:
- Can bind to all interfaces (
0.0.0.0) - Requires additional security measures
- Use with TLS termination proxy (nginx, Caddy)
Security Recommendations
- Always use HTTPS in production — Use a reverse proxy with TLS
- Restrict network access — Firewall rules, VPN, or private network
- Use strong passwords — Both storage and admin passwords
- Enable audit logging — Track credential usage
- Rotate API keys — Set expiration on Vultrino API keys
- Principle of least privilege — Create scoped roles for each application
Quick Comparison
┌─────────────────────────────────────────────────────────────────┐
│ LOCAL │
│ vultrino web (+ vultrino mcp for AI agents) │
│ ├── Best for: Personal use, development │
│ ├── Security: Localhost only │
│ └── Setup: Minimal │
├─────────────────────────────────────────────────────────────────┤
│ VPS/SERVER │
│ vultrino web + nginx/caddy │
│ ├── Best for: Team use, production │
│ ├── Security: TLS, firewall, auth │
│ └── Setup: Moderate (systemd, reverse proxy) │
├─────────────────────────────────────────────────────────────────┤
│ CLOUDFLARE │
│ Cloudflare Workers + KV/Durable Objects │
│ ├── Best for: Edge deployment, global access │
│ ├── Security: Cloudflare's infrastructure │
│ └── Setup: Moderate (requires adaptation) │
├─────────────────────────────────────────────────────────────────┤
│ DOCKER │
│ docker run vultrino │
│ ├── Best for: Containerized environments │
│ ├── Security: Container isolation │
│ └── Setup: Simple (docker-compose) │
└─────────────────────────────────────────────────────────────────┘
Next Steps
Choose your deployment method:
- Local Development — Start here for testing
- VPS / Server — Production deployment
- Cloudflare Workers — Edge/serverless deployment
- Docker — Container deployment
Local Development
The simplest way to run Vultrino — perfect for personal use and development.
Quick Setup
# 1. Initialize
vultrino init
# 2. Add credentials
vultrino add --alias github-api --key ghp_xxx
# 3. Start services
vultrino web & # HTTP API + web UI on :7879
vultrino mcp # MCP server (stdio) for AI agents
Running Components
HTTP API + Web UI
The web process serves the JSON API (/api/v1/…), the connector routes
(/mcp, /llm), and the HTML admin UI on one port:
export VULTRINO_PASSWORD="your-password"
vultrino web
# Access at http://127.0.0.1:7879
MCP Server Only
For local AI agent integration over stdio:
export VULTRINO_PASSWORD="your-password"
vultrino mcp # equivalently: vultrino serve --mcp
vultrino serveon its own does not start an API server (it's a stub that redirects you tovultrino web). Usevultrino webfor HTTP.
Configuration for Local Use
The default configuration is optimized for local development:
[server]
mode = "local"
[storage]
backend = "file"
Using with Claude Desktop
Add to your Claude Desktop MCP configuration (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"vultrino": {
"command": "/path/to/vultrino",
"args": ["mcp"],
"env": {
"VULTRINO_PASSWORD": "your-password"
}
}
}
}
Running as Background Process
macOS (launchd)
Create ~/Library/LaunchAgents/dev.vultrino.web.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.vultrino.web</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/vultrino</string>
<string>web</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>VULTRINO_PASSWORD</key>
<string>your-password</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
Load it:
launchctl load ~/Library/LaunchAgents/dev.vultrino.web.plist
Linux (systemd user service)
Create ~/.config/systemd/user/vultrino-web.service:
[Unit]
Description=Vultrino Web UI
After=network.target
[Service]
Type=simple
Environment="VULTRINO_PASSWORD=your-password"
ExecStart=/usr/local/bin/vultrino web
Restart=always
[Install]
WantedBy=default.target
Enable and start:
systemctl --user enable vultrino-web
systemctl --user start vultrino-web
Tips
- Store password in keychain — Use OS keychain to avoid plaintext passwords
- Use aliases — Add
alias vreq='vultrino request'to your shell - Tab completion — Generate with
vultrino completions bash > /etc/bash_completion.d/vultrino
Troubleshooting
"Device not configured" error
The password prompt requires a terminal. Set VULTRINO_PASSWORD environment variable instead.
"Address already in use"
Another process is using the port. Check with:
lsof -i :7879
Credentials not loading
Ensure you're using the same VULTRINO_PASSWORD that was used when creating credentials.
VPS / Server Deployment
Deploy Vultrino on a VPS or dedicated server for team access and production use.
Prerequisites
- Linux server (Ubuntu 22.04+ recommended)
- Domain name (optional but recommended)
- TLS certificate (Let's Encrypt)
Installation
# Download latest release
curl -L https://github.com/FeirAI/vultrino/releases/latest/download/vultrino-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv vultrino /usr/local/bin/
# Create vultrino user
sudo useradd -r -s /bin/false vultrino
# Create directories
sudo mkdir -p /etc/vultrino /var/lib/vultrino
sudo chown vultrino:vultrino /var/lib/vultrino
Configuration
Create /etc/vultrino/config.toml:
[server]
# `mode = "server"` enables the stricter server posture. `vultrino web` binds
# 127.0.0.1:7879 by default (the systemd unit below sets it explicitly with --bind).
mode = "server"
[storage]
backend = "file"
[storage.file]
path = "/var/lib/vultrino/credentials.enc"
[logging]
level = "info"
audit_file = "/var/log/vultrino/audit.log"
[mcp]
enabled = true
transport = "stdio"
Systemd Services
Web UI Service
Create /etc/systemd/system/vultrino-web.service:
[Unit]
Description=Vultrino Web UI
After=network.target
[Service]
Type=simple
User=vultrino
Group=vultrino
Environment="VULTRINO_PASSWORD=your-secure-password"
Environment="VULTRINO_CONFIG=/etc/vultrino/config.toml"
ExecStart=/usr/local/bin/vultrino web --bind 127.0.0.1:7879
Restart=always
RestartSec=5
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/vultrino
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
The single vultrino web process serves the HTTP JSON API (/api/v1/…), the
connector routes (/mcp, /llm), and the admin UI — there is no separate proxy
service. (vultrino serve no longer starts an API server.)
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable vultrino-web
sudo systemctl start vultrino-web
Reverse Proxy Setup
Nginx
Install nginx and certbot:
sudo apt install nginx certbot python3-certbot-nginx
Create /etc/nginx/sites-available/vultrino:
server {
listen 80;
server_name vultrino.yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name vultrino.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/vultrino.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vultrino.yourdomain.com/privkey.pem;
# Web UI + JSON API (both served by vultrino web on 7879)
location / {
proxy_pass http://127.0.0.1:7879;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Enable and get certificate:
sudo ln -s /etc/nginx/sites-available/vultrino /etc/nginx/sites-enabled/
sudo certbot --nginx -d vultrino.yourdomain.com
sudo systemctl reload nginx
Caddy (Alternative)
Create /etc/caddy/Caddyfile:
vultrino.yourdomain.com {
reverse_proxy 127.0.0.1:7879
}
Firewall Configuration
# Allow only HTTPS
sudo ufw allow 443/tcp
sudo ufw allow 22/tcp # SSH
sudo ufw enable
Initialize Credentials
# Set password
export VULTRINO_PASSWORD="your-secure-password"
# Initialize (as vultrino user)
sudo -u vultrino VULTRINO_PASSWORD="$VULTRINO_PASSWORD" vultrino init
# Add credentials
sudo -u vultrino VULTRINO_PASSWORD="$VULTRINO_PASSWORD" vultrino add --alias github-api --key ghp_xxx
Security Checklist
- Strong storage password (32+ characters)
- Strong admin password
- TLS enabled (HTTPS only)
- Firewall configured
- Audit logging enabled
-
Regular backups of
/var/lib/vultrino/ - API keys have expiration dates
- Roles use principle of least privilege
Monitoring
Check service status
sudo systemctl status vultrino-web
View logs
sudo journalctl -u vultrino-web -f
sudo tail -f /var/log/vultrino/audit.log
Health check
curl -s http://127.0.0.1:7879/login | head -1
Backup & Restore
Backup
sudo tar -czf vultrino-backup-$(date +%Y%m%d).tar.gz \
/etc/vultrino \
/var/lib/vultrino
Restore
sudo tar -xzf vultrino-backup-YYYYMMDD.tar.gz -C /
sudo systemctl restart vultrino-web
Cloudflare Workers Deployment
Note: Cloudflare Workers deployment requires adapting Vultrino to the Workers runtime. This guide covers the architecture and approach.
Overview
Deploying Vultrino to Cloudflare Workers provides:
- Global edge deployment
- Serverless scaling
- Cloudflare's security infrastructure
- Low-latency access worldwide
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare Edge │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
│ │ Workers │ │ Durable Objects │ │
│ │ (HTTP API) │────▶│ (Session State, Credentials) │ │
│ └─────────────────┘ └─────────────────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────────────────────┐ │
│ │ │ KV │ │
│ │ │ (Encrypted Credential Storage) │ │
│ │ └─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ External APIs │ │
│ │ (via fetch) │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Components Mapping
| Vultrino Component | Cloudflare Equivalent |
|---|---|
| Encrypted file storage | KV with encryption |
| Session management | Durable Objects |
| HTTP handlers | Workers |
| Static assets | Workers Sites or R2 |
Implementation Approach
1. Create Worker Project
npm create cloudflare@latest vultrino-edge -- --template hello-world
cd vultrino-edge
2. Configure wrangler.toml
name = "vultrino"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[vars]
ENVIRONMENT = "production"
[[kv_namespaces]]
binding = "CREDENTIALS"
id = "your-kv-namespace-id"
[[durable_objects.bindings]]
name = "SESSIONS"
class_name = "SessionDO"
[[migrations]]
tag = "v1"
new_classes = ["SessionDO"]
3. Implement Core Logic
// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
type Bindings = {
CREDENTIALS: KVNamespace;
SESSIONS: DurableObjectNamespace;
ENCRYPTION_KEY: string;
};
const app = new Hono<{ Bindings: Bindings }>();
// Middleware
app.use('*', cors());
// Login
app.post('/login', async (c) => {
const { username, password } = await c.req.json();
// Verify credentials, create session
// ...
});
// Execute request with credential (mirrors the shipped POST /api/v1/execute:
// a flat body with credential/method/url, not a nested `params` object)
app.post('/api/v1/execute', async (c) => {
const { credential, method, url, headers, body } = await c.req.json();
// Get encrypted credential from KV
const encryptedCred = await c.env.CREDENTIALS.get(credential);
if (!encryptedCred) {
return c.json({ error: 'Credential not found' }, 404);
}
// Decrypt credential
const cred = await decrypt(encryptedCred, c.env.ENCRYPTION_KEY);
// Execute request with injected auth
const response = await executeWithCredential(cred, { method, url, headers, body });
return c.json(response);
});
export default app;
4. Credential Encryption
Use Web Crypto API for encryption:
async function encrypt(data: string, key: string): Promise<string> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(key),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode('vultrino-salt'),
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
derivedKey,
encoder.encode(data)
);
// Return iv + ciphertext as base64
return btoa(String.fromCharCode(...iv, ...new Uint8Array(encrypted)));
}
5. Deploy
# Set secrets
wrangler secret put ENCRYPTION_KEY
wrangler secret put ADMIN_PASSWORD_HASH
# Deploy
wrangler deploy
Limitations
| Feature | Status | Notes |
|---|---|---|
| HTTP Proxy | ✅ | Via fetch() |
| Web UI | ✅ | Workers Sites |
| MCP Server | ❌ | Requires stdio (not available) |
| File Storage | ⚠️ | Use KV instead |
| OS Keychain | ❌ | Not available |
Security Considerations
- Secrets Management — Use Wrangler secrets for encryption keys
- KV Encryption — Always encrypt credentials before storing in KV
- Access Control — Use Cloudflare Access for additional auth layer
- Audit Logging — Log to Workers Analytics or external service
Alternative: Hybrid Deployment
For MCP support, consider a hybrid approach:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ AI Agent │────▶│ Local Vultrino │────▶│ Cloudflare │
│ (MCP) │ │ (MCP Server) │ │ Vultrino Edge │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
Syncs credentials
from edge storage
This allows:
- MCP support via local instance
- Centralized credential management on edge
- Web UI accessible globally
Docker Deployment
Run Vultrino in containers for easy deployment and isolation.
Quick Start
docker run -d \
--name vultrino \
-p 7879:7879 \
-e VULTRINO_PASSWORD=your-secure-password \
-v vultrino-data:/data \
ghcr.io/feirai/vultrino:latest web
Dockerfile
FROM rust:1.75-slim as builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/vultrino /usr/local/bin/
# Create non-root user
RUN useradd -r -s /bin/false vultrino
USER vultrino
WORKDIR /data
VOLUME ["/data"]
EXPOSE 7879
ENTRYPOINT ["vultrino"]
CMD ["web"]
Docker Compose
Create docker-compose.yml:
version: '3.8'
services:
vultrino-web:
image: ghcr.io/feirai/vultrino:latest
command: web --bind 0.0.0.0:7879
ports:
- "7879:7879"
environment:
- VULTRINO_PASSWORD=${VULTRINO_PASSWORD}
volumes:
- vultrino-data:/data
- ./config.toml:/etc/vultrino/config.toml:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7879/login"]
interval: 30s
timeout: 10s
retries: 3
volumes:
vultrino-data:
Create .env:
VULTRINO_PASSWORD=your-secure-password
Run:
docker-compose up -d
With Traefik (Reverse Proxy)
version: '3.8'
services:
traefik:
image: traefik:v2.10
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "[email protected]"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
vultrino:
image: ghcr.io/feirai/vultrino:latest
command: web --bind 0.0.0.0:7879
environment:
- VULTRINO_PASSWORD=${VULTRINO_PASSWORD}
volumes:
- vultrino-data:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.vultrino.rule=Host(`vultrino.yourdomain.com`)"
- "traefik.http.routers.vultrino.entrypoints=websecure"
- "traefik.http.routers.vultrino.tls.certresolver=letsencrypt"
- "traefik.http.services.vultrino.loadbalancer.server.port=7879"
volumes:
vultrino-data:
letsencrypt:
Kubernetes
Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: vultrino
spec:
replicas: 1
selector:
matchLabels:
app: vultrino
template:
metadata:
labels:
app: vultrino
spec:
containers:
- name: vultrino-web
image: ghcr.io/feirai/vultrino:latest
args: ["web", "--bind", "0.0.0.0:7879"]
ports:
- containerPort: 7879
env:
- name: VULTRINO_PASSWORD
valueFrom:
secretKeyRef:
name: vultrino-secrets
key: password
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: vultrino-pvc
---
apiVersion: v1
kind: Service
metadata:
name: vultrino
spec:
selector:
app: vultrino
ports:
- port: 80
targetPort: 7879
---
apiVersion: v1
kind: Secret
metadata:
name: vultrino-secrets
type: Opaque
stringData:
password: your-secure-password
Building the Image
# Clone repository
git clone https://github.com/FeirAI/vultrino.git
cd vultrino
# Build image
docker build -t vultrino:local .
# Run
docker run -d \
--name vultrino \
-p 7879:7879 \
-e VULTRINO_PASSWORD=test \
-v vultrino-data:/data \
vultrino:local web
Environment Variables
| Variable | Description | Required |
|---|---|---|
VULTRINO_PASSWORD | Storage encryption password | Yes |
VULTRINO_CONFIG | Path to config file | No |
RUST_LOG | Log level | No |
Volumes
| Path | Description |
|---|---|
/data | Credential storage and state |
/etc/vultrino/config.toml | Configuration file (optional) |
Initializing in Docker
# Initialize (creates admin credentials)
docker run -it --rm \
-v vultrino-data:/data \
ghcr.io/feirai/vultrino:latest \
init
# Add a credential
docker run -it --rm \
-e VULTRINO_PASSWORD=your-password \
-v vultrino-data:/data \
ghcr.io/feirai/vultrino:latest \
add --alias github-api --key ghp_xxx
Health Checks
# Check the web UI
curl -f http://localhost:7879/login
# Check the JSON API (no auth required)
curl -f http://localhost:7879/api/v1/health
CLI Reference
The Vultrino CLI provides complete control over credential management, server operations, and administration.
Global Options
vultrino [OPTIONS] <COMMAND>
Options:
-c, --config <FILE> Path to config file
-v, --verbose Enable verbose output
-h, --help Print help
-V, --version Print version
Environment Variables
| Variable | Description |
|---|---|
VULTRINO_PASSWORD | Storage encryption password (required) |
VULTRINO_CONFIG | Path to config file |
RUST_LOG | Log level (trace, debug, info, warn, error) |
Commands
init
Initialize a new Vultrino instance.
vultrino init [OPTIONS]
Options:
--force Overwrite existing configuration
This command:
- Creates the credentials storage file
- Prompts for admin username and password
- Sets up initial configuration
Example:
export VULTRINO_PASSWORD="your-secure-password"
vultrino init
# Enter admin username: admin
# Enter admin password: ********
add
Add a new credential to storage.
vultrino add [OPTIONS]
Options:
-a, --alias <ALIAS> Human-readable name (required)
-t, --type <TYPE> Credential type [default: api_key]
-k, --key <KEY> API key or secret value
-u, --username <USERNAME> Username (for basic auth)
-p, --password <PASSWORD> Password (for basic auth)
--description <DESC> Optional description
Credential Types:
api_key— API key or tokenbasic_auth— Username and passwordoauth2— OAuth2 credentials (client ID, secret, tokens)private_key— SSH or signing key
Examples:
# Add an API key
vultrino add --alias github-api --key ghp_xxx...
# Add basic auth credentials
vultrino add --alias jira-api --type basic_auth \
--username [email protected] --password secret123
# Add with description
vultrino add --alias stripe-api --key sk_live_xxx \
--description "Production Stripe key"
list
List all stored credentials.
vultrino list [OPTIONS]
Options:
--json Output as JSON
Example:
vultrino list
# ID Alias Type Created
# 550e8400-e29b-41d4-a716-446655440000 github-api api_key 2024-01-15
# 6ba7b810-9dad-11d1-80b4-00c04fd430c8 stripe-api api_key 2024-01-16
info
Show details about a specific credential — metadata only, never the secret.
vultrino info <ALIAS>
Accepts a credential alias or id.
Example:
vultrino info github-api
# Alias: github-api
# Type: api_key
# Created: 2024-01-15T10:30:00Z
# Description: GitHub personal access token
remove
Remove a credential from storage.
vultrino remove <ALIAS>
Accepts a credential alias or id.
Example:
vultrino remove old-api-key
# Deleted credential: old-api-key
serve
Run the MCP server (with --mcp). serve on its own no longer starts an API
server — it prints a message directing you to vultrino web and exits without
binding. Use vultrino web for the HTTP JSON API and admin UI, and vultrino mcp
(or vultrino serve --mcp) for the MCP stdio server.
vultrino serve [OPTIONS]
Options:
-b, --bind <ADDR> Bind address for the legacy stub [default: 127.0.0.1:7878]
--mcp Start as MCP server (stdio transport) — same as `vultrino mcp`
Examples:
# Start MCP server for AI agents
vultrino serve --mcp # equivalently: vultrino mcp
# `vultrino serve` alone does NOT serve the API — use `vultrino web` instead
web
Start the HTTP JSON API and web administration UI (the same process serves both,
plus the /mcp and /llm connector routes).
vultrino web [OPTIONS]
Options:
-b, --bind <ADDR> Bind address [default: 127.0.0.1:7879]
Example:
vultrino web
# API + Web UI available at http://127.0.0.1:7879
request
Make an authenticated HTTP request.
vultrino request [OPTIONS] <CREDENTIAL> <URL>
Arguments:
<CREDENTIAL> Credential alias to use for authentication
<URL> Target URL
Options:
-X, --method <METHOD> HTTP method [default: GET]
-H, --header <HEADER> Additional headers (can be repeated)
-d, --data <DATA> Request body (JSON string, or @filename to read from a file)
-q, --quiet Output only the response body (no status info)
Examples:
# Simple GET request (credential alias is the first positional argument)
vultrino request github-api https://api.github.com/user
# POST with JSON body
vultrino request stripe-api \
-X POST \
-H "Content-Type: application/json" \
-d '{"amount": 1000, "currency": "usd"}' \
https://api.stripe.com/v1/charges
# Body only
vultrino request api-key https://api.example.com/data --quiet
role
Manage RBAC roles.
vultrino role <SUBCOMMAND>
Subcommands:
create Create a new role
list List all roles
get Get role details
delete Delete a role
Create a role:
vultrino role create <NAME> [OPTIONS]
Options:
-p, --permissions <PERMS> Comma-separated: read,write,update,delete,execute
-s, --scopes <SCOPES> Credential patterns (glob): "github-*,stripe-*"
-d, --description <DESC> Role description
Examples:
# Create read-only role
vultrino role create readonly --permissions read
# Create role for GitHub credentials only
vultrino role create github-executor \
--permissions read,execute \
--scopes "github-*" \
--description "Execute requests with GitHub credentials"
# List roles
vultrino role list
# Delete role
vultrino role delete old-role
key
Manage API keys for programmatic access.
vultrino key <SUBCOMMAND>
Subcommands:
create Create a new API key
list List all API keys
revoke Revoke an API key
Create an API key:
vultrino key create <NAME> [OPTIONS]
Options:
-r, --role <ROLE> Role to assign (required)
-e, --expires <DURATION> Expiration (e.g., "30d", "1y")
Examples:
# Create key with role
vultrino key create my-app --role github-executor
# Created API key: vk_a1b2c3d4e5f6...
# (Save this key - it won't be shown again)
# Create key with expiration
vultrino key create temp-key --role readonly --expires 7d
# List keys
vultrino key list
# Revoke key
vultrino key revoke vk_a1b2c3d4
completions
Generate shell completions.
vultrino completions <SHELL>
Shells:
bash, zsh, fish, powershell
Examples:
# Bash
vultrino completions bash > /etc/bash_completion.d/vultrino
# Zsh
vultrino completions zsh > ~/.zfunc/_vultrino
# Fish
vultrino completions fish > ~/.config/fish/completions/vultrino.fish
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Invalid arguments |
| 3 | Credential not found |
| 4 | Permission denied |
| 5 | Storage error |
Tips
Use Aliases
Add to your shell profile:
alias vreq='vultrino request'
alias vcred='vultrino list'
Store Password Securely
On macOS, use Keychain:
security add-generic-password -a vultrino -s vultrino -w "your-password"
export VULTRINO_PASSWORD=$(security find-generic-password -a vultrino -s vultrino -w)
On Linux, use a secrets manager or environment file:
# ~/.vultrino-env (chmod 600)
export VULTRINO_PASSWORD="your-password"
# In shell profile
source ~/.vultrino-env
Web UI
The Vultrino Web UI provides a browser-based interface for managing credentials, roles, and API keys.
Overview
The web interface offers:
- Dashboard with usage statistics
- Credential management (add, view, delete)
- Role-based access control configuration
- API key generation and management
- Audit log viewing
Starting the Web UI
export VULTRINO_PASSWORD="your-password"
vultrino web
# Web UI available at http://127.0.0.1:7879
Custom bind address:
vultrino web --bind 0.0.0.0:8080
Authentication
The web UI requires authentication with the admin credentials set during vultrino init.
Login
Navigate to http://127.0.0.1:7879 and enter:
- Username: The admin username set during init
- Password: The admin password set during init
Sessions expire after 24 hours of inactivity.
Changing Admin Password
Currently, to change the admin password:
-
Delete the admin configuration:
rm ~/.vultrino/admin.json -
Reinitialize:
vultrino init
Pages
Dashboard
The main dashboard displays:
- Total credentials stored
- Number of roles configured
- Active API keys
- Recent audit activity
Credentials
List View (/credentials)
- Shows all stored credentials
- Displays alias, type, and creation date
- Credentials are never shown in the UI
Add Credential (/credentials/new)
- Form to add new credentials
- Supported types: API Key, Basic Auth
- Optional description field
Delete Credential
- Click delete button on credential row
- Confirmation required
Roles
List View (/roles)
- Shows all configured roles
- Displays permissions and credential scopes
Create Role (/roles/new)
- Name and description
- Permission checkboxes:
- Read — List credentials
- Write — Create credentials
- Update — Modify credentials
- Delete — Remove credentials
- Execute — Use credentials for requests
- Credential scopes (glob patterns)
API Keys
List View (/keys)
- Shows all API keys (prefix only)
- Displays assigned role and expiration
- Shows last used timestamp
Create Key (/keys/new)
- Key name for identification
- Role selection dropdown
- Optional expiration date
Revoke Key
- Click revoke button on key row
- Immediate revocation, no confirmation
Audit Log
View (/audit)
- Recent credential usage events
- Shows timestamp, action, credential used
- IP address and user agent when available
Configuration
Session Settings
Configure in config.toml:
[web]
session_timeout = 86400 # 24 hours in seconds
cookie_secure = true # Require HTTPS for cookies
Binding
For production, always bind to localhost and use a reverse proxy:
[web]
bind = "127.0.0.1:7879"
Security Considerations
HTTPS
The web UI should always be accessed over HTTPS in production. Use a reverse proxy like nginx or Caddy for TLS termination.
Session Security
- Sessions are stored server-side
- Session IDs are cryptographically random
- Cookies are HTTP-only and secure (when behind HTTPS)
CSRF Protection
Forms include CSRF tokens to prevent cross-site request forgery.
Rate Limiting
Login attempts are rate-limited to prevent brute force attacks.
Screenshots
Login Page
┌─────────────────────────────────────────┐
│ Vultrino │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Username │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ Password │ │
│ └─────────────────────────────────┘ │
│ │
│ [ Sign in ] │
│ │
└─────────────────────────────────────────┘
Dashboard
┌─────────────────────────────────────────────────────────────┐
│ Vultrino Credentials Roles API Keys Audit [Logout] │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 5 │ │ 3 │ │ 2 │ │ 127 │ │
│ │ Creds │ │ Roles │ │ Keys │ │ Requests│ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Recent Activity │
│ ─────────────────────────────────────────────────── │
│ 10:30 github-api GET /user │
│ 10:28 stripe-api POST /v1/charges │
│ 10:25 github-api GET /repos │
│ │
└─────────────────────────────────────────────────────────────┘
Credentials List
┌─────────────────────────────────────────────────────────────┐
│ Credentials [ + New Credential]│
├─────────────────────────────────────────────────────────────┤
│ │
│ Alias Type Created Actions │
│ ───────────────────────────────────────────────────── │
│ github-api api_key Jan 15, 2024 [Delete] │
│ stripe-api api_key Jan 16, 2024 [Delete] │
│ jira-api basic_auth Jan 17, 2024 [Delete] │
│ │
└─────────────────────────────────────────────────────────────┘
Troubleshooting
"Invalid credentials" error
- Verify username and password match those set during
vultrino init - Check that admin.json exists in the data directory
Session expires immediately
- Ensure cookies are enabled in your browser
- If using HTTPS, verify
cookie_securematches your setup
Cannot access from external machine
- By default, the web UI binds to localhost only
- Use a reverse proxy to expose it securely
- Never bind directly to 0.0.0.0 in production without TLS
Blank page or errors
- Check browser console for JavaScript errors
- Verify the server is running:
curl http://127.0.0.1:7879/login - Check server logs for errors
HTTP API (Credential Broker)
Vultrino runs actions with a credential on the caller's behalf and injects the secret in-path, so the client makes authenticated calls without ever seeing the credential. There is no transparent forwarding proxy and no credential header — a caller names a credential alias and an action, and Vultrino executes it through the enforced Policy Enforcement Point.
This page is a task-oriented overview. The complete, code-verified route table (every path, header, request/response shape, and error code) lives in
docs/dev/API.md. Where this page and the dev reference differ, the dev reference wins.
The server
The JSON API is served by vultrino web, the same process that serves the
HTML admin panel:
export VULTRINO_PASSWORD="your-password"
vultrino web
# Listening on http://127.0.0.1:7879
Custom bind address:
vultrino web --bind 0.0.0.0:8080
The server speaks plaintext HTTP; terminate TLS at a reverse proxy for network
exposure. vultrino serve does not serve this API — see the CLI
reference.
Authentication
Every JSON API route under /api/v1/ (except /api/v1/health) authenticates a
bearer token:
Authorization: Bearer vk_your_api_key # an API key
Authorization: Bearer vut_your_use_token # a single-/multi-use scoped token
A vut_ prefix is recognized as a use token (scoped to one credential/action
with optional use limits); anything else is validated as an API key. Admin
routes additionally require an API key whose role holds the admin permission
(use tokens are rejected).
Running an action — POST /api/v1/execute
The credential broker. Vultrino resolves the credential by alias, injects the secret, runs the action, scrubs the response, and returns it — the secret never leaves the vault.
curl -sX POST http://127.0.0.1:7879/api/v1/execute \
-H "Authorization: Bearer vk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user"
}'
The request body is flat (not nested under params):
| Field | Required | Notes |
|---|---|---|
credential | yes | Credential alias. |
method | yes | HTTP method. |
url | yes | Target URL (must be a public host — SSRF guard). |
action | no | Canonical plugin.action or a govder action label. Omitted → http.request. |
headers | no | Extra request headers. |
body | no | Request body (JSON). |
query | no | Query params. |
Success (200) returns the upstream status, headers, and body (a string,
after egress scrub):
{ "status": 200, "headers": { "content-type": "application/json" }, "body": "…" }
Approval required (202) — the action did not run; poll the returned
approval_id:
{
"outcome": "pending_approval",
"approval_id": "appr_…",
"message": "This action requires human approval before it runs. … Poll GET /api/v1/approvals/{id} …",
"summary": "http.request on deploy-hook",
"expires_at": "2026-06-20T11:30:00Z"
}
Poll GET /api/v1/approvals/{id} with the same bearer; the action runs at
most once, on the first poll after a human approves. If the serving process
crashes mid-execution, recovery is fail-closed — the action is not silently
re-run; the approval is finalized as outcome unknown and must be re-approved to
retry.
Injection by credential type
Vultrino formats the injected auth from the stored credential type — for example
api_key becomes Authorization: Bearer <key> (or a custom header per the
credential's header_name/header_prefix), basic_auth becomes
Authorization: Basic <base64(user:pass)>, and oauth2 injects (and refreshes)
the access token. The client sends none of these; it only names the alias.
Listing credentials — GET /api/v1/credentials
Metadata only — secrets are never returned. Requires the read permission and
is filtered to the caller's role scope.
{ "credentials": [ { "alias": "github-api", "credential_type": "api_key", "description": "…" } ] }
There is no GET /api/v1/credentials/{alias} route. Credential writes
(POST /api/v1/credentials, DELETE /api/v1/credentials/{id}) are admin-only —
see Admin API.
Other surfaces on the same server
The web process also serves, on the same port:
POST /mcp— the networked MCP transport (JSON-RPC), for remote agent harnesses. See MCP Server.POST /llmand/llm/channels/{channel}/…— the metered LLM proxy: point a harness's modelbase_urlhere so the provider key stays in the vault and token spend is metered. Providers are default-deny (VULTRINO_PROVIDER_*_ENABLED). See the LLM proxy reference.POST /api/v1/workload/exchangeandGET /api/v1/runtime/control— the workload-identity token exchange (a signedvwa_assertion is traded for short-lived use tokens) and its non-consuming liveness lease.
Error responses
JSON API errors are { "error": "message", "code": "machine_code" }. Common:
400 execute_error (policy denied, credential not found, SSRF block, plugin
error), 401 missing_api_key / invalid_api_key / invalid_token,
403 permission_denied / not_admin / token_unusable, 404 *_not_found.
Security
- Bind to localhost and put a TLS-terminating reverse proxy (nginx, Caddy) in front for any network exposure.
- Default-deny policy. A credential matching no policy is denied unless
[enforcement] default_action = "allow". See Policy Configuration. - Egress scrub. Responses are scanned and the credential's own reflected
secret is redacted before return;
[[egress]]rules can block or redact further. See Configuration.
MCP Server
The Model Context Protocol (MCP) server allows AI agents to make authenticated API requests without accessing the actual credentials.
Overview
MCP is a protocol for AI assistants to interact with external tools and services. Vultrino's MCP server provides tools for:
- Listing available credentials (by alias only)
- Making authenticated HTTP requests
- Managing credentials (with proper permissions)
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ AI Agent │────▶│ Vultrino MCP │────▶│ External │
│ (Claude, etc.) │ │ Server │ │ APIs │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │
│ "Use github-api │ Credential never
│ to fetch user" │ exposed to agent
│ │
Starting the MCP Server
export VULTRINO_PASSWORD="your-password"
vultrino mcp
The MCP server uses stdio transport, communicating via stdin/stdout. A remote
harness can instead reach the networked MCP transport at POST /mcp on
vultrino web (JSON-RPC, authenticated by a Bearer vk_/vut_ token).
API Key Authentication
There is no authenticate tool and no session step. Every tool call carries an
api_key argument — a regular API key (vk_…) or a use token (vut_…) —
which Vultrino validates per call and scopes to that principal's role. The
argument is consumed by Vultrino and never forwarded to the target API.
{
"tool": "list_credentials",
"arguments": { "api_key": "vk_your_api_key_here" }
}
Setup:
- Admin creates an API key:
vultrino key create ai-agent --role executor - The agent includes that key as
api_keyin every tool call.
A tool call with a missing or invalid api_key is rejected. A use-token
(vut_) agent sees only the named capabilities its token grants (plus
check_approval), not the generic built-in tools below — those are for a direct
operator holding a vk_ key.
Configuring AI Clients
Claude Desktop
Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"vultrino": {
"command": "/path/to/vultrino",
"args": ["mcp"],
"env": {
"VULTRINO_PASSWORD": "your-password"
}
}
}
}
Claude Code (CLI)
Add to your MCP configuration:
{
"mcpServers": {
"vultrino": {
"command": "vultrino",
"args": ["mcp"],
"env": {
"VULTRINO_PASSWORD": "your-password"
}
}
}
}
Important: The agent must include its api_key (a vk_ key or vut_ use token) in every tool call; there is no separate authenticate step.
Generic MCP Client
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "vultrino",
args: ["mcp"],
env: {
VULTRINO_PASSWORD: process.env.VULTRINO_PASSWORD
}
});
const client = new Client({
name: "my-ai-app",
version: "1.0.0"
});
await client.connect(transport);
// Every tool call carries the api_key (vk_ or vut_) — no separate authenticate step
await client.callTool("list_credentials", {
api_key: process.env.VULTRINO_API_KEY
});
Available Tools
list_credentials
List all available credential aliases.
Input: None
Output:
{
"credentials": [
{
"alias": "github-api",
"type": "api_key",
"description": "GitHub personal access token"
},
{
"alias": "stripe-api",
"type": "api_key",
"description": "Stripe API key"
}
]
}
Example prompt:
"What credentials are available?"
http_request
Make an authenticated HTTP request.
Input:
{
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user",
"headers": {
"Accept": "application/json"
},
"body": null
}
Output:
{
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"login\": \"username\", ...}"
}
Example prompts:
"Use github-api to get my user profile" "Make a POST request to Stripe to create a customer using stripe-api"
get_credential_info
Return metadata (type, description) for one credential — never the secret.
Input:
{
"api_key": "vk_...",
"credential": "github-api"
}
check_approval
Poll an action that was gated for human approval. Once approved, this tool runs
the original action and returns its result; while pending it reports the status
and tells the agent to keep polling. An agent may only poll approvals it created
(same api_key/use token).
Input:
{
"api_key": "vk_...",
"approval_id": "appr_..."
}
Note: Adding or deleting credentials is not exposed as an MCP tool. Use the CLI (
vultrino add/vultrino remove) or the admin JSON API. The built-in MCP tools arelist_credentials,http_request,get_credential_info, andcheck_approval, plus any tools contributed by installed plugins or granted capabilities.
Security Model
Credential Isolation
The MCP server never exposes actual credential values to the AI agent. The agent only sees:
- Credential aliases
- Credential types
- Descriptions
Permission Checks
If RBAC is enabled, the MCP server checks:
- API key validity (from session or configuration)
- Role permissions (read, execute, write, delete)
- Credential scope restrictions
Audit Trail
All MCP tool calls are logged:
2024-01-15T10:30:00Z MCP http_request credential=github-api url=https://api.github.com/user
Tool Descriptions
The MCP server provides rich tool descriptions to help AI agents understand capabilities:
{
"name": "http_request",
"description": "Make an authenticated HTTP request using a stored credential alias; authentication is injected inside the trusted connector.",
"inputSchema": {
"type": "object",
"properties": {
"credential": {
"type": "string",
"description": "Alias of the credential to use for authentication"
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
"description": "HTTP method"
},
"url": {
"type": "string",
"description": "Target URL"
},
"headers": {
"type": "object",
"description": "Additional headers to include"
},
"body": {
"type": "string",
"description": "Request body (for POST, PUT, PATCH)"
}
},
"required": ["credential", "method", "url"]
}
}
Example Conversations
Listing and Using Credentials
User: "What API credentials do I have available?"
AI Agent: calls list_credentials tool
AI Agent: "You have the following credentials available:
github-api- GitHub personal access tokenstripe-api- Stripe API key"
User: "Get my GitHub profile"
AI Agent: calls http_request with credential=github-api
AI Agent: "Your GitHub profile shows you're logged in as 'username' with 50 public repos..."
Making Authenticated Requests
User: "Create a new Stripe customer with email [email protected]"
AI Agent: calls http_request tool
{
"credential": "stripe-api",
"method": "POST",
"url": "https://api.stripe.com/v1/customers",
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "[email protected]"
}
AI Agent: "I've created a new Stripe customer. The customer ID is cus_xxx..."
Troubleshooting
MCP server not starting
- Verify
VULTRINO_PASSWORDis set - Check credentials file exists (
vultrino listshould work) - Look for error messages in stderr
Tool not found
- Ensure you're using the latest Vultrino version
- Verify MCP server started successfully
- Check client configuration
Permission denied
- Verify the API key (if RBAC enabled) has execute permission
- Check credential scope restrictions
- Review audit logs for denial reasons
Connection timeout
- MCP uses stdio transport; ensure no other process is consuming stdin
- Check that Vultrino binary path is correct
- Verify environment variables are passed correctly
Best Practices
For AI Developers
- Don't ask for credentials — Always use aliases, never actual secrets
- Use descriptive aliases — Help the AI understand what each credential is for
- Set up RBAC — Create restricted roles for AI agent access
- Review audit logs — Monitor what requests agents are making
For System Administrators
- Use short-lived credentials — Rotate frequently
- Scope credentials narrowly — Each credential should do one thing
- Enable audit logging — Track all credential usage
- Review agent behavior — Periodically check what the AI is doing
Managing Credentials
This guide covers how to securely store, organize, and use credentials with Vultrino.
Credential Types
Vultrino supports several credential types:
| Type | Use Case | Auth Header Format |
|---|---|---|
api_key | API tokens, bearer tokens | Authorization: Bearer <key> |
basic_auth | Username/password auth | Authorization: Basic <base64> |
oauth2 | OAuth2 flows | Authorization: Bearer <access_token> |
private_key | SSH keys, signing keys | (used for signing, not HTTP) |
Adding Credentials
API Keys
Most common type for SaaS APIs:
# GitHub Personal Access Token
vultrino add --alias github-api --key ghp_xxxxxxxxxxxx
# Stripe API Key
vultrino add --alias stripe-api --key sk_live_xxxxxxxxxxxx
# OpenAI API Key
vultrino add --alias openai --key sk-xxxxxxxxxxxx
# With description
vultrino add --alias anthropic --key sk-ant-xxxx \
--description "Claude API key for production"
Basic Auth
For APIs using username/password:
vultrino add --alias jira-api --type basic_auth \
--username [email protected] \
--password api_token_here
vultrino add --alias jenkins --type basic_auth \
--username admin \
--password jenkins_token
OAuth2 Credentials
For OAuth2 flows with refresh tokens:
vultrino add --alias google-api --type oauth2 \
--client-id "xxx.apps.googleusercontent.com" \
--client-secret "GOCSPX-xxx" \
--refresh-token "1//xxx"
Organizing Credentials
Naming Conventions
Use a consistent naming scheme:
<provider>-<environment>-<scope>
Examples:
github-prod-readonlystripe-test-chargesaws-staging-s3
Using Prefixes
Group related credentials with prefixes:
# All GitHub credentials
vultrino add --alias github-api-readonly --key ghp_read...
vultrino add --alias github-api-admin --key ghp_admin...
# All Stripe credentials
vultrino add --alias stripe-live --key sk_live...
vultrino add --alias stripe-test --key sk_test...
Then restrict access with role scopes:
vultrino role create github-readonly --scopes "github-*" --permissions read,execute
Listing Credentials
Basic List
vultrino list
# Output:
# ID Alias Type Created
# 550e8400-e29b-41d4-a716-446655440000 github-api api_key 2024-01-15
# 6ba7b810-9dad-11d1-80b4-00c04fd430c8 stripe-live api_key 2024-01-16
JSON Output
vultrino list --json | jq '.[] | select(.type == "api_key")'
Via Web UI
Navigate to /credentials in the web interface for a visual list.
Using Credentials
CLI Request
vultrino request github-api https://api.github.com/user
HTTP API
curl -sX POST http://localhost:7879/api/v1/execute \
-H "Authorization: Bearer vk_your_api_key" \
-H "Content-Type: application/json" \
-d '{"credential": "github-api", "method": "GET", "url": "https://api.github.com/user"}'
MCP (AI Agents)
The AI agent uses the credential alias:
AI: "Using github-api to fetch your profile..."
*Makes request without seeing actual token*
Updating Credentials
Currently, to update a credential:
-
Remove the old credential:
vultrino remove github-api -
Add the new one:
vultrino add --alias github-api --key ghp_newtoken
Future versions will support in-place updates.
Deleting Credentials
CLI
vultrino remove old-api-key
# Deleted credential: old-api-key
Web UI
Click the delete button on the credentials list, then confirm.
Security Best Practices
1. Use Descriptive Names
Bad:
vultrino add --alias key1 --key xxx
Good:
vultrino add --alias github-ci-readonly --key xxx \
--description "Read-only token for CI pipeline"
2. Scope Credentials Narrowly
Instead of one admin key:
vultrino add --alias github-admin --key ghp_admin_all_perms
Create scoped credentials:
vultrino add --alias github-repos-read --key ghp_repos_read
vultrino add --alias github-actions-write --key ghp_actions_write
3. Set Expiration Reminders
Track when credentials expire:
vultrino add --alias stripe-api --key sk_live_xxx \
--description "Expires: 2024-12-31"
4. Rotate Regularly
Set up a rotation schedule:
- Generate new credential at source (GitHub, Stripe, etc.)
- Add to Vultrino with new alias
- Test the new credential
- Update applications to use new alias
- Delete old credential
5. Audit Usage
Check what's using each credential:
# View audit logs
tail -f /var/log/vultrino/audit.log | grep github-api
Backup and Recovery
Backup
The credentials file is encrypted. Back it up securely:
cp ~/.vultrino/credentials.enc ~/backup/vultrino-$(date +%Y%m%d).enc
Recovery
To restore:
cp ~/backup/vultrino-20240115.enc ~/.vultrino/credentials.enc
You'll need the same VULTRINO_PASSWORD used when the backup was created.
Export (Not Recommended)
Vultrino intentionally doesn't support exporting credentials in plaintext. This is a security feature, not a limitation.
Troubleshooting
"Credential not found"
- Check the alias is spelled correctly
- Verify with
vultrino list - Credential may have been deleted
"Permission denied"
- Your API key may not have access to this credential
- Check role scopes:
vultrino role list
"Decryption error"
- Wrong
VULTRINO_PASSWORD - Corrupted credentials file
- Restore from backup if needed
"Invalid credential format"
- Check credential type matches the data
- API keys shouldn't have usernames
- Basic auth requires both username and password
Roles & API Keys
Vultrino's Role-Based Access Control (RBAC) system lets you create scoped API keys for different applications, each with specific permissions.
Overview
┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐
│ API Key │────▶│ Role │────▶│ Permissions │
│ vk_xxx │ │ executor │ │ + Credential Scopes│
└─────────────┘ └─────────────┘ └─────────────────────┘
- API Keys — Authenticate applications
- Roles — Define permissions and scopes
- Permissions — What actions are allowed
- Scopes — Which credentials are accessible
Permissions
| Permission | Description |
|---|---|
read | List credentials (metadata only, never secrets) |
write | Create new credentials |
update | Modify existing credentials |
delete | Remove credentials |
execute | Use credentials for authenticated requests |
Predefined Roles
Vultrino includes three predefined roles that are always available:
| Role | Permissions | Use Case |
|---|---|---|
admin | read, write, update, delete, execute | Full administrative access |
executor | read, execute | AI agents and applications (recommended) |
read-only | read | Monitoring and listing credentials only |
These roles cannot be deleted and are available immediately after init.
Creating Custom Roles
Basic Role
# Read-only role (can only list credentials)
vultrino role create readonly --permissions read
# Execute-only role (can use credentials but not manage them)
vultrino role create executor --permissions execute
# Full management role
vultrino role create admin --permissions read,write,update,delete,execute
Scoped Roles
Limit which credentials a role can access using glob patterns:
# Only GitHub credentials
vultrino role create github-user \
--permissions read,execute \
--scopes "github-*"
# Only test credentials (no production)
vultrino role create test-executor \
--permissions execute \
--scopes "*-test,*-staging"
# Multiple specific patterns
vultrino role create payment-processor \
--permissions execute \
--scopes "stripe-*,paypal-*,braintree-*"
With Description
vultrino role create ci-pipeline \
--permissions read,execute \
--scopes "github-ci-*" \
--description "Used by CI/CD pipeline for deployments"
Managing Roles
List Roles
vultrino role list
# Output:
# Name Permissions Scopes
# readonly read (all)
# executor execute (all)
# github-user read,execute github-*
View Role Details
vultrino role get github-user
# Output:
# Name: github-user
# Permissions: read, execute
# Scopes: github-*
# Created: 2024-01-15T10:30:00Z
Delete Role
vultrino role delete old-role
Note: Deleting a role doesn't delete associated API keys, but those keys will no longer work.
Creating API Keys
Basic Key
vultrino key create my-app --role executor
# Output:
# Created API key: vk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
#
# IMPORTANT: Save this key now. It won't be shown again.
With Expiration
# Expires in 30 days
vultrino key create temp-access --role readonly --expires 30d
# Expires in 1 year
vultrino key create annual-key --role executor --expires 1y
# Specific date (ISO format)
vultrino key create project-key --role github-user --expires 2024-12-31
Managing API Keys
List Keys
vultrino key list
# Output:
# Prefix Name Role Expires Last Used
# vk_a1b2... my-app executor never 2024-01-15
# vk_x9y8... temp-access readonly 2024-02-14 2024-01-16
Revoke Key
vultrino key revoke vk_a1b2c3d4
# Revoked API key: vk_a1b2c3d4
Revocation is immediate. Any requests using the key will fail.
Using API Keys
CLI with API Key (No Password Required)
Once vultrino web is running, use CLI commands with just an API key:
# Make request using API key (no VULTRINO_PASSWORD needed)
vultrino --key vk_a1b2c3d4... request github-api https://api.github.com/user
This connects to the running web server's API at http://127.0.0.1:7879.
MCP Server (Per-Request Auth)
Every MCP tool call requires the API key:
{
"tool": "list_credentials",
"arguments": {
"api_key": "vk_a1b2c3d4..."
}
}
{
"tool": "http_request",
"arguments": {
"api_key": "vk_a1b2c3d4...",
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user"
}
}
This allows multiple AI agents to use the same MCP server with different scoped keys.
HTTP API
Include the API key in the Authorization header:
# List credentials
curl -H "Authorization: Bearer vk_a1b2c3d4..." \
http://localhost:7879/api/v1/credentials
# Execute request
curl -X POST http://localhost:7879/api/v1/execute \
-H "Authorization: Bearer vk_a1b2c3d4..." \
-H "Content-Type: application/json" \
-d '{"credential": "github-api", "method": "GET", "url": "https://api.github.com/user"}'
Application Configuration
Store the API key in your application's environment:
# .env
VULTRINO_API_KEY=vk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
import os
import requests
api_key = os.environ["VULTRINO_API_KEY"]
response = requests.post(
"http://vultrino:7879/api/v1/execute",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json={
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user"
}
)
Web UI
Creating Roles
- Navigate to Roles in the sidebar
- Click New Role
- Fill in:
- Name
- Description (optional)
- Select permissions
- Enter credential scopes (comma-separated)
- Click Create Role
Creating API Keys
- Navigate to API Keys in the sidebar
- Click New API Key
- Fill in:
- Name (for identification)
- Select a role
- Expiration date (optional)
- Click Create Key
- Copy the key immediately — it's only shown once
Common Patterns
Multi-Environment Setup
# Production role (limited credentials)
vultrino role create prod-app \
--permissions execute \
--scopes "*-prod"
# Staging role (more credentials)
vultrino role create staging-app \
--permissions read,execute \
--scopes "*-staging,*-test"
# Development role (everything except prod)
vultrino role create dev-app \
--permissions read,write,execute \
--scopes "*-dev,*-staging,*-test"
Per-Service Keys
# Role for payment processing
vultrino role create payment-service \
--permissions execute \
--scopes "stripe-*,paypal-*"
# Create key for the payment service
vultrino key create payment-service-prod --role payment-service
# Role for email service
vultrino role create email-service \
--permissions execute \
--scopes "sendgrid-*,mailgun-*"
# Create key for the email service
vultrino key create email-service-prod --role email-service
CI/CD Pipeline
# Read-only for listing credentials in CI
vultrino role create ci-readonly \
--permissions read \
--scopes "*"
# Execute for deployments
vultrino role create ci-deploy \
--permissions execute \
--scopes "aws-deploy-*,github-ci-*"
# Short-lived keys for CI
vultrino key create ci-read-key --role ci-readonly --expires 7d
vultrino key create ci-deploy-key --role ci-deploy --expires 7d
AI Agent Access
# Limited role for AI agents
vultrino role create ai-agent \
--permissions read,execute \
--scopes "github-api,stripe-test" # Only specific credentials
# Create key for the AI
vultrino key create claude-agent --role ai-agent
Security Best Practices
1. Principle of Least Privilege
Only grant the minimum permissions needed:
# Bad: Full admin access
vultrino role create my-app --permissions read,write,update,delete,execute
# Good: Only what's needed
vultrino role create my-app --permissions execute --scopes "api-needed-*"
2. Use Scopes
Always scope credentials when possible:
# Bad: Access to all credentials
vultrino role create service-role --permissions execute
# Good: Scoped to specific credentials
vultrino role create service-role --permissions execute --scopes "service-*"
3. Set Expiration
Use expiring keys for temporary access:
# Contractor access
vultrino key create contractor-key --role readonly --expires 90d
# CI pipeline (rotate weekly)
vultrino key create ci-key --role ci-deploy --expires 7d
4. Audit Key Usage
Check the audit log for unusual activity:
grep "vk_a1b2" /var/log/vultrino/audit.log
5. Rotate Keys Regularly
Even for long-lived applications, rotate keys periodically:
- Create new key
- Update application configuration
- Verify new key works
- Revoke old key
Troubleshooting
"Permission denied"
- Check the role has the required permission
- Verify the credential matches the role's scopes
- Ensure the API key hasn't expired
"API key not found"
- The key may have been revoked
- Check for typos in the key
- Verify the key was created successfully
"Role not found"
- The role may have been deleted
- Keys without valid roles won't work
- Recreate the role or assign a new role to a new key
Use Tokens
A use token is a narrow, ephemeral grant — the opposite of a durable API key. Where an API key is a long-lived identity scoped by a role, a use token authorizes one kind of action against one credential (or glob), optionally capped to a number of uses and/or a time window. It is designed to be handed to an agent for a single task and then forgotten.
Use tokens are recognized by their vut_ prefix and are presented in exactly the same place as an API key — the api_key tool argument (MCP) or an Authorization: Bearer <token> header (HTTP). The underlying credential secret is not part of that agent-facing schema.
Creating a token
# "POST to the deploy webhook once, in the next 10 minutes"
vultrino token create deploy-once \
--credential deploy-hook --action http.request --uses 1 --expires 10m
# Output: vut_xxxxxxxx... (shown once — copy it now)
The plaintext token is shown once at creation. Vultrino stores only a hash, so it can never be recovered or re-displayed.
Scoping options
| Flag | Meaning |
|---|---|
--credential <glob> | Credential alias or glob the token may use (e.g. deploy-hook, github-*). Required. |
--action <glob> | Restrict to a single action (http.request) or a plugin glob (postgres.*). Omit for any action. |
--uses <N> | Cap total executions. --uses 1 is single-use; omit for unlimited. |
--expires <dur> | Time window: 30m, 24h, 7d. Omit for no expiry. |
--require-approval | Gate every use behind a human decision — see Action Approvals. |
Both scopes are enforced authoritatively in the server at execution time, not just at the edge — a token narrowed to github-* / http.request is rejected for any other credential or action even if a caller tries to use it directly.
The --action scope may also be a govder action label (see
Action Labels); it is
satisfied by either the label or the canonical action it resolves to.
Strictness (V8)
When minting a token via the admin API, a strictness compiles to enforced
settings, so direct and approve-at-checkpoint differ on the wire:
| Strictness | Compiles to |
|---|---|
direct | single-use (max_uses = 1) + require_approval + dual_control — the gated action requires two distinct approvers (M-of-N, M configurable) before it runs; see Action Approvals |
checkpoint | require_approval; does not force single-use — the caller's --uses is preserved (unlimited unless given), so one approval is required per gated action |
direct overrides any max_uses/require_approval the caller passed.
Guarantees
- Fail-closed counting. A use is spent the moment the action runs — before the side effect, and even if the downstream call then errors. A single-use token can never drive two executions.
- Cross-process atomic. The check-and-increment happens under a cross-process file lock on the vault, so the count holds even when the web UI and the MCP server run as separate processes sharing one encrypted store.
- Preflight is free. A not-yet-loaded plugin or invalid parameters are caught before a use is consumed, so a misconfigured call doesn't burn the token.
Managing tokens
vultrino token list # show tokens, their scopes, uses, and status
vultrino token revoke <id> # immediately disable a token
Tokens are also listed and revocable in the Use Tokens page of the web admin UI.
Relationship to approvals
Add --require-approval to make a token's every use pause for a human. The use is not consumed when the approval is opened — only when the approved action actually runs. A use token's pending approvals are bounded so it can never open more outstanding approvals than it has remaining capacity (uses + pending ≤ max_uses). See Action Approvals.
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.
Kill Switches & Halt (V6)
When an agent misbehaves you need to stop it now, not at the end of its run. Vultrino's halt is a layered kill switch with a deliberately-documented set of achievable semantics — it does not pretend to preempt work a harness gives it no way to preempt.
Halting an agent
# Admin API (Permission::Admin). Convergent: a repeat under the same
# Idempotency-Key RE-ASSERTS the halt rather than replaying it.
curl -XPOST https://vultrino.example.com/api/v1/agents/bot-7/halt \
-H "Authorization: Bearer vk_admin_..."
The response summarizes the three legs:
{
"agent_label": "bot-7",
"revoked_tokens": ["vut_..."],
"deny_policy_id": "halt:bot-7",
"policy_active": true,
"in_flight": [ { "session_id": "...", "credential": "...", "action": "...", "started_at": "..." } ],
"callbacks_fired": 1
}
Lift a halt with DELETE /api/v1/agents/{label}/halt (the kill policy is removed; already-revoked tokens stay revoked — revocation is permanent, so mint fresh tokens to resume the agent).
What halt actually does — the three legs
- Revoke the agent's use tokens. Storage-authoritative and re-checked under the vault lock on every gated call, so it takes effect immediately and across processes (web + MCP).
- Install an authoritative per-agent kill policy (
principal_pattern= the halt target). The target is matched against the principal's agent label or its key/token id, so it covers both label-bound use-token agents (/api/v1/agents/refund-bot/halt) and an API-key agent that carries no label (halt it by its key id,/api/v1/agents/vk_<id>/halt). It is a kill policy: unlike an ordinary per-agent Deny, it is evaluated before every other policy, so anallowrule that happens to be ordered first can never let a halted agent slip through. It is persisted and propagates to other processes via the policy refresh. (The halt target must be a literal identifier —*?[]globs are rejected — so a halt can't accidentally deny a whole fleet. The kill policy is a normal stored policy: it is visible in, and removable via, the policy admin API, in addition toDELETE …/halt.)
If the immediate in-process engine reload fails (rare), the halt still returns
200with"policy_active": false: any of the agent's tokens have already been revoked (immediate, cross-process) and the kill policy is persisted, so it activates within the refresh window. The halt is effective within the kill-SLA regardless. (Token revocation matches the target against a token's agent label or its id, so a halt-by-id revokes that token too; an agent that authenticates only by API key has no token to revoke and relies on the kill policy.)
- Fire registered abort callbacks for the agent's in-flight sessions.
Achievable semantics (read this before relying on it)
- Deny-the-next-gated-call is the baseline guarantee, and it is solid: within the kill-SLA, the halted agent's next attempt to use any credential is denied — on every path (MCP and HTTP), on every process. On the process that serves the admin API the kill policy is live immediately; other processes pick it up within the policy-refresh window (a few seconds), while token revocation is immediate everywhere.
- True preemption of in-flight work is only possible where the harness exposes an abort/pause primitive. Vultrino can't reach into an agent runtime it doesn't control. Register a
HaltCallbackfor harnesses that do expose one; without it, an action already mid-flight runs to completion (its result is still subject to egress controls), and the halt takes hold on the next gated call.
The session registry
Vultrino records in-flight gated executions so a halt can report — and a callback can act on — what an agent is doing right now:
curl https://vultrino.example.com/api/v1/sessions -H "Authorization: Bearer vk_admin_..."
The registry is in-memory and per-process (the same model as the rate-limit counters): it resets on restart, and in a web+MCP deployment each process only sees the executions it is running. The cross-process kill legs (token revoke + kill policy) are not subject to this limitation.
Abort callbacks
A harness integration registers a HaltCallback on the server; on halt it is fired with the agent's in-flight sessions, so an integration that can signal the agent runtime (cancel a task, close a session) can preempt rather than wait for the next gated call. With no callback registered, halt is purely deny-next-gated-call plus token revocation.
Workload Identity & Owner Binding (V10)
Vultrino's principal — the thing a policy's principal_pattern matches and an approval's separation-of-duty is computed against — can be a workload identity resolved from an external identity document, and a vk_/vut_ can carry an IdP-resolvable owner so a non-human identity (NHI) maps to a directory identity.
Trust boundary. The SPIFFE and OIDC resolvers parse and validate an already-verified document. The cloud-IAM resolvers are claim adapters — they map a verified token's claims to a principal but do not perform the cloud-specific cryptographic verification (JWKS fetch / cloud SDK), which is wired at deployment. Signature/issuer verification must happen before resolution — these resolvers trust the document they are handed, so the deployment must terminate mTLS / verify the token at the edge and pass the verified document inbound. The cloud-IAM adapters stay integration-time and are not auto-wired inbound.
Wiring it inbound (R6)
Enable inbound resolution with [identity]: a request carrying the configured header (the already transport-verified SVID or OIDC claims) has its principal resolved from that document before policy evaluation — subject becomes the Principal.id a principal_pattern matches, and owner the SoD owner.
[identity]
kind = "spiffe" # spiffe | oidc (the two wireable resolvers)
header = "x-spiffe-verified" # inbound header carrying the verified document
allowed = ["example.org"] # SPIFFE trust domains (or OIDC issuers); empty = any
So a principal_pattern Deny on spiffe://example.org/* blocks any request whose presented SVID is in that trust domain, regardless of which vk_/vut_ carried it. A malformed or untrusted document is logged and ignored (the request falls back to its static vk_/vut_ principal) — a bad document can only fail to refine the principal, never elevate it.
The resolved subject is an additional match dimension, never a replacement. The principal's stable
vk_/vut_id remains the halt / ownership anchor, so a halt keyed on the credential always holds even when a workload identity is presented (it can't be escaped by waving an SVID). To halt by a resolved workload identity itself, push a kill/Deny policy withprincipal_pattern = <the SVID/OIDC subject>through the admin write API —vultrino agent halttargets agent labels / credential ids (which exclude the://in SVID strings), whereas a policyprincipal_patternmatches the resolved subject directly.
Resolving a workload identity
The vultrino::identity module turns an identity document into a WorkloadIdentity { kind, subject, trust_domain, owner }:
| Source | Resolver | subject | trust_domain |
|---|---|---|---|
| SPIFFE/SPIRE SVID | SpiffeResolver | the full spiffe://… ID | the trust domain (optionally allowlisted) |
| Generic OIDC | OidcResolver | the sub claim | the iss (optionally allowlisted); owner from email/preferred_username |
| AWS IAM (Roles Anywhere) | resolve_cloud_iam(AwsIam, …) | the assumed-role arn | aws |
| GCP workload identity | resolve_cloud_iam(GcpWorkload, …) | the service-account email | the iss |
| Entra workload identity | resolve_cloud_iam(EntraWorkload, …) | the oid | the tid (tenant) |
The resolved subject is what a policy principal_pattern matches — so a policy (or a halt) can target a SPIFFE ID, an IAM role ARN, or an OIDC subject, not just a static vk_/vut_ id.
Owner binding
A use token can be bound to a human/directory owner — the OIDC sub / SCIM id of the person accountable for the NHI. (The same owner_identity field exists on API keys for when the key-mint path is extended; today it is settable on use tokens via the admin token-mint.)
# Mint a use token bound to a directory owner (admin API).
curl -XPOST .../api/v1/tokens -H "Authorization: Bearer vk_admin_..." -d '{
"name": "refund-bot", "credential_scope": "pay-*",
"owner_identity": "[email protected]"
}'
The owner flows into the resolved principal and the approval record, and — most importantly — into separation of duty: when an owner is bound, approver ≠ requester's owner is computed against the directory owner (the precise human), not just the agent label. So a human approving an action requested by an NHI they own is flagged (or rejected, with enforce_separation_of_duty). See Action Approvals.
Multi-Tenancy & Per-Team Partition (V11)
A single vultrino can serve multiple teams/tenants, each with its own enforcement posture and credential isolation — so a federated enterprise can run one team in enforce mode while another is observe-only, on the same instance.
Tagging a principal with a tenant
An API key or use token carries an optional tenant. For a use token, set it at the admin mint:
curl -XPOST .../api/v1/tokens -H "Authorization: Bearer vk_admin_..." -d '{
"name": "team-b-bot", "credential_scope": "*", "tenant": "team-b"
}'
The principal's tenant is what selects its enforcement mode and scopes its credential access.
Per-tenant enforcement mode
Configure each tenant's mode under [[tenants]]:
[[tenants]]
id = "team-a"
mode = "enforce" # the default — a policy Deny blocks the action
[[tenants]]
id = "team-b"
mode = "observe" # a policy Deny is recorded + emitted but NOT blocked
enforce(the default, and the mode for any untenanted or unlisted principal — fail-closed): a policyDenyblocks the action as usual.observe: a policyDenyis downgraded to allow — the action runs, a warning is logged, and apolicy.observed_denialevent is emitted to the signed outbox (carrying the credential, action, and what would have happened). This lets a team onboard and watch what would be blocked before flipping toenforce, while other teams enforce on the same vultrino. Note this also downgrades the engine's fail-closedno_policydefault-deny, so in an observe tenant a credential lacking an explicit allow policy is usable (the point of observe-only onboarding) — size the blast radius accordingly.
Observe mode downgrades an authorization-posture denial only. The following are security/financial/abuse boundaries and are not observable-away — they hold even in an observe tenant: cross-tenant isolation (below), use-token scope, RBAC, the dual-control gate, SpendCap / RateLimit resource guards (a credential under a per-action spend cap or a rate cap is never downgraded — those are financial/abuse boundaries, not authorization posture; conservatively, if any policy matching a credential carries a spend/rate rule, observe mode enforces all of that credential's denials), and — critically — a halt / kill switch (a halted agent stays blocked).
Credential isolation
A credential can be tagged to a tenant via its tenant metadata:
vultrino meta set team-a-secret tenant team-a
A principal may only use credentials in its own tenant; an untenanted credential is shared (usable by any tenant). A principal in team-b attempting to use a team-a-tagged credential is denied — regardless of team-b's enforce/observe mode (isolation is a hard boundary, not a policy that observe mode can downgrade).
Approval partitioning
When a gated action opens an approval, the approval is tagged with the opening principal's tenant — so it can be partitioned the same way credentials are. The partition rule is the visible_to_tenant predicate:
- A global view (no acting
tenant) sees every tenant's approval. - An untenanted (shared) approval is visible to every tenant (like an untenanted credential).
- Otherwise a tenant only sees its own approvals —
team-acan never see ateam-bapproval.
Where this is wired today:
- Admin metrics (
GET /api/v1/metrics) scopes its approval counts to the calling admin key'stenant(and echoes thetenant_scopeit applied) — so a tenant-scoped admin key sees only its own (+ shared) approvals in the read-back. - The web admin panel is a global console (the session admin carries no tenant): it lists and decides across all tenants. Per-tenant decision scoping is therefore an API concern — the
visible_to_tenantprimitive is the gate a tenant-scoped decision endpoint uses; the panel itself is intentionally global.
Using with AI Agents
Vultrino enables AI agents to make authenticated API calls without exposing credentials. This guide covers integration patterns and best practices.
Why Vultrino for AI Agents?
AI agents (Claude, GPT, etc.) need to interact with APIs, but:
- Credentials shouldn't be in prompts or context
- Agents shouldn't see actual secrets
- Usage should be auditable
- Access should be restricted and revocable
Vultrino solves this by:
- Storing credentials securely (encrypted at rest)
- Exposing only aliases to agents
- Injecting auth automatically
- Logging all usage
Integration Options
1. MCP Server (Recommended)
The Model Context Protocol provides native AI integration:
vultrino mcp
Pros:
- Native protocol for AI tools
- Rich tool descriptions
- Scoped access via API key authentication
- Best security isolation
Setup: See MCP Server documentation for configuration.
2. HTTP API
For agents that can make HTTP requests:
vultrino web # Start the JSON API + admin UI on 127.0.0.1:7879
The agent POSTs the credential alias and target to the execute endpoint (there is no credential header and no transparent proxy):
POST http://vultrino:7879/api/v1/execute
Authorization: Bearer vk_your_api_key
Content-Type: application/json
{"credential": "github-api", "method": "GET", "url": "https://api.github.com/user"}
Pros:
- Works with any HTTP-capable agent
- Simple integration
- Language agnostic
3. CLI Tool Calls
For agents that can execute shell commands:
vultrino request github-api https://api.github.com/user
Pros:
- Simplest integration
- Works with function calling
- Easy to audit
Setting Up for AI Agents
Step 1: Store Credentials
# Add credentials the AI will need
vultrino add --alias github-api --key ghp_xxx \
--description "GitHub API for repo access"
vultrino add --alias stripe-test --key sk_test_xxx \
--description "Stripe test mode API"
Step 2: Create Restricted Role
# Create a role with limited access
vultrino role create ai-agent \
--permissions read,execute \
--scopes "github-api,stripe-test" \
--description "Limited access for AI agent"
Step 3: Create API Key
vultrino key create claude-key --role ai-agent --expires 30d
# Save the generated key
Step 4: Configure AI Client
For Claude Desktop MCP:
{
"mcpServers": {
"vultrino": {
"command": "/path/to/vultrino",
"args": ["mcp"],
"env": {
"VULTRINO_PASSWORD": "your-password"
}
}
}
}
Step 5: Configure Agent with API Key
Add to your AI agent's system prompt:
When using Vultrino tools, always include your API key in every tool call:
For listing credentials:
{"tool": "list_credentials", "arguments": {"api_key": "vk_your_generated_key_here"}}
For making requests:
{"tool": "http_request", "arguments": {"api_key": "vk_your_generated_key_here", "credential": "...", ...}}
Important: Every tool call requires the api_key parameter. This enables multiple agents to share the same MCP server with different scoped access.
Example Interactions
Listing Available Credentials
User: "What APIs can you access?"
AI: calls list_credentials with api_key
{
"tool": "list_credentials",
"arguments": {"api_key": "vk_..."}
}
AI: "I have access to:
github-api— GitHub API for repo accessstripe-test— Stripe test mode API"
Making Requests
User: "Check my GitHub notifications"
AI: calls http_request with api_key and credential
{
"tool": "http_request",
"arguments": {
"api_key": "vk_...",
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/notifications"
}
}
AI: "You have 3 unread notifications..."
Handling Errors
User: "Access my AWS account"
AI: calls http_request with credential=aws-prod
AI: "I don't have access to AWS credentials. I can only access:
- github-api
- stripe-test
Would you like to add AWS credentials?"
Security Best Practices
1. Scope Credentials Narrowly
Don't give AI access to admin credentials:
# Bad: Full admin access
vultrino add --alias github-admin --key ghp_admin_everything
# Good: Read-only access
vultrino add --alias github-readonly --key ghp_read_only_token
2. Use Test/Sandbox Credentials
For AI experimentation, use test mode:
vultrino add --alias stripe-test --key sk_test_xxx
# Not: stripe-live with sk_live_xxx
3. Set Key Expiration
Short-lived keys limit damage if compromised:
vultrino key create ai-key --role ai-agent --expires 7d
4. Monitor Usage
Check what the AI is doing:
# Watch audit log
tail -f /var/log/vultrino/audit.log
# Filter by credential
grep "github-api" /var/log/vultrino/audit.log
5. Restrict Scopes
Only allow access to specific credentials:
vultrino role create ai-readonly \
--permissions read,execute \
--scopes "github-readonly,stripe-test"
Common Patterns
Read-Only Research Agent
# Create read-only credential
vultrino add --alias github-public --key ghp_public_readonly
# Create restricted role
vultrino role create research-agent \
--permissions read,execute \
--scopes "github-public"
# Create key
vultrino key create research-key --role research-agent
Multi-Service Agent
# Add multiple credentials
vultrino add --alias github-api --key ghp_xxx
vultrino add --alias linear-api --key lin_xxx
vultrino add --alias notion-api --key secret_xxx
# Role with access to all
vultrino role create project-agent \
--permissions read,execute \
--scopes "github-api,linear-api,notion-api"
Development vs Production
# Dev credentials
vultrino add --alias stripe-dev --key sk_test_xxx
vultrino add --alias github-dev --key ghp_dev_xxx
# Dev-only role
vultrino role create ai-dev \
--permissions read,execute \
--scopes "*-dev,*-test"
# This role CANNOT access production credentials
Prompt Engineering Tips
Be Explicit About Available Credentials
System prompt:
You have access to these credentials through Vultrino:
- github-api: Read/write access to company repositories
- jira-api: Read access to project issues
- slack-api: Can post to #engineering channel
Always use these aliases when making API requests.
Never ask for or accept raw API keys.
Guide Credential Usage
When the user asks about GitHub:
1. Use github-api credential
2. Check available endpoints first
3. Prefer read operations over write
When uncertain, list available credentials first.
Handle Errors Gracefully
If a credential is not available or access is denied:
1. Inform the user what credentials you DO have access to
2. Suggest alternatives if possible
3. Never attempt to bypass Vultrino
Troubleshooting
Agent Can't Find Credentials
- Verify credentials exist:
vultrino list - Check role scopes include the credential
- Ensure API key has read permission
Request Fails with 403
- Check role has execute permission
- Verify credential scope matches
- The underlying API may also be denying access
MCP Server Not Responding
- Ensure
VULTRINO_PASSWORDis set - Check Vultrino binary path is correct
- Review stderr for error messages
Audit Log Not Showing Requests
- Audit logging may be disabled
- Check config:
logging.audit_file - Verify file permissions
Monitoring AI Usage
Real-time Monitoring
# Watch all AI requests
tail -f /var/log/vultrino/audit.log | grep ai-agent-key
# Count requests per credential
awk '{print $4}' /var/log/vultrino/audit.log | sort | uniq -c
Usage Reports
Generate daily summaries:
# Requests per credential today
grep $(date +%Y-%m-%d) /var/log/vultrino/audit.log | \
awk '{print $4}' | sort | uniq -c | sort -rn
Alerting
Set up alerts for unusual activity:
- Sudden spike in requests
- Access to unexpected credentials
- Failed authentication attempts
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 |
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 |
Conditions
URL Match
Restrict to specific URLs or patterns:
# Exact match
condition = { url_match = "https://api.github.com/user" }
# Wildcard pattern
condition = { url_match = "https://api.github.com/repos/*" }
# Multiple paths
condition = { url_match = "https://api.github.com/{user,repos,gists}/*" }
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
Restrict to specific hours:
# Business hours only (9 AM - 5 PM)
condition = { time_window = { start = "09:00", end = "17:00" } }
# Night shift (11 PM - 7 AM)
condition = { time_window = { start = "23:00", end = "07:00" } }
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 } }
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 Stripe API
[[policies.rules]]
condition = { url_match = "https://api.stripe.com/*" }
action = "allow"
# Rate limit to prevent abuse
[[policies.rules]]
condition = { rate_limit = { max = 100, window_secs = 60 } }
action = "allow"
Business Hours Only
[[policies]]
name = "sensitive-data-access"
credential_pattern = "database-*"
default_action = "deny"
# Only during business hours
[[policies.rules]]
condition = { time_window = { start = "09:00", end = "18:00" } }
action = "allow"
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
[[policies.rules]]
condition = { method_match = ["GET", "HEAD"] }
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"
# Rate limit all requests
[[policies.rules]]
condition = { rate_limit = { max = 60, window_secs = 60 } }
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
- Rule evaluation — Evaluate rules in order
- Default action — Apply if no rules matched
Rules are evaluated in order. First matching rule determines the action.
Debugging Policies
Verbose Logging
Enable debug logging to see policy evaluation:
RUST_LOG=vultrino=debug vultrino web
Output:
DEBUG vultrino::policy: Evaluating policy "github-readonly" for credential "github-api"
DEBUG vultrino::policy: Rule 1 url_match: matched
DEBUG vultrino::policy: Rule 2 method_match: GET in [GET, HEAD] = true
DEBUG vultrino::policy: Result: allow
Audit Log
Check why requests were denied:
grep "policy_denied" /var/log/vultrino/audit.log
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: enable
debug logging (RUST_LOG=vultrino=debug) and exercise the credential, then check
the evaluation trace and the policy_denied events in the audit trail.
Plugin System
Vultrino supports trusted built-in Rust connectors and sandboxed WASM extensions. They occupy different credential trust boundaries.
PluginRegistry
├── built-in Rust plugin ── receives CredentialData as a trusted injector
└── WASM plugin
├── PluginManifest
└── Wasmtime ABI v2 ─── receives alias + credential type only
WASM ABI v2 boundary
Installed WASM is untrusted. The host sends public action parameters and a non-secret credential handle. It never serializes a vault credential, metadata, or a general-purpose secret map into guest memory. ABI v1 modules fail installation and loading.
WASM plugins can provide public transformations, validation, actions, and MCP tools. They cannot currently perform an operation that needs private credential bytes because no secret-using host capability exists. Such functionality must be implemented as a reviewed built-in connector or wait for a narrow host operation specific to the credential type.
Installed plugins live under the platform data directory's vultrino/plugins/
folder and contain plugin.toml, the declared .wasm module, and
.installed.json. The web server loads them at startup.
The old PGP signing module is an archived ABI v1 rejection fixture, not an available plugin.
Next steps
Installing Plugins
Vultrino plugins can be installed from local paths, git repositories, or archive URLs.
Installation Sources
From Local Path
Install a plugin from a local directory:
vultrino plugin install ./my-plugin
vultrino plugin install /absolute/path/to/plugin
vultrino plugin install ~/plugins/my-plugin
From Git Repository
Install directly from GitHub, GitLab, or other git hosts:
# Latest commit
vultrino plugin install https://github.com/user/vultrino-plugin
# Specific tag or branch
vultrino plugin install https://github.com/user/vultrino-plugin#v1.0.0
vultrino plugin install https://github.com/user/vultrino-plugin#main
From Archive URL
Install from a .tar.gz archive:
vultrino plugin install https://example.com/plugin-1.0.0.tar.gz
Build Process
When installing a plugin with a Cargo.toml, Vultrino automatically:
- Checks for the
wasm32-wasip1target (installs if needed) - Runs
cargo build --release --target wasm32-wasip1 - Copies the built WASM module to the plugin directory
Requirements:
- Rust toolchain installed
rustupavailable in PATH
Managing Plugins
List Installed Plugins
vultrino plugin list
Example output:
Installed plugins:
public-transform v2.0.0
Source: https://github.com/example/public-transform#v2.0.0
Installed: 2024-01-15
MCP tools: normalize_payload
View Plugin Details
vultrino plugin info public-transform
Remove a Plugin
vultrino plugin remove public-transform
Reload a Plugin
Reload a plugin's WASM module without restarting:
vultrino plugin reload public-transform
Plugin Discovery
Plugins can be discovered in the Vultrino plugin registry (coming soon) or by searching GitHub for repositories tagged with vultrino-plugin.
Troubleshooting
Build Fails
If the WASM build fails:
- Ensure Rust is installed:
rustup --version - Check the target:
rustup target list --installed - Install the target manually:
rustup target add wasm32-wasip1
Plugin Not Loading
Vultrino requires the credential-confining WASM ABI v2. ABI v1 modules are rejected because they accepted plaintext credential data. Installation validates the module and ABI before it is copied into the plugin directory.
Check the plugin manifest is valid:
cd ~/.vultrino/plugins/my-plugin
cat plugin.toml
Verify the WASM module exists:
ls -la *.wasm
Hot Reload Not Working
Make sure the Vultrino server has write access to the plugins directory and that the new WASM module compiles successfully.
Developing WASM Plugins
Vultrino loads WASI Preview 1 modules through a credential-confining ABI. A guest is untrusted: it receives public action parameters plus a non-secret credential handle, never vault material.
Current capability boundary
ABI v2 intentionally does not expose a generic “read credential” function. No secret-using host operations exist yet. Therefore a WASM plugin can:
- validate and transform public action parameters;
- use the selected credential's alias and type as non-secret routing context;
- return a public result; and
- define actions and MCP tools in its manifest.
It cannot sign, authenticate, or otherwise operate on private credential bytes. Those features require a narrow host-side capability for the particular operation. Do not work around this by placing secrets in action parameters.
Plugin structure
my-plugin/
├── Cargo.toml
├── plugin.toml
└── src/
└── lib.rs
A manifest may define actions and tools normally. Credential fields can still describe the selected credential, but their values are held only by Vultrino; the guest receives the handle below.
ABI v2
The module must export:
vultrino_plugin_version() -> u32, returning2;vultrino_alloc(size: u32) -> u32;vultrino_free(ptr: u32, len: u32);vultrino_execute(ptr: u32, len: u32) -> u64; and- optionally
vultrino_validate_params(action_ptr, action_len, params_ptr, params_len) -> i32.
The execute request is JSON:
{
"action": "do_something",
"credential_handle": {
"alias": "selected-alias",
"credential_type": "plugin:my-plugin:profile"
},
"parameters": {
"input": "public data"
}
}
There is deliberately no credential, secret map, metadata map, or credential
id. ABI v1 modules that expect a plaintext credential object are rejected at
installation and loading.
The response is JSON:
{
"code": 0,
"data": "public result",
"error": null
}
Nonzero result codes are failures. Guest error text is treated as potentially secret-bearing after dispatch and is replaced with a constant message at the public execution boundary.
Build and test
cargo build --release --target wasm32-wasip1
vultrino plugin install ./my-plugin
Installation compiles source when necessary, verifies that the declared module exists, instantiates it, and checks ABI v2 before copying it into the installed plugin directory. The server loads installed plugins at startup.
Keep plugin results public, validate all parameters, bound memory use, and pin dependencies. A future secret-using plugin API will expose operation-specific host capabilities rather than plaintext credentials.
PGP Signing Plugin (archived)
The repository's PGP module targets the former WASM ABI v1 and is not a deployable plugin. ABI v1 serialized private credential material into guest memory; Vultrino now rejects it during installation and loading.
WASM ABI v2 gives an untrusted guest only the selected credential's alias and type. It has no generic credential-read operation and currently exposes no host-side PGP signing capability. Consequently, PGP signing through an external WASM plugin is unavailable.
The source and binary under plugins/pgp-signing/ are retained only as a
negative compatibility fixture. Do not install them. A future PGP integration
must keep the private key in Vultrino and expose only narrow host operations such
as sign and public_key; restoring plaintext key transfer to WASM is not an
acceptable migration.
SSH Plugin
The ssh plugin lets Vultrino hold an SSH password and drive remote
deployments and commands against a host — without the calling agent ever
seeing the password. It ships as a built-in plugin, so no separate
installation step is needed.
Two actions are exposed:
| Action | What it does |
|---|---|
deploy | rsync a local directory to the remote host over SSH |
run | Execute a sequence of shell commands over SSH |
Both read their inputs from credential metadata by default, so a credential alias can hold the full recipe for a specific server (source path, destination, excludes, command list). Agents then invoke the action against the alias and supply no parameters — which is also how the override-lock security model works.
System requirements
sshpass, ssh, and rsync must be on PATH.
| macOS (Homebrew) | brew install hudochenkov/sshpass/sshpass (and rsync is preinstalled) |
| Debian / Ubuntu | apt install sshpass rsync openssh-client |
| RHEL / Fedora | dnf install sshpass rsync openssh-clients |
The plugin will return a clear "binary not found" error if any of these are missing; it won't silently fall back.
Credential type: ssh_password
Holds the connection info and the password that will be supplied to
sshpass at invocation time.
Fields
| Field | Type | Required | Description |
|---|---|---|---|
host | text | Yes | Hostname or IP of the SSH server |
port | text | No | SSH port (default 22) |
user | text | Yes | SSH username |
password | password | Yes | SSH password (stored encrypted) |
Adding via CLI
vultrino add \
--alias prod-api \
--type ssh_password \
--ssh-host deploy.example.com \
--ssh-user deploy
# prompts for SSH password
Add as many credentials as you have targets — each alias is an independent "instance" with its own host, user, password, and metadata defaults.
Configuring per-credential defaults (metadata)
Metadata is free-form key-value on the credential. The plugin looks up
specific keys to populate action inputs. Set them via the vultrino meta
subcommand:
vultrino meta set prod-api deploy.source_dir /path/to/local/dir/
vultrino meta set prod-api deploy.dest_dir /opt/app/
vultrino meta set prod-api deploy.excludes '[".git",".env","node_modules","dist"]'
vultrino meta list prod-api
vultrino meta unset prod-api deploy.excludes
Deploy keys
| Key | Default | Description |
|---|---|---|
deploy.source_dir | — | Local directory. Trailing / is significant to rsync — see man rsync. |
deploy.dest_dir | — | Remote directory. |
deploy.excludes | [] | JSON array of rsync --exclude patterns. |
deploy.flags | -avz | Rsync flags as a single string. |
deploy.timeout_secs | 1800 (30min) | Kill local rsync if it exceeds this. |
deploy.allow_override | false | If true, callers can override source_dir / dest_dir / excludes / flags in params. |
Run keys
| Key | Default | Description |
|---|---|---|
run.commands | — | JSON array of commands. Each runs in its own SSH invocation. |
run.stop_on_error | false | If true, halt the sequence on the first non-zero exit. |
run.interval_ms | 0 | Milliseconds to sleep between commands. |
run.timeout_secs | 300 (5min) | Per-command timeout. Local ssh is killed on expiry. |
run.allow_override | false | If true, callers can pass a custom commands array in params. |
Shared keys
| Key | Default | Description |
|---|---|---|
ssh.strict_host_key_checking | accept-new | Forwarded to ssh -o StrictHostKeyChecking=…. Sensible choices are accept-new, yes, no. |
Actions
ssh.deploy — rsync a directory
Invoked with zero params, uses metadata defaults:
vultrino action prod-api ssh.deploy
Params (all optional):
| Param | Locked by override? | Description |
|---|---|---|
dry_run | Always allowed | Run rsync --dry-run. Useful for agents to preview. |
source_dir | Locked | Requires deploy.allow_override=true. |
dest_dir | Locked | Requires deploy.allow_override=true. |
excludes | Locked | Requires deploy.allow_override=true. |
flags | Locked | Requires deploy.allow_override=true. |
timeout_secs | Always allowed | Override the configured timeout. |
Response body:
{
"ok": true,
"exit_code": 0,
"stdout": "sending incremental file list\n...",
"stderr": "",
"duration_ms": 4213,
"timed_out": false,
"dry_run": false,
"command_display": "sshpass -e rsync -avz --exclude=.git -e \"ssh -p 22 -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30\" /src/ user@host:/dest/"
}
ssh.run — execute a command sequence
vultrino action prod-api ssh.run
Params (all optional):
| Param | Locked by override? | Description |
|---|---|---|
commands | Locked | JSON array. Requires run.allow_override=true. |
stop_on_error | Always allowed | Halt on first non-zero exit. |
interval_ms | Always allowed | Sleep between commands. |
timeout_secs | Always allowed | Per-command timeout. |
Response body:
{
"ok": true,
"results": [
{
"index": 0,
"command": "uptime",
"ok": true,
"exit_code": 0,
"stdout": " 13:42:17 up 18 days, ...\n",
"stderr": "",
"duration_ms": 742,
"timed_out": false
}
]
}
ok at the top level is true only if every command returned 0 and
none timed out.
Security model
- Password stays out of the agent response. Agents present a credential
alias; the trusted plugin resolves it, decrypts the password, and passes it to
sshpassvia theSSHPASSenvironment variable — never visible inpsoutput, never on disk. - Override-locked by default. An agent cannot pass a custom command
list or target directory unless the credential's metadata explicitly
opts in with
run.allow_override=true/deploy.allow_override=true. This is intentional: a credential is a fixed recipe, and a prompt- injected agent can't turn it into "rm -rf /" without the credential owner's opt-in. - Host key verification.
StrictHostKeyChecking=accept-newis the default — trust on first use, reject on key change. Change it via thessh.strict_host_key_checkingmetadata key if you need stricter or looser behavior. - Timeouts actually kill. Commands that exceed their timeout have
the local ssh/rsync process sent SIGKILL (
tokio::Command::kill_on_drop(true)). The remote side is best-effort — SIGHUP from the closed SSH channel should reach the remote command, but commands that explicitly detach from stdio (nohup,disown, backgrounded processes) can outlive the channel. Response carries atimed_out: boolso callers don't mistake a timeout for a clean exit.
MCP exposure
Both actions are exposed as MCP tools automatically:
| MCP tool name | Action |
|---|---|
ssh_deploy | ssh.deploy |
ssh_run | ssh.run |
Schemas include all params above plus credential and api_key.
Common gotchas
bash: command not found when you know the binary is installed
ssh host "mycommand" gives you a non-interactive, non-login shell.
It doesn't source ~/.bashrc, ~/.zshrc, or (usually) ~/.profile, so
PATH additions installers wrote there aren't present. Workarounds in
rough order of cleanest-first:
- Reference the absolute path:
/root/.nvm/versions/node/v20/bin/node server.js. - Set PATH inline:
PATH=$HOME/.cargo/bin:$PATH my-tool. - Shift the command into an existing session that has the right
environment:
tmux send-keys -t mysession 'my-tool' Enter— this is fire-and-forget (you won't see the command's exit code), so pair with a health probe at the end if correctness matters.
pkill -f kills itself
pkill -f pattern matches against every process's full argv. The pkill
you just ran also contains pattern in its argv, so it'll match — and
the shell running it gets killed, causing your SSH connection to drop
with exit status 255.
Use pkill <name> without -f when matching by process name. If you
really need -f, pick a pattern that can't match pkill itself (e.g.
pkill -f '^/usr/local/bin/myapp').
tmux send-keys doesn't report command results
tmux send-keys returns 0 as soon as the keystrokes are delivered — not
when the command inside tmux finishes. If you need to know the
deployment actually started, append an explicit probe after the
send-keys command, running over ssh proper:
[
"tmux send-keys -t app 'bun install && bun run start' Enter",
"sleep 8 && curl -fsS http://127.0.0.1:3000/health"
]
The final command's exit code gives you real status.
Example: typical deploy + restart flow
For a service running under tmux on a VPS, with the backend process in
a tmux pane named app:
vultrino add \
--alias prod-api \
--type ssh_password \
--ssh-host deploy.example.com \
--ssh-user deploy
vultrino meta set prod-api deploy.source_dir /path/to/local/backend/
vultrino meta set prod-api deploy.dest_dir /srv/app/
vultrino meta set prod-api deploy.excludes \
'[".git",".env","node_modules","dist","tests"]'
vultrino meta set prod-api run.commands '[
"tmux send-keys -t app C-c",
"tmux send-keys -t app C-c",
"sleep 1",
"pkill -9 mybinary || true",
"sleep 1",
"tmux send-keys -t app '\''cd /srv/app && myinstall && myrun'\'' Enter"
]'
# Sanity-check once before relying on it
vultrino action prod-api ssh.deploy -p '{"dry_run": true}'
# Real deploy + restart
vultrino action prod-api ssh.deploy
vultrino action prod-api ssh.run
Multiple targets? Just create more aliases with their own metadata —
staging-api, dr-api, etc. Same plugin, different credentials.
Postgres Plugin
The postgres plugin holds PostgreSQL connection credentials and drives
the two most common "run it from an automation system" operations: SQL
execution (migrations, maintenance) and pg_dump-based backups. The
stored password is absent from the calling agent's schema — Vultrino passes it
to the trusted psql / pg_dump child via the PGPASSWORD environment
variable, then drops its exposed copy. It ships as a built-in plugin;
no separate installation step.
Two actions are exposed:
| Action | What it does |
|---|---|
run_sql | Execute SQL (a raw string or a local .sql file) against the DB |
backup | Run pg_dump and write the output to a local file |
Both read their inputs from credential metadata by default, so a credential alias can hold the full recipe (which script to run, where to put backups). Agents then invoke the action against the alias and supply no parameters — which is how the override-lock security model works.
System requirements
psql and pg_dump must be on PATH. These ship together in the
postgresql-client package.
| macOS (Homebrew) | brew install libpq && brew link --force libpq (or brew install postgresql@16 for the full server) |
| Debian / Ubuntu | apt install postgresql-client |
| RHEL / Fedora | dnf install postgresql |
Pick a client version that is greater than or equal to your server's major version — older clients can fail on dumps from newer servers.
Credential type: postgres
Holds the connection info and password used by both actions.
Fields
| Field | Type | Required | Description |
|---|---|---|---|
host | text | Yes | Hostname or IP of the Postgres server |
port | text | No | Postgres port (default 5432) |
database | text | Yes | Database name |
user | text | Yes | Postgres role / username |
password | password | Yes | Password (stored encrypted; passed via PGPASSWORD) |
sslmode | text | No | libpq sslmode: disable, allow, prefer (default), require, verify-ca, verify-full |
The default sslmode is prefer to match libpq's out-of-the-box
behavior and to avoid hard-failing if the server doesn't advertise TLS.
For production remote databases, set it explicitly to require or
stronger on the credential.
Adding via CLI
vultrino add \
--alias prod-db \
--type postgres \
--pg-host db.example.com \
--pg-database app_production \
--pg-user deploy \
--pg-sslmode require
# prompts for the Postgres password
Add as many credentials as you have targets — each alias is an independent "instance" with its own host, user, password, and metadata defaults.
Configuring per-credential defaults (metadata)
Metadata is free-form key-value on the credential. The plugin looks up
specific keys to populate action inputs. Set them via the vultrino meta
subcommand:
vultrino meta set prod-db run_sql.file /path/to/repo/migrations/apply.sql
vultrino meta set prod-db backup.output_dir /var/backups/postgres/
vultrino meta set prod-db backup.filename_template "{alias}-{date}.sql"
vultrino meta list prod-db
vultrino meta unset prod-db backup.format
run_sql keys
| Key | Default | Description |
|---|---|---|
run_sql.sql | — | Default raw SQL string (mutually exclusive with run_sql.file). |
run_sql.file | — | Default path to a .sql file on the local machine. |
run_sql.transaction | true | Wrap the execution in a single transaction (--single-transaction + ON_ERROR_STOP). |
run_sql.statement_timeout_ms | 0 (off) | Sets Postgres statement_timeout at session start. |
run_sql.timeout_secs | 600 (10min) | Wall-clock timeout for the psql process. Local process is killed on expiry. |
run_sql.allow_override | false | If true, callers can pass a custom sql or file in params. |
backup keys
| Key | Default | Description |
|---|---|---|
backup.output_dir | — | Local directory to write dumps into. Must exist. |
backup.filename_template | {alias}-{timestamp}.sql | Tokens: {alias}, {date} (YYYY-MM-DD), {time} (HH-MM-SS), {timestamp} (unix seconds). |
backup.format | plain | pg_dump format: plain, custom, directory, tar. |
backup.timeout_secs | 1800 (30min) | Wall-clock timeout for pg_dump. |
backup.allow_override | false | If true, callers can pass output_path / format in params. |
Actions
postgres.run_sql — execute SQL
Invoked with zero params, uses metadata defaults:
vultrino action prod-db postgres.run_sql
Params (all optional):
| Param | Locked by override? | Description |
|---|---|---|
sql | Locked | Raw SQL string. Requires run_sql.allow_override=true. |
file | Locked | Path to a local .sql file. Requires run_sql.allow_override=true. |
transaction | Always allowed | Wrap in a single transaction (default: true). |
timeout_secs | Always allowed | Wall-clock timeout override. |
sql and file are mutually exclusive. Raw SQL is fed to psql via
stdin (not -c), so it handles multi-statement scripts, comments, and
\ meta-commands correctly.
Response body:
{
"ok": true,
"exit_code": 0,
"stdout": "BEGIN\nCREATE TABLE\n...",
"stderr": "",
"duration_ms": 213,
"timed_out": false,
"source": "file:/path/to/migrations/apply.sql",
"transaction": true
}
postgres.backup — pg_dump to a local file
vultrino action prod-db postgres.backup
Params (all optional):
| Param | Locked by override? | Description |
|---|---|---|
output_path | Locked | Full output file path. Requires backup.allow_override=true. |
format | Locked | Override the format. Requires backup.allow_override=true. |
timeout_secs | Always allowed | Wall-clock timeout override. |
The destination directory must exist before the call — the plugin
refuses to create it, so a mistyped path fails loudly instead of
dumping into / unexpectedly.
Response body:
{
"ok": true,
"exit_code": 0,
"stdout": "",
"stderr": "",
"duration_ms": 4210,
"timed_out": false,
"output_path": "/var/backups/postgres/prod-db-2026-04-24.sql",
"format": "plain",
"bytes_written": 12345678
}
Security model
- Password stays out of the agent response. Agents present a credential
alias; the trusted plugin decrypts the password and hands it to
psql/pg_dumpvia thePGPASSWORDenv var of the child process — not visible inps, not on disk. - SQL overrides are locked by default. Raw SQL is code. An agent
cannot pass a custom
sqlstring orfilepath unless the credential's metadata explicitly opts in withrun_sql.allow_override=true. With the lock on, the credential is a sealed "run our migration script" operation, and a prompt-injected agent can't turn it intoDROP TABLE usersorCOPY … TO PROGRAM …. - Backup destinations are locked by default. Without the lock, an
agent couldn't redirect a dump to e.g.
/tmp/world-readable/and exfiltrate the DB. The credential controls where dumps go; the agent controls only whether to run one. - TLS by credential, not by caller.
sslmodelives on the credential, not in params. Agents can't downgrade it. - Timeouts actually kill. Both
psqlandpg_dumprun underkill_on_drop(true)+stdin(null), so an expired timeout sends SIGKILL to the local process. Onpsqldisconnect, any in-flight transaction is rolled back server-side. Partial pg_dump files on timeout should be treated as garbage and deleted; the response carriestimed_out: boolso callers can branch cleanly.
MCP exposure
Both actions are exposed as MCP tools automatically:
| MCP tool name | Action |
|---|---|
postgres_run_sql | postgres.run_sql |
postgres_backup | postgres.backup |
Schemas include the params above plus credential and api_key.
Common gotchas
Backup succeeded but file is tiny
If the pg_dump file is only a few hundred bytes of SQL, you almost
certainly got "just the DDL / just the role" — check stderr in the
response for a role permission error. pg_dump silently continues past
many auth problems and returns 0. Run the same user manually to verify
they have SELECT on everything.
"FATAL: no pg_hba.conf entry for host"
The Postgres server doesn't permit connections from your client IP.
This is not a Vultrino problem — fix pg_hba.conf on the server (add a
matching host line) and SELECT pg_reload_conf().
Client / server major version mismatch
pg_dump errors with server version X.Y; pg_dump version W.Z when
the client is older than the server. Install a client matching your
server's major version (e.g. postgresql-client-16 for a 16.x server).
run_sql returns 0 but "nothing happened"
By default the plugin passes --single-transaction and
ON_ERROR_STOP=1, so a failing statement will exit non-zero. But if
you explicitly set run_sql.transaction=false, a failing statement in
the middle of a batch can be skipped and you'll still get exit_code: 0. Leave transactions on unless you really mean to opt out.
Raw SQL mode doesn't support psql client-side meta-commands with args
\i /path/to/other.sql works (it's handled client-side), but things
like \copy TABLE FROM 'file.csv' depend on the client's filesystem.
For file-based migrations, prefer run_sql.file with a path the
Vultrino-side user can read.
Example: migration + nightly backup for a project
# 1. Store the credential once
vultrino add \
--alias prod-db \
--type postgres \
--pg-host db.example.com \
--pg-database app_production \
--pg-user deploy \
--pg-sslmode require
# 2. Configure per-credential defaults
vultrino meta set prod-db run_sql.file /srv/app/migrations/apply.sql
vultrino meta set prod-db backup.output_dir /var/backups/postgres
vultrino meta set prod-db backup.filename_template "{alias}-{date}.sql"
vultrino meta set prod-db backup.format custom
# 3. Apply migrations (agent- or cron-invokable)
vultrino action prod-db postgres.run_sql
# 4. Nightly backup (put this in cron / a scheduler)
vultrino action prod-db postgres.backup
Multiple targets? Just add more aliases — staging-db, analytics-db,
prod-db-reader-only. Same plugin, independent recipes.
A common pattern is to pair this plugin with the SSH
plugin: postgres.backup writes a dump locally, then
ssh.deploy rsyncs the backup directory off-site. Each credential
keeps its own blast radius and its own override-lock posture.
HTTP API Reference
The JSON API served by vultrino web. This page is the task-oriented route
map; the exhaustive, code-verified wire reference (every field, object, enum, and
error code) is docs/dev/API.md — it wins where the two differ.
Base URL & transport
- Default base:
http://127.0.0.1:7879(override withvultrino web --bind). - All JSON routes are under
/api/v1/. - Plaintext HTTP — terminate TLS at a reverse proxy for network exposure.
There is no transparent forwarding proxy and no X-Vultrino-Credential
header: a client names a credential alias in the request body and Vultrino runs
the action for it (see POST /api/v1/execute). vultrino serve does not serve
this API.
Authentication
Authorization: Bearer vk_your_api_key # an API key
Authorization: Bearer vut_your_use_token # a scoped use token
A vut_ prefix is a use token; anything else is validated as an API key. Admin
routes require an API key with the admin permission (use tokens rejected). The
workload-exchange route instead takes a signed vwa_ assertion.
Error bodies are { "error": "message", "code": "machine_code" }. Codes by
status: 400 invalid_* / execute_error; 401 missing_api_key / invalid_api_key / invalid_token; 403 permission_denied / not_admin / not_authorized / token_unusable; 404 *_not_found; 409 *_exists / idempotency_*;
500 storage_error.
Public / authenticated routes
GET /api/v1/health — no auth
{ "status": "ok", "version": "0.1.0" }
POST /api/v1/execute — run an action with a credential (API key or use token)
Vultrino injects the secret, runs the action, scrubs the response, returns it.
The body is flat (not nested under params):
{
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user",
"action": "http.request",
"headers": { "Accept": "application/json" },
"body": null,
"query": {}
}
| Field | Required | Notes |
|---|---|---|
credential | yes | Credential alias. |
method | yes | HTTP method. |
url | yes | Target URL (public host — SSRF guard). |
action | no | Canonical plugin.action or a govder action label. Omitted → http.request. |
headers / body / query | no | Request headers / JSON body / query params. |
200 → { "status", "headers", "body" } (body is a string, post-scrub).
202 → { "outcome": "pending_approval", "approval_id": "appr_…", … } — the
action did not run; poll the approval.
Errors: 401 (bad bearer); 403 token_unusable (revoked/expired/exhausted
token); 400 execute_error (policy denied, credential not found, SSRF block).
GET /api/v1/approvals/{id} — poll & lazily run an approved action
Authenticate with the same bearer that opened the approval. On the first poll
after a human approves, the action runs at most once and the result is
returned. status is one of Pending / Escalated / Approved / Denied /
Expired. Errors: 401; 403 not_authorized / token_revoked;
404 approval_not_found.
GET /api/v1/credentials — list (API key, read)
{ "credentials": [ { "alias": "github-api", "credential_type": "api_key", "description": "…" } ] }
Metadata only — secrets are never returned; filtered to the caller's role scope.
There is no GET /api/v1/credentials/{alias} route.
Admin routes (API key with admin only)
Use tokens are rejected. Mutating routes honor an optional Idempotency-Key.
See Admin API for bodies and semantics.
| Method | Path | Purpose |
|---|---|---|
POST / PUT / DELETE | /api/v1/policies[/{id}] | Manage stored policies (hot-reload). |
POST / DELETE | /api/v1/credentials[/{id}] | Create (write-only secret) / delete by id. |
POST | /api/v1/tokens, /api/v1/tokens/{id}/revoke | Mint / revoke use tokens. |
POST / DELETE | /api/v1/roles[/{id}] | Manage roles. |
POST / DELETE | /api/v1/agents/{label}/halt | Kill / un-kill an agent principal (V6). |
GET | /api/v1/sessions, /api/v1/metrics | In-flight sessions; per-process metrics (V12). |
GET / POST | /api/v1/events[?after=N], /api/v1/events/{seq}/replay | Signed outbox replay + DLQ (V9). |
PUT / DELETE | /api/v1/workload-grants/{agent} | Author / remove exchange grant templates. |
Connector surfaces (same server)
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /mcp | vk_ / vut_ | Networked MCP transport (JSON-RPC). |
POST | /llm, /llm/{*path}, /llm/channels/{channel}[/{*path}] | vk_ / vut_ | Metered LLM proxy (provider gate default-deny; SSE streaming). |
POST | /api/v1/workload/exchange | vwa_ | Trade a signed workload assertion for short-lived use tokens (gated by VULTRINO_WORKLOAD_EXCHANGE_ENABLED). |
GET | /api/v1/runtime/control | vut_ | Non-consuming liveness lease; 409 runtime_cancelled once revoked/expired/halted. |
HTML admin panel (session auth)
Served alongside the API for human operators: /login, /dashboard (/),
/credentials, /roles, /keys, /tokens, /approvals, /audit. Login is
rate-limited; write forms require a CSRF token.
Admin API (runtime config-write)
The admin API lets a control plane (e.g. govder) configure the enforcement plane
at runtime — push policies, mint/revoke tokens, manage roles, and register
credentials — without restarting vultrino. It is served by vultrino web
under /api/v1/ alongside the existing read/execute endpoints.
Authentication
Every admin endpoint requires an API key (vk_…) whose role holds the
admin permission. The predefined admin role has it; grant it to a custom
role with "permissions": ["admin", …]. Use tokens (vut_…) are rejected
outright — admin is an API-key-only capability.
Authorization: Bearer vk_your_admin_key
Responses: 401 (missing/invalid key), 403 (valid key without admin, or a
use token), 400 (invalid body), 404 (no such resource), 409 (duplicate or
an in-flight idempotency key), 201/200 on success.
Idempotency
Mutating endpoints accept an optional Idempotency-Key header. While the first
request is still in flight, a second with the same key gets 409. Keys are
remembered for 24h.
Idempotency-Key: 5f3c…unique-per-logical-request
The key is bound to a hash of the request body: reusing a key with a different
body returns 409 rather than replaying the original response.
A repeat with the same key and body is handled per route class:
- Creates and mints (
POST /policies,POST /capabilities,POST /tokens,POST /approval-tokens,POST /roles,POST /credentials) replay the original response instead of acting again, so a retried token mint never creates a second token. Minted token plaintext is not retained in the idempotency record — a replayed mint returns metadata plus a note (revoke and re-mint if you lost the original response). - Convergent writes (
PUT /policies/{id},PUT /capabilities/{id},PUT /roles/{name},POST /agents/{label}/halt) re-apply the body and return the fresh result. These address a caller-supplied deterministic id, so a second application only converges — and short-circuiting them would let a content-derived key silently leave an in-between (possibly wider) version enforced while reporting success. Each applied repeat emits its ownpolicy.changed/capability.changedoutbox event.
At-least-once on crash. Reserve → operate → record-completion are three separate atomic storage writes, not one transaction. If the process crashes after the operation persists but before completion is recorded, a retry (after the ~60s stale-reservation window) re-runs the operation. Idempotency is exactly-once only absent a mid-operation crash.
Endpoints
Policies
Policies pushed here are merged with the static [[policies]] from
config.toml into the live engine (config policies stay declarative; the API
manages dynamic ones by id). Each write hot-reloads the engine on the web
process synchronously.
Cross-process propagation. Other long-running processes that share the same vault (notably the MCP server) reload policies on a periodic refresh (default 5s), so an admin push reaches them within that window — not instantly. For an immediate kill, revoke the use token instead: token revocation is storage-authoritative and re-checked under the lock on every gated call, so it takes effect on the very next call in every process.
| Method | Path | Body | Result |
|---|---|---|---|
POST | /api/v1/policies | {name, credential_pattern, rules?, default_action, id?} | 201 canonical policy (id generated if omitted) |
PUT | /api/v1/policies/{id} | same | 200 canonical policy (create-or-replace) |
DELETE | /api/v1/policies/{id} | — | 200 {deleted} / 404 |
rules and default_action use the same shape as the config file
(allow / deny / prompt). An invalid credential_pattern glob is rejected
with 400 rather than silently never matching.
Use tokens
| Method | Path | Body | Result |
|---|---|---|---|
POST | /api/v1/tokens | {name, credential_scope, action_scope?, max_uses?, require_approval?, expires_in_secs?} | 201 {token, metadata} — plaintext shown once |
POST | /api/v1/tokens/{id}/revoke | — | 200 {revoked, metadata} / 404 |
Roles
| Method | Path | Body | Result |
|---|---|---|---|
POST | /api/v1/roles | {name, permissions[], credential_scopes?, description?} | 201 role / 409 if the name exists |
DELETE | /api/v1/roles/{id} | — | 200 {deleted} / 404 |
Credentials
Secret material is write-only: it is stored encrypted and never returned by any endpoint (the create response carries metadata only).
| Method | Path | Body | Result |
|---|---|---|---|
POST | /api/v1/credentials | {alias, metadata?, data} | 201 credential metadata / 409 duplicate alias |
DELETE | /api/v1/credentials/{id} | — | 200 {deleted} / 404 |
data is the tagged credential payload, e.g.
{"type":"api_key","key":"…","header_name":"Authorization","header_prefix":"Bearer "}.
Webhooks
PUT /api/v1/config/webhooks (govder approval-callback target + signing key) is
delivered as part of the signed webhook outbox (see the events/outbox
guide), which owns webhook configuration and ordered, replayable delivery.
Deployment note (vault format)
The admin API's stored policies and idempotency records live in the encrypted
vault, whose on-disk format is versioned (STORAGE_VERSION, currently 7). A
newer binary reads an older vault fine, but the first write upgrades the on-disk
format, after which any still-running older binary (a not-yet-upgraded MCP or
CLI process sharing the same vault) is refused the vault entirely. Upgrade all
vultrino processes before issuing admin writes to avoid breaking the
un-upgraded enforcement plane.
Example
# Push an allow policy for github credentials (takes effect immediately).
curl -sX POST http://127.0.0.1:7879/api/v1/policies \
-H "Authorization: Bearer $VULTRINO_ADMIN_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"name":"gh-allow","credential_pattern":"github-*","default_action":"allow"}'
MCP Tools Reference
Complete reference for Vultrino's Model Context Protocol (MCP) tools.
Overview
Vultrino exposes tools through MCP that allow AI agents to:
- List available credentials (
list_credentials) and inspect one (get_credential_info) - Make authenticated HTTP requests (
http_request) - Poll for human approval of gated actions (
check_approval)
Installed plugins and granted capabilities can contribute additional named tools.
Credential writes are not MCP tools (see the note below get_credential_info).
Authentication. Every tool takes an
api_keyargument. It accepts a regular API key (vk_…) or a use token (vut_…). A use token additionally constrains which credential and action the call may use, and how many times. Theapi_keyargument is consumed by Vultrino and is never forwarded to the target API or plugin.
Tool Definitions
list_credentials
List all credentials available to the current session.
Schema:
{
"name": "list_credentials",
"description": "List all available credential aliases. Returns metadata only, never actual secrets.",
"inputSchema": {
"type": "object",
"properties": {
"api_key": { "type": "string", "description": "API key (vk_) or use token (vut_)" },
"pattern": { "type": "string", "description": "Optional glob to filter aliases (e.g. 'github-*')" }
},
"required": ["api_key"]
}
}
Input: None
Output:
{
"credentials": [
{
"alias": "github-api",
"type": "api_key",
"description": "GitHub personal access token"
},
{
"alias": "stripe-test",
"type": "api_key",
"description": "Stripe test mode API key"
}
]
}
Required Permission: read
Example Usage:
User: "What APIs can you access?"
Agent: [calls list_credentials]
Agent: "I have access to 2 credentials: github-api and stripe-test"
http_request
Make an authenticated HTTP request using a stored credential.
Schema:
{
"name": "http_request",
"description": "Make an authenticated HTTP request. Agent input carries only the credential alias; Vultrino injects authentication inside the trusted connector.",
"inputSchema": {
"type": "object",
"properties": {
"api_key": {
"type": "string",
"description": "API key (vk_) or use token (vut_)"
},
"credential": {
"type": "string",
"description": "Alias of the credential to use for authentication"
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
"description": "HTTP method"
},
"url": {
"type": "string",
"description": "Target URL"
},
"headers": {
"type": "object",
"description": "Additional headers to include in the request",
"additionalProperties": { "type": "string" }
},
"body": {
"description": "Request body (for POST, PUT, PATCH requests)"
},
"query": {
"type": "object",
"description": "Query parameters to append to the URL",
"additionalProperties": { "type": "string" }
}
},
"required": ["api_key", "credential", "method", "url"]
}
}
Input:
{
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user",
"headers": {
"Accept": "application/vnd.github.v3+json"
}
}
Output:
{
"status": 200,
"headers": {
"content-type": "application/json; charset=utf-8",
"x-ratelimit-limit": "5000",
"x-ratelimit-remaining": "4999"
},
"body": "{\"login\":\"username\",\"id\":12345,...}"
}
Required Permission: execute
Error Responses:
| Error | Description |
|---|---|
credential_not_found | The specified credential alias doesn't exist |
permission_denied | No permission to use this credential |
policy_denied | Request blocked by policy rules |
upstream_error | Failed to connect to target server |
Example Usage:
User: "Get my GitHub profile"
Agent: [calls http_request with credential=github-api, url=https://api.github.com/user]
Agent: "Your GitHub profile shows you're logged in as 'username' with 42 public repos"
Approval-gated response:
If the credential, use token, or a policy requires human approval, http_request returns an "APPROVAL REQUIRED" message with an approval_id instead of a result — the action has not run. The agent should then poll check_approval (below) with that id.
check_approval
Poll a previously-gated action. Once a human approves it, this tool runs the action and returns the real result. Until then it reports the current status and tells the agent to keep polling. An agent may only check approvals it originally requested (same api_key/use token).
Schema:
{
"name": "check_approval",
"description": "Check the status of an action that required human approval, and retrieve its result once approved.",
"inputSchema": {
"type": "object",
"properties": {
"api_key": { "type": "string", "description": "API key or use token (same one that made the original request)" },
"approval_id": { "type": "string", "description": "The approval id returned by the gated tool call" }
},
"required": ["api_key", "approval_id"]
}
}
Output (still pending):
{
"approval_id": "appr_xxxxxxxx",
"status": "Pending",
"executed": false,
"message": "Awaiting human approval. The action has NOT run. Poll again every ~10-30 seconds."
}
Output (approved and executed):
{
"approval_id": "appr_xxxxxxxx",
"status": "Approved",
"executed": true,
"message": "Approved and executed.",
"result": { "status": 200, "body": "..." }
}
A Denied or Expired status returns a message instructing the agent to stop and not retry. The action runs at most once no matter how many times it is polled.
Required Permission: execute (same as the original request)
get_credential_info
Return metadata about one credential — its type and description. Never exposes the secret value.
Schema:
{
"name": "get_credential_info",
"description": "Get metadata (type, description) for a specific credential. Does not expose the actual secret value.",
"inputSchema": {
"type": "object",
"properties": {
"api_key": { "type": "string", "description": "API key or use token" },
"credential": { "type": "string", "description": "The credential alias or id" }
},
"required": ["api_key", "credential"]
}
}
Required Permission: read
Credential writes are not MCP tools. The stdio/networked MCP server exposes exactly four built-in tools —
list_credentials,http_request,get_credential_info,check_approval— plus any tools contributed by installed plugins or granted capabilities. To add or delete credentials, use the CLI (vultrino add/vultrino remove) or the admin JSON API (POST /api/v1/credentials,DELETE /api/v1/credentials/{id}). There is noadd_credentialordelete_credentialtool.
Permission Requirements
| Tool | Required Permission |
|---|---|
list_credentials | read |
get_credential_info | read |
http_request | execute |
check_approval | execute |
Scope Restrictions
If the API key's role has credential scopes, tools are further restricted:
list_credentials— Only shows credentials matching scope patternsget_credential_info— Only resolves credentials matching scope patternshttp_request— Only works with credentials matching scope patterns
Error Format
All MCP tool errors follow this format:
{
"error": {
"code": "error_code",
"message": "Human-readable error message"
}
}
Usage Patterns
Basic API Call
{
"tool": "http_request",
"arguments": {
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user"
}
}
POST with JSON Body
{
"tool": "http_request",
"arguments": {
"credential": "stripe-api",
"method": "POST",
"url": "https://api.stripe.com/v1/customers",
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "[email protected]&name=Test+User"
}
}
Check Available Credentials First
// Step 1: List what's available
{
"tool": "list_credentials",
"arguments": {}
}
// Step 2: Use a credential
{
"tool": "http_request",
"arguments": {
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/repos/owner/repo"
}
}
Best Practices for AI Agents
1. List First, Then Use
Always check available credentials before attempting to use one:
1. Call list_credentials
2. Verify the needed credential exists
3. Call http_request with the credential
2. Handle Errors Gracefully
When a credential isn't available:
Agent: "I don't have access to AWS credentials. The credentials I can use are:
- github-api (GitHub API)
- stripe-test (Stripe test mode)
Would you like to add AWS credentials?"
3. Use Appropriate Methods
- GET — Fetch data
- POST — Create resources
- PUT — Replace resources
- PATCH — Update resources
- DELETE — Remove resources
4. Include Necessary Headers
Many APIs require specific headers:
{
"headers": {
"Accept": "application/json",
"Content-Type": "application/json"
}
}
5. Parse Response Bodies
The body field is a string. Parse it as appropriate:
- JSON APIs:
JSON.parse(response.body) - XML APIs: Parse as XML
- Plain text: Use directly
Security Notes
- Alias-only schema — Raw credential fields are not present in MCP tool input/output
- All requests are logged — Audit trail of all tool usage
- Policies are enforced — URL and method restrictions apply
- Rate limits apply — Prevent abuse
- Scopes restrict access — Roles limit which credentials are visible
LLM Reference
This section provides documentation optimized for Large Language Models (LLMs) to understand and use Vultrino effectively.
Quick Reference
What is Vultrino?
Vultrino is a credential proxy that allows applications (including AI agents) to make authenticated API requests without seeing the actual credentials.
Key Concept: You use credential aliases (like "github-api"), not actual secrets.
Available Paths
For programmatic access to documentation, these raw markdown paths are available:
| Content | Path |
|---|---|
| Full reference | /llm/full-reference.md |
| Quick start | /getting-started/quickstart.md |
| CLI commands | /components/cli.md |
| HTTP API | /api/http.md |
| MCP tools | /api/mcp-tools.md |
Condensed Reference
Making Authenticated Requests
Via HTTP API (vultrino web, default port 7879):
curl -sX POST http://localhost:7879/api/v1/execute \
-H "Authorization: Bearer vk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"credential": "<alias>", "method": "GET", "url": "https://target-api.com/endpoint"}'
Via MCP (AI Agents):
{
"tool": "http_request",
"arguments": {
"api_key": "vk_your_api_key_here",
"credential": "<alias>",
"method": "GET",
"url": "https://target-api.com/endpoint"
}
}
Via CLI (requires running Vultrino web server):
vultrino --key vk_your_api_key request <alias> https://target-api.com/endpoint
MCP Tools Summary
| Tool | Purpose | Required Permission |
|---|---|---|
list_credentials | List available credentials | read |
http_request | Make authenticated request | execute |
check_approval | Retrieve the result of an action that required human approval | execute |
get_credential_info | Get credential metadata | read |
Important: Every tool call requires an api_key parameter for authentication. This value may be a regular API key (vk_…) or a use token (vut_…) — a single-use or time-scoped grant restricted to one credential/action.
Approval gating: If a request requires human approval, http_request returns an "APPROVAL REQUIRED" message with an approval_id and the action does not run. Poll check_approval with that id (re-sending your api_key) every ~10–30 seconds; once a human approves, it returns the real result. A denied/expired approval tells you to stop — do not retry.
Common Credential Aliases
Typical naming patterns:
github-api— GitHub API tokenstripe-live/stripe-test— Stripe API keysopenai— OpenAI API keyanthropic— Anthropic API keyaws-prod/aws-staging— AWS credentials
For AI Agents
Authentication Model: Every tool call requires your API key. This enables multiple agents to use the same MCP server with different scoped keys.
Step 1: Check Available Credentials
{
"tool": "list_credentials",
"arguments": {
"api_key": "vk_your_api_key_here"
}
}
Response:
{
"credentials": [
{"alias": "github-api", "type": "api_key", "description": "..."},
{"alias": "stripe-test", "type": "api_key", "description": "..."}
]
}
Step 2: Make Request
{
"tool": "http_request",
"arguments": {
"api_key": "vk_your_api_key_here",
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user"
}
}
Response:
{
"status": 200,
"headers": {"content-type": "application/json"},
"body": "{\"login\":\"username\",...}"
}
Step 3: Parse Response
The body field is a JSON string. Parse it to access the data.
Error Handling
If a credential isn't available:
- List what IS available
- Explain to user
- Offer alternatives
Example response to user:
"I don't have access to AWS credentials. I can access: github-api, stripe-test. Would you like to add AWS credentials?"
HTTP API Quick Reference
Base URL http://127.0.0.1:7879 (served by vultrino web). All routes are under
/api/v1/. Authenticate every call with Authorization: Bearer vk_… (API key)
or vut_… (use token). There is no credential header and no transparent proxy.
Execute an action
POST /api/v1/execute
Authorization: Bearer <vk_ or vut_ token>
Content-Type: application/json
{"credential": "<alias>", "method": "GET", "url": "https://api.example.com/endpoint"}
The body is flat; action is optional (defaults to http.request). Optional
headers, body, and query fields are also accepted.
List Credentials
GET /api/v1/credentials
Authorization: Bearer <vultrino-api-key>
Configuration Summary
Environment Variables
VULTRINO_PASSWORD— Storage encryption password (required)VULTRINO_CONFIG— Config file pathRUST_LOG— Log level
Default Ports
7879—vultrino web: HTTP JSON API (/api/v1/…,/mcp,/llm) and the HTML admin UI- stdio —
vultrino mcp: MCP server for local AI agents (no port)
File Locations
~/.local/share/vultrino/credentials.enc— Encrypted vault (default;~/path is configurable)- Config:
--config <path>, else the OS config dir (~/.config/vultrino/config.tomlon Linux)
Security Model
- Credentials encrypted at rest — AES-256-GCM
- Aliases only — Raw credential fields are absent from the LLM-facing schema
- RBAC — Role-based access control via API keys
- Policies — URL/method restrictions
- Audit logging — Track all usage
API Key Authentication
Vultrino uses per-request API key authentication. Include your API key in every tool call:
{
"tool": "list_credentials",
"arguments": {
"api_key": "vk_your_api_key_here"
}
}
This design enables:
- Multiple agents using the same MCP server with different keys
- Scoped access - each key has its own permissions and credential access
- No session state - stateless, secure by default
The API key determines what credentials you can access based on your assigned role:
| Role | Permissions | Use Case |
|---|---|---|
executor | read, execute | AI agents (recommended) |
read-only | read | Listing credentials only |
admin | all | Full administrative access |
Every tool call requires the api_key parameter.
Common Tasks
"List my credentials"
{"tool": "list_credentials", "arguments": {"api_key": "vk_..."}}
"Get my GitHub user info"
{
"tool": "http_request",
"arguments": {
"api_key": "vk_...",
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user"
}
}
"Create a Stripe customer"
{
"tool": "http_request",
"arguments": {
"api_key": "vk_...",
"credential": "stripe-api",
"method": "POST",
"url": "https://api.stripe.com/v1/customers",
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
"body": "[email protected]"
}
}
"List GitHub repos"
{
"tool": "http_request",
"arguments": {
"api_key": "vk_...",
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user/repos"
}
}
Vultrino Complete LLM Reference
This document contains everything an LLM needs to know to use Vultrino effectively.
What is Vultrino?
Vultrino is a credential proxy for the AI era. It allows AI agents and applications to make authenticated API requests without ever seeing the actual credentials.
Core Concept: You reference credentials by alias (e.g., "github-api"), and Vultrino automatically injects the real credential into your request.
MCP Tools
list_credentials
Lists all credentials you have access to.
Input: None required
Output:
{
"credentials": [
{
"alias": "github-api",
"type": "api_key",
"description": "GitHub personal access token"
}
]
}
Permission Required: read
http_request
Makes an authenticated HTTP request.
Input:
{
"credential": "string (required) - credential alias",
"method": "string (required) - GET|POST|PUT|PATCH|DELETE",
"url": "string (required) - target URL",
"headers": "object (optional) - additional headers",
"body": "string (optional) - request body"
}
Output:
{
"status": 200,
"headers": {"content-type": "application/json"},
"body": "string - response body"
}
Permission Required: execute
Example:
{
"credential": "github-api",
"method": "GET",
"url": "https://api.github.com/user"
}
get_credential_info
Returns metadata (type, description) for one credential — never the secret.
Input:
{
"credential": "string (required) - alias or id"
}
Permission Required: read
check_approval
Polls an action that was gated for human approval. Once approved, this tool runs
the original action and returns its result; while pending it says to keep polling.
You may only poll approvals created by the same api_key/use token.
Input:
{
"approval_id": "string (required) - the appr_… id from a gated call"
}
Permission Required: execute
Credential writes are not MCP tools. The stdio MCP server exposes only
list_credentials,http_request,get_credential_info, andcheck_approval(plus any plugin/capability tools). Adding or deleting credentials is done with the CLI (vultrino add/vultrino remove) or the admin JSON API — there is noadd_credential/delete_credentialMCP tool.
Every tool call also takes an api_key argument — a vk_ API key or a vut_
use token — which Vultrino consumes and never forwards to the target.
HTTP API Endpoints
Served by vultrino web at http://127.0.0.1:7879. All routes are under
/api/v1/ and take Authorization: Bearer vk_… or vut_…. There is no
credential header and no transparent proxy. Full wire reference:
docs/dev/API.md.
Execute an action
POST /api/v1/execute
Authorization: Bearer {vk_ or vut_ token}
Content-Type: application/json
{
"credential": "alias",
"method": "GET",
"url": "https://...",
"action": "http.request", // optional; defaults to http.request
"headers": {}, // optional
"body": null, // optional
"query": {} // optional
}
Returns { "status", "headers", "body" } on 200, or a 202 pending-approval
envelope with an approval_id to poll.
List Credentials (metadata only)
GET /api/v1/credentials
Authorization: Bearer {api_key} # requires the `read` permission
Create / Delete Credential (admin API key only)
POST /api/v1/credentials # body: { alias, metadata?, data }
DELETE /api/v1/credentials/{id} # by id, not alias
There is no GET /api/v1/credentials/{alias} route. See
Admin API.
CLI Commands
# Initialize
vultrino init
# Add credential
vultrino add --alias NAME --key SECRET
# List credentials
vultrino list
# Make request (credential alias is the first positional argument)
vultrino request ALIAS URL
# Start the HTTP API + web UI (default 127.0.0.1:7879)
vultrino web
# Start MCP server (stdio) for AI agents
vultrino mcp # or: vultrino serve --mcp
# Manage roles
vultrino role create NAME --permissions read,execute
vultrino role list
vultrino role delete NAME
# Manage API keys
vultrino key create NAME --role ROLE
vultrino key list
vultrino key revoke KEY_PREFIX
Credential Types
api_key
- For API tokens, bearer tokens
- Injected as:
Authorization: Bearer {key}
basic_auth
- For username/password
- Injected as:
Authorization: Basic {base64(user:pass)}
oauth2
- For OAuth2 with refresh tokens
- Handles token refresh automatically
Permissions
| Permission | Description |
|---|---|
| read | List credentials (metadata only) |
| write | Create new credentials |
| update | Modify existing credentials |
| delete | Remove credentials |
| execute | Use credentials for requests |
Error Codes
| Code | Meaning |
|---|---|
| invalid_api_key / invalid_token | Missing or invalid bearer token |
| permission_denied | Role lacks the required permission |
| token_unusable | Use token revoked, expired, or exhausted |
| execute_error | Policy denied, credential not found, SSRF block, or plugin error |
Common Patterns
List then use
1. list_credentials → see what's available
2. http_request → use the appropriate credential
Handle missing credentials
When a credential isn't available:
- List available credentials
- Tell user what's available
- Suggest adding the needed credential
Parse response body
The body field in http_request response is always a string.
Parse it according to the content-type:
application/json→ JSON.parse()text/plain→ use directlytext/html→ use directly
Security Notes
- Never ask for actual secrets - only use aliases
- Credential payload fields are absent - listing endpoints return metadata
- All requests are logged - audit trail exists
- Policies may restrict access - some URLs/methods may be blocked
- Scopes limit visibility - you may not see all credentials
Environment
| Variable | Purpose |
|---|---|
| VULTRINO_PASSWORD | Decryption password (required) |
| VULTRINO_CONFIG | Config file path |
| RUST_LOG | Log level |
| Port | Service |
|---|---|
| 7879 | vultrino web — HTTP JSON API (/api/v1/…, /mcp, /llm) + admin UI |
| stdio | vultrino mcp — MCP server for local AI agents (no port) |
Quick Examples
Get GitHub user:
{"tool": "http_request", "arguments": {"credential": "github-api", "method": "GET", "url": "https://api.github.com/user"}}
Create Stripe customer:
{"tool": "http_request", "arguments": {"credential": "stripe-api", "method": "POST", "url": "https://api.stripe.com/v1/customers", "headers": {"Content-Type": "application/x-www-form-urlencoded"}, "body": "[email protected]"}}
List repos:
{"tool": "http_request", "arguments": {"credential": "github-api", "method": "GET", "url": "https://api.github.com/user/repos"}}
Post to Slack:
{"tool": "http_request", "arguments": {"credential": "slack-webhook", "method": "POST", "url": "https://hooks.slack.com/services/xxx", "headers": {"Content-Type": "application/json"}, "body": "{\"text\":\"Hello!\"}"}}