security

arizuko › security

arizuko treats every agent run as untrusted code. Three controls are enforced today: a per-group egress allowlist at an HTTP/HTTPS proxy on an internal: true network, OAuth-gated identity stamped at the edge with an ES256 transit bearer, and a credential broker that keeps per-user capability tokens out of the container. One control is built but not yet wired into the agent spawn: the DNS resolver that returns NXDOMAIN off the allowlist (crackbox side ships; arizuko-side --dns plumbing is follow-up). 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/11/12-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 signed by CHANNEL_SECRET, 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. crackbox — the box the agent runs in

crackbox is the containment boundary for an agent run, not a proxy the agent happens to call. Every agent container attaches to a per-group Docker network created with internal: true — no default route to the internet — and the only path out is the crackbox forward proxy, set as HTTPS_PROXY in the container's environment at spawn. The agent runs inside that jail: a compromised turn reaches the allowlist and nothing else. Non-cooperating clients fail closed — a process that ignores HTTPS_PROXY finds the network unrouted. (The stronger box — the agent inside a crackbox-spawned KVM micro-VM rather than a shared-kernel container — is the next phase, § 7; today the box is container + internal network + the egress proxy below.)

Connections to non-allowlisted hosts are denied silently at the proxy. Connections to allowlisted hosts are CONNECT-tunneled (HTTPS) or forwarded (HTTP). The default seed is anthropic.com and api.anthropic.com (store/migrations/0037-network-rules.sql). Operators extend per folder:

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

Allowlist resolution walks the folder ancestry and dedupes (store/network.go ResolveAllowlist). The proxy itself, packaging, and CLI live in the crackbox component page; the same daemon ships standalone for non-arizuko uses (CI scripts, vendor binaries).

3. Secrets never enter the container

Three credential types live in the secrets table, each with its own rule (specs/5/14-credential-model.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. Folder secrets inject as container env at spawn (container/runner.go resolves the folder's rows and merges them into the run's env); a user's own capability key overrides the folder default. Capability credentials resolved for a REST or MCP connector call are brokered on the host and never enter 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.

4. DNS filtering — defense in depth

The proxy bundles a UDP/53 listener. The container's resolver points at it via docker create --dns <proxy-ip>. Per query:

The HTTP/CONNECT enforcement is still the primary gate; DNS is additive. If a client somehow ignores both the proxy env vars and resolv.conf, the internal: true Docker network ends the conversation. Spec: specs/11/15-crackbox-dns-filter.md. Implementation: crackbox/pkg/dns/.

Status: crackbox-side DNS server shipped. arizuko-side container plumbing (passing --dns with the resolved per-folder crackbox IP) tracked as a follow-up under spec 9/10.

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 aggregated into PROXYD_ROUTES_JSON at compose generation (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, authenticated by Authorization: Bearer $CHANNEL_SECRET (chanlib/chanlib.go). 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.

Operations spawning new state, mutating ACLs, or reading audit data are operator-only (tier 0). Operations scoped to a group's own files, members, and tasks are inner-group (tier 1+). The per-daemon ownership table is in specs/5/5-uniform-mcp-rest.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 ([[ext]]) join the same surface, gated on ext:<service>:<operation>.

7. What we don't do

8. Dig deeper