Scoped tokens & policies
A crucible API key can be scoped to a policy the daemon enforces on every request. A scoped, expiring key is a small, bounded, revocable capability — so handing one to an agent (or exposing a remote daemon) is safe: a leaked or stolen scoped token is worthless beyond its policy.
This is what makes the MCP guardrails a real boundary rather than a convenience: enforcement lives in the authoritative daemon, so an agent that bypasses its MCP server and calls the daemon directly gets exactly the bounded capability it already had.
- Unscoped key (or loopback with no keys): full access — the default.
- Scoped key: the daemon rejects anything outside its policy.
Existing keys are unscoped, so nothing changes until you mint a scoped one.
The workflow
# 1. author a policy (JSON) and check it — same validation the daemon uses
crucible policy validate agent-policy.json
# 2. mint a scoped, expiring key (fails closed if the policy is invalid)
crucible daemon token add --name agent --policy agent-policy.json --ttl 24h
# 3. from the client side, see what a token may actually do
crucible --token crucible_… policy showpolicy validate runs the exact function that gates token add, so a file
that validates here can't be rejected at mint time. Both check statically
(schema, types, patterns); when policy validate can reach a daemon it will
later also verify live facts like profile existence.
Policy schema
A small JSON object. Every field is optional; absent means "no restriction on
that axis", so {} is fully permissive.
| Field | Type | Meaning |
|---|---|---|
operations |
[]string |
allow-list of verbs: create, exec, snapshot, fork, delete, read. Absent/empty = all allowed. |
net_allow_max |
[]string |
egress ceiling (tri-state, below). |
allow_profiles |
[]string |
which rootfs profiles may launch. Absent = any. |
max_sandboxes |
int |
concurrent live sandboxes (0 = unlimited). |
max_fork |
int |
cap on a single fork count (0 = unlimited). |
max_timeout_s |
int |
clamp on every run/exec command timeout (0 = no clamp). |
max_vcpus |
int |
cap on a create's vCPU count (0 = unlimited). |
max_memory_mib |
int |
cap on a create's memory (0 = unlimited). |
net_allow_max is tri-state — the one field with meaningful emptiness:
| Value | Meaning |
|---|---|
| absent | no restriction — the agent may allowlist any public host (the daemon's range-filter still blocks internal/metadata addresses) |
[] (present, empty) |
no network at all — every request's net_allow must be empty |
["pypi.org","*.npmjs.org"] |
a request's net_allow must be a subset (normalized exact match) |
Host patterns use the same syntax as network allowlists (docs/network.md):
exact hostnames or a single leading-label wildcard (*.npmjs.org); bare * is
rejected.
Operations ↔ endpoints ↔ tools
The daemon speaks endpoints; MCP speaks tools. Both map to the same operations, so the MCP server can mirror a policy by advertising only the tools it permits.
| Operation | Endpoints | MCP tools that need it |
|---|---|---|
create |
POST /sandboxes |
run, create_sandbox |
exec |
POST /sandboxes/{id}/exec |
run, exec |
snapshot |
POST /sandboxes/{id}/snapshot |
snapshot |
fork |
POST /snapshots/{id}/fork |
fork |
delete |
`DELETE /sandboxes | snapshots/{id}` |
read |
all GET |
list_sandboxes, inspect_sandbox, list_snapshots, list_profiles |
run creates → execs → deletes in one call, so it needs create+exec+delete
— a token lacking any of those won't be offered run.
Worked examples
A sandboxed code runner — run untrusted code, fetch from PyPI, nothing else:
{
"operations": ["create", "exec", "delete", "read"],
"allow_profiles": ["python-3.12"],
"net_allow_max": ["pypi.org", "*.pythonhosted.org"],
"max_sandboxes": 4,
"max_timeout_s": 120,
"max_vcpus": 2,
"max_memory_mib": 1024
}Air-gapped — compute only, never any network:
{ "operations": ["create", "exec", "delete"], "net_allow_max": [] }Read-only observer — inspect, never mutate:
{ "operations": ["read"] }Enforcement
- The daemon is authoritative. It checks the presenting token's policy on
every request: the operation verb (403 if not allowed), and — on create/exec/
fork — the resource ceilings. Every violation on one request is reported at
once. An expired token is rejected with
401. GET /whoamireturns the effective policy (crucible policy show), so a client can discover exactly what it may do. It carries no operation gate — even a token with an emptyoperationslist can introspect itself.- The MCP server mirrors the policy. At startup it calls
/whoamiand advertises only the tools the policy permits, so an agent never sees a tool it can't use. This is UX only — the daemon still enforces, so a hidden-but-called tool is still rejected. mcp serveflags narrow, never widen. The operator's--max-*/--net-allow-maxonmcp servelayer on top of the token policy: a request must satisfy both. They can only tighten what a scoped token already allows.
Immutability & rotation
A token's policy is immutable — there is no token edit. To change a
policy, rotate: token add a new scoped key, then token revoke the old one.
Limitations
- The local same-user bypass is closed for scoped tokens. A same-user agent that steals its MCP server's key and calls the loopback daemon directly gets only the token's bounded capability — the bypass gains nothing. (An unscoped key still grants full access; scope the keys you expose.)
max_sandboxesis counted per token — each scoped token has its own budget, so two tokens can't consume each other's. (Best-effort: the live count races a concurrent create, which is fine for a single agent.)- Short-lived session tokens (an MCP server exchanging its long-lived key for
a scoped, short-TTL session token at startup) are deferred;
--ttlon the key itself is the expiry mechanism today.
See SECURITY.md for the isolation model and docs/mcp.md for how the MCP server consumes all this.