security

arizuko › security

arizuko treats every agent run as untrusted code. Three controls are enforced today: a per-folder egress allowlist at a forward proxy started fresh for each spawn on an internal: true network, OAuth-gated identity stamped at the edge with an ES256 transit bearer, and a credential broker that keeps every capability token out of the container. The sections below mark each one shipped or planned.

1. Threat model

The agent is the threat surface. Claude Code runs with bypassPermissions inside its container and can shell out, fetch URLs, run generated scripts — under normal operation. Under prompt injection, RCE in a tool call, or an adversarial skill, that same scope is what the attacker gets. The platform assumes this and enforces around it.

The primary threat is cross-tenant: a compromised agent in one group reaching another group's files, secrets, messages, or network. arizuko closes that path structurally — the group is the container boundary. Each group runs in its own Docker container with its own bind mounts (groups/<folder>/ at /home/node/), its own internal: true network, its own DB view through the per-folder MCP socket, and its own egress allowlist. solo/inbox and corp/eng see none of each other — not because policy says so, but because they are separate processes on separate networks with separate mount sets. bypassPermissions inside the container doesn't change that: there is no path from the agent's shell to another group's data.

The container itself is already per-turn fresh: each agent invocation runs in its own docker run --rm container that reads one turn from stdin and exits, so no compromised process survives into the next turn. What persists across turns is the group folder under /home/node/ — conversation history, diary, skills, and the Claude Code session jsonl — re-loaded each turn so context accumulates. That persisted state, not a long-lived process, is the same-group cross-turn surface: a compromised turn could poison files the next turn reads. Per-group isolation removes the cross-tenant risk; a KVM micro-VM per turn (stronger isolation of the kernel and the persisted state) is the stricter posture for that less-common same-group threat, researched under specs/6/9-crackbox-sandboxing.md as opt-in, not default.

The system surface (operator, split daemons, store) sits outside the agent boundary. It trusts the operator, the chat-platform adapter ingress authenticated by a rotating ES256 service:<adapter> token, and the unified acl + acl_membership tables resolved through auth.Authorize. Inbound web traffic is OAuth-gated at proxyd; proxyd proves the channel to each backend with an ES256 service:proxyd bearer and then trust-stamps the X-User-* headers, verified by every backend via auth/middleware.go.

The full trust-zone diagram and per-boundary mechanism table is in SECURITY.md in the repo. Per-daemon files ship next to the source when a daemon's threat model outgrows a row (e.g. ipc/SECURITY.md for the MCP socket and SO_PEERCRED check).

2. egress — one proxy per agent spawn

Every agent container attaches to a per-folder Docker network created with internal: true — no default route to the internet. runed starts one egress forward proxy for that spawn, with the folder’s allowlist baked into the process, attaches it to both the internal network and the uplink, waits for its /health, and only then starts the agent with HTTPS_PROXY pointing at it. The proxy dies with the turn.

The proxy filters; the missing route confines. A process that ignores HTTPS_PROXY and opens a raw socket finds the network unrouted — there is nowhere for the packet to go. That is why the agent must never hold a second interface and must never share the proxy’s namespace (--network container:): the proxy is dual-homed by necessity, so its namespace carries the route out. Measured on a live docker host, a container sharing a proxy’s namespace fetched an unlisted host directly.

Each proxy serves exactly one address — that spawn’s — and answers 403 to every other peer, including the folder’s other spawns on the same network. Without the pin, a plain spawn could send its CONNECT to a concurrent elevated turn’s proxy and inherit an unrestricted allowlist. A proxy that fails to start or never reports healthy fails the spawn; there is no fallback to an unfiltered container.

Connections to allowlisted hosts are CONNECT-tunnelled (HTTPS) or forwarded (HTTP), never decrypted. Everything else gets 403. The default seed is anthropic.com and api.anthropic.com (routd/migrations/0005-network-rules.sql). Operators extend per folder:

arizuko network <instance> allow <folder> api.github.com

Allowlist resolution walks the folder ancestry and dedupes (routd/network.go ResolveAllowlist). The wildcard * is appended only for a folder holding the egress grant or an elevated /root turn. Setting EGRESS_IMAGE is the whole switch; leave it empty and spawns run unfiltered. The proxy, its flags and its standalone use live on the egress component page.

3. Secrets never enter the container

Three credential types live in the secrets table, each with its own rule (specs/5/14-credentials.md):

The agent invokes a tool by name; for a brokered call the host process resolves the keys, makes the outbound HTTP, and hands back only the response. Its own scoped secret wins over the folder default (caller's sub beats operator-managed team keys).

At rest: the secrets table stores values encrypted (AES-256-GCM, required SECRETS_KEY; see SECRETS_KEY). The surrounding rows (scope_kind ∈ {folder, user}, schema: store/migrations/0034-secrets.sql) live under /srv/data/<instance>/ like everything else, trusting disk + FS perms.

Status today: shipped. The container env carries model credentials only — the four env-profile keys from the host .env, with the triggering user's own rows for those same keys overlaid on top (routd.DB.EnvProfileSecrets narrows the set, container/runner.go merges it). Every capability credential, folder-scoped or user-scoped, is brokered on the host at call time and never enters the container. Values encrypted at rest (AES-256-GCM, required SECRETS_KEY). Write surfaces: /dash/me/env for model keys, /dash/me/secrets for capability tokens, the arizuko secret CLI for folder scope. With the litellm package installed, the model credential stays out of the container too: the gateway holds it and the spawn gets MODEL_GATEWAY_KEY, a virtual key worth only the gateway’s upstream access — one per instance, since per-spawn keys need the Postgres arizuko does not run.

4. No DNS filter

An earlier design bundled a UDP/53 listener that answered NXDOMAIN for anything off the allowlist. It was deleted with the shared proxy (specs/6/24). It was never wired into the container spawn, and a single-tenant proxy already sees every destination in the CONNECT line — a rule expressed twice is a rule that can disagree with itself. The 403 in egress/ is the one enforced path, and the internal: true network is what ends the conversation for a client that ignores the proxy entirely.

5. proxyd — auth at the edge

Every request from outside the docker network passes through proxyd. It terminates TLS, verifies the OAuth-issued JWT (refresh-token cookie fallback), strips any client-supplied identity headers, then re-stamps its own and attaches a transit bearer:

X-User-Sub:    google:alice
X-User-Name:   Alice
X-User-Groups: solo/inbox,corp/eng/sre
Authorization: Bearer <ES256 service:proxyd token>

The bearer is an authd-minted ES256 token whose subject is pinned to service:proxyd. Every backend verifies it via auth/middleware.goProxydTransit checks the signature, expiry, issuer, and that subject pin against authd's public JWKS. Only on that proof does a backend trust the stamped X-User-* headers. There is no per-request HMAC signature; the transit bearer is what makes a request a genuine “came through proxyd” call. Without the subject pin, any holder of a valid authd token reaching a backend directly could forge X-User-* and be treated as that user — covered in SECURITY.md § Identity header trust.

One OAuth login covers GitHub, Google, Discord, and Telegram — account linking unifies them under one auth_users.sub. Per-route auth modes (public / user / operator) are declared per-daemon in service config and written into proxyd_routes by arizuko generate (specs/5/7-proxyd-standalone.md). Public paths today: /pub/*, /health, /chat/* and /hook/* (route-token bearer, per-token rate bucket).

Adapter ingress is a separate trust boundary — channel adapters reach routd over the internal docker network only. Each adapter exchanges a bootstrap secret, AUTHD_SERVICE_KEY, for a service:<adapter> ES256 token that rotates roughly hourly (chanlib/chanlib.go), and every channel-registration call to routd carries that token as the bearer. routd's channel gate verifies it against authd's JWKS and requires a service: subject prefix (routd/channels.go channelAuth); with no verifier configured (local dev) the gate is open. On first registration, routd pins a channel name to the (source IP, verified subject) pair, not to the token itself — the token rotates, so pinning the raw bearer would reject every legitimate re-register after a refresh. proxyd does not see channel traffic; channels do not see web traffic.

6. Configuration surface — REST + MCP

The destination: every security configuration (secrets, allowlists, grants, routes, invites) is reachable two ways, sharing one handler per resource.

Nothing is granted by position in the folder tree. A folder starts with the messaging verbs and nothing else; operations that spawn new state, mutate ACLs, or read audit data require a row somebody delegated, and that row's scope glob is the boundary it may act inside. An action with no matching row is refused — there is no fallback that quietly grants it. The per-daemon ownership table is in specs/5/17-openapi-mcp.md.

Status: shipped and uniform. Each daemon exposes its resources over both surfaces from one hand-written handler — REST (/v1/<resource>, OAuth-gated through proxyd) and MCP (per-group unix socket). The MCP tools/list a session sees is filtered by grants: a tool registers only when the folder is granted its scope, so an agent sees exactly what it may call. External REST tools ([[connector]], transport = "rest") join the same surface, gated on ext:<service>:<operation>.

7. What we don't do

8. Dig deeper