Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help


title: Security Model tags: [security, network, proxy, namespace, sandbox]

Security Model

Cowboy isolates the agent with several independent mechanisms. None of them relies on the agent behaving correctly: the agent never holds API credentials, its egress is constrained at the network layer, and its shell tools run under a syscall sandbox.

This page is descriptive, not normative. It describes how the mechanisms work. What Cowboy actually guarantees — and, just as importantly, what it does not — is specs/THREAT-MODEL.md, and that document wins wherever the two disagree. The mechanisms below are on by default (next section), but each can be turned off individually; read this page against what your configuration leaves enabled, not against the prose. Each guarantee in the threat model names a flake check that fails when it stops holding.

What is enforced by default

Three of the mechanisms on this page — the credential proxy, the Redis ACL, and the syscall sandbox — used to be independently opt-in and off by default, while every description of them read as though they were active. A deployer could write a page of sheepdog deny rules and get zero enforcement and zero signal.

They are now on by default, which is what this page and the threat model’s guarantees describe:

OptionLinuxDarwin
services.cowboy.secretsProxy.enableonon
services.cowboy.pubsub.redisAcl.enableonoff
services.cowboy.sheepdog.enableonoff

The two darwin exceptions are not a weaker default; they are the absence of an implementation. There is no seccomp on darwin and no Redis ACL support in the darwin module tree, so switching them on there would assert a guarantee nothing delivers. Darwin must not be read as equivalent to the Linux deployment — see N-11.

The credential proxy needs provider secrets at the agenix paths it expects. A host that does not have them yet must turn it off deliberately:

services.cowboy.secretsProxy.enable = false;

Every substrate you turn off emits an eval-time warning naming what stopped holding as a result. The warnings track what is enabled — there is no way to disable one quietly.

Network isolation

On Linux (modules/network.nix) the agent runs in a dedicated network namespace cowboy-ns, connected to the host by a veth pair:

  • host side: 10.200.0.1
  • agent side: 10.200.0.2

Outbound TCP from the namespace is DNAT’d by iptables to the proxy. Non-TCP egress is not simply left to leak past that TCP redirect: a default-drop egress filter allows only loopback, the veth subnet, established replies, and DNS (UDP 53) to the resolver, then drops everything else — so UDP/QUIC (including HTTP/3 on UDP 443, which falls back to proxied TCP) and ICMP cannot bypass the proxy. SSH is not a blanket exception: port 22 leaves the namespace directly only for the hosts listed in secretsProxy.sshDestinations (empty by default); extra direct-egress TCP ports go through secretsProxy.passthroughPorts.

One authority remains outside this boundary and is called out honestly: operator-listed passthrough/SSH destinations (intentional holes).

The host Nix daemon is a second such authority — a fixed-output derivation’s builder gets network access on the host, outside the namespace, so a malicious builder could POST workspace data out unmediated. Because Nix build users (nixbld*) are shared system-wide, this path cannot be firewalled per agent without degrading the operator’s own builds. So agents are denied direct daemon access by default — but note precisely what enforces that, because it is narrower than it looks.

nix.settings.allowed-users cannot express the restriction. It is a list option, so definitions merge by concatenation: cowboy can only ever add users to it, never remove one. (Cowboy used to define it and no longer does — as sole definer its empty list replaced nixpkgs’ ["*"] and denied the daemon to every non-root account on the host.) The only mechanism that denies one agent is the agent-scoped seccomp rule Connect(unix:/nix/var/nix/daemon-socket/socket) in its sheepdog policy.

So allowNixDaemon = false is enforced only when sheepdog is enabled — that is, by default on Linux. With sheepdog.enable = false the setting denies nothing, and the module emits an eval warning saying so. On Darwin there is no sheepdog at all, so it is never enforced there.

An agent that is genuinely denied the daemon cannot manage its own environment implicitly; it still triggers rebuilds through the approval-gated broker (see below). Set services.cowboy.agents.<name>.allowNixDaemon = true to grant direct access to an agent you are content to let reach the shared daemon.

On Darwin (modules/darwin/network.nix) the equivalent is a UID-scoped pf redirect: agent-UID TCP is routed to the loopback proxy, and agent-UID UDP is dropped except DNS (there is no sheepdog on Darwin, so this PF rule is the only non-TCP mediation).

Credential-injecting proxy

The proxy is a mitmproxy addon (proxy/addon.py, class AgentProxy, configured by services.cowboy.secretsProxy). The agent’s requests carry no credentials; the proxy injects them per destination domain.

For each configured route the proxy reads a secret from a file (secret_file), then writes it into the request using the route’s inject_header and template. Default routes inject x-api-key for api.anthropic.com and api.exa.ai, and Authorization: Bearer … for api.openai.com and openrouter.ai. Because the secret lives only on the host side of the proxy, the agent process never sees a real API key. On an HTTP 401 the cached secret is invalidated and re-read from disk on the next request.

When more than one route matches a host, the most specific wins: an exact host route beats a wildcard, and among wildcards the longest matching suffix wins — so a broad *.example.com route can never shadow an exact api.example.com route and inject the wrong key.

Upstream TLS is always verified. The proxy injects real API keys into upstream requests, so it verifies the upstream certificate against the system root store plus any secretsProxy.trustedCa entries (identical on Linux and Darwin). There is no implicit “insecure when no custom CA is set” fallback; secretsProxy.sslInsecure is an explicit, loud, dev-only opt-out that defaults to false.

Egress control (method gating)

When egress_control is enabled, the proxy gates write methods to the allowed_write_domains allowlist; a write to a domain not on the list gets a 403. The gate is fail-closed: only GET, HEAD, OPTIONS, and TRACE are treated as reads and always permitted. Every other method — POST/PUT/ PATCH/DELETE, WebDAV verbs (MKCOL, MOVE, COPY, LOCK, …), and any unknown/custom verb — is treated as a write and must clear the allowlist. So the agent can read freely but cannot mutate external state, via any method, outside the allowlist.

Syscall sandbox (sheepdog)

Shell tool execution is mediated by sheepdog (crates/sheepdog/), a seccomp-notify sandbox. Tool processes run under a seccomp BPF filter that routes security-relevant syscalls (openat, execve, connect, …) to a userspace supervisor (“the gofer”), which checks the resolved path / argv / address against a policy and either performs the operation or returns EACCES. The policy is generated by Nix (modules/lib/policy.nix) and baked into the binary at build time.

Sheepdog is Linux x86_64 only and is not active on Darwin. See Sheepdog policy below and the authoritative contract in crates/sheepdog/POLICY.md.

Approvals

Outbound bridge messages can require human approval before the broker sends them (services.cowboy.bridges.<name>.approval). Approval state is held in Redis with a timeout; an unapproved message is auto-rejected after timeout seconds. See Approvals & Outbox.

Bridges run as the broker

Bridge services (modules/bridges.nix) run as the broker user, a separate trust domain from the agent. Each declaration generates a pub/sub source plus cowboy-<name>-{ping,ingest,outbox} systemd units under cowboy-bridges.target. The agent reaches a bridge only through pub/sub and the approval gate, not by holding the bridge’s credentials.

Bridge units are hardened by default (NoNewPrivileges, ProtectSystem = "strict", ProtectHome, and the kernel/SUIDSGID protections) — an ordinary message bridge only talks to Redis and its provider, so it never needs host privilege. A bridge that genuinely does (the rebuild bridge shells out to sudo nixos-rebuild and writes under the broker’s home) sets services.cowboy.bridges.<name>.privileged = true to opt back out; the default stays locked down so a compromised message bridge cannot escalate or write outside its own state directory.

Per-agent message isolation

With services.cowboy.pubsub.redisAcl enabled, agents are isolated from each other at the message layer (the persona model). Each agent has its own inbox keyspace {agent}:{source}:inbox and an agent_<name> Redis ACL identity scoped to ~<name>:* (its own keyspace, read/write) plus %W~*:outbox (write-only to the shared outboxes — it can reply but cannot read another agent’s pending outbound, nor any approval:* state).

Crucially the agent holds no Redis credential. It reaches Redis only over its own unix socket /run/cowboy/<agent>.sock, owned by that agent’s uid at mode 0600. One redis-auth-proxy is socket-activated off every such socket and injects the right identity based on which socket accepted the connection (its FileDescriptorName) — never SO_PEERCRED or source IP. The filesystem (socket ownership) is what enforces that an agent can reach only its own identity, and the password lives only on the proxy side.

Bridges are on the same ACL, not exempt from it

Bridges route inbound messages to the target agent’s inbox by a channel/guild → agent table. They used to do so holding a single shared identity — user bridge on >PASS ~* &* +@all — in one group-readable env file, which meant every bridge held the entire bus. That is a privilege inversion rather than a convenience: the consult bridge runs an attacker-authored prompt, and with that credential it could read every agent’s routed inbox and HSET the rebuild bridge’s pending approval to approved.

Each bridge now gets bridge_<name>, and its credential lives in /run/cowboy/bridge-<name>-redis.env at mode 0400 owned by that bridge’s own uid — every bridge shares one primary group, so a group-readable file would have undone the uid split. The grant covers:

  • ~<name>:outbox, ~<name>:outbox:*, ~<name>:dead — the stream it consumes plus its own bookkeeping (deliveries, inflight, op_latest, approval_sent). The agents’ %W~*:outbox grant deliberately does not match the :* bookkeeping keys, so an agent cannot forge delivery state.
  • %W~<agent>:<name>:inbox for each agent, and %W~<name>:inbox as the un-routed fallback — write-only, so a bridge cannot read back another agent’s conversation through its own inbox stream.
  • %W~<notify>:outbox if it requires approval — just enough to send the notification.
  • ~approval:<name>_map, its own external-id → approval-id map.
  • ~approval:* only if it creates approvals (approval.required) or resolves them (it is somebody’s approval.notify target). Approval records are uuid-keyed and a uuid is not a pattern, so this is the tightest expressible grant — and consult, holding neither role, gets none of it.

Commands are +@all -@admin -@dangerous +info: every ordinary data command, so a missing verb can’t surface as an unbounded restart loop, minus the server-control surface (ACL, CONFIG, DEBUG, FLUSHALL, KEYS, SHUTDOWN, MIGRATE, REPLICAOF, MONITOR, …) that would let a bridge edit the boundary from inside. security-bridge-acl fails the build if any of this regresses.

This isolation is at the message layer only: agents share one network namespace and one secrets proxy, so provider credentials are common to all agents on a host. That is by design — all agents on a host are the operator’s own, one provider-credential trust domain — not a gap; per-agent provider-key isolation is deliberately not planned (see Per-Agent Isolation).

The journal is a different case, and this page previously got it wrong. Agent users are no longer placed in the systemd-journal group by default. The journal is the union of everything every unit on the host has ever logged — other agents’ sessions, the proxy’s request lines, and whatever a service leaked into its own output — which is a far wider read capability than anything else in an agent’s allow-list, and it is not covered by the “agents are mutually semi-trusted” argument, because it also crosses out of the agent trust domain entirely. Set services.cowboy.agents.<name>.allowJournal = true to grant it deliberately; that also adds Read(/var/log/**) to the agent’s sheepdog policy so the two layers agree. An agent without it loses nothing operational: build and activation output still reaches it through the rebuild bridge, scrubbed.

Credentials must not reach shared logs in the first place. The proxy strips query strings (which carry signed URLs, tokens, and OAuth codes) from its log lines via safe_path (proxy/addon.py); only host and path without the query are logged. Note the scope of that control: it covers lines the proxy writes. It does not cover what other units log, which is why the sudo path was changed to stop carrying secrets across the privilege boundary at all rather than relying on scrubbing.

Sheepdog policy

The policy is rooted on the OCI Runtime Spec v1.2 shape. Syscalls fall into three tiers:

  • BlockedSCMP_ACT_ERRNO(ENOSYS) at the BPF level (the configurable block-list, e.g. bpf, ptrace, mount).
  • MediatedSECCOMP_RET_USER_NOTIF; the gofer inspects arguments and decides. This set is fixed in seccomp.rs.
  • Allowed — everything else, unmediated.

Path-, argv-, and address-aware rules (Read/Edit/Create/Delete/ Bash/Connect) are carried in the document’s rs.cowboy.policy.{deny,allow} annotations, because OCI seccomp can only match scalar syscall arguments, not string paths. Decision precedence is lazy_allow > deny > allow > default-deny.

The OCI-shaped document is currently enforced by sheepdog’s own notify supervisor; making it directly enforceable by a stock runtime (runc/crun) is a deferred upgrade path, not current behavior. See crates/sheepdog/POLICY.md for the full authority matrix and accepted limitations.

Example configuration

services.cowboy.agents.agent = {
  enable = true;
  homeDirectory = "/home/agent";

  # Per-agent policy (sheepdog / seccomp). allow/deny use Claude Code rule
  # syntax; blockedSyscalls names seccomp-blocked syscalls. See
  # modules/options/user.nix for the full schema and defaults.
  sheepdog.deny = [ "Read(/home/agent/.ssh/**)" ];
  sheepdog.allow = [ "Edit(/home/agent/workspace/**)" ];
  sheepdog.blockedSyscalls = [ "ptrace" "bpf" ];

  # Bind extra host paths into the agent (emitted into the OCI config mounts).
  mounts = [ { source = "/srv/data"; destination = "/srv/data"; readOnly = true; } ];
};

services.cowboy.bridges.discord = {
  pkg = pkgs.discord-service;
  env = "/run/agenix/discord-env";
  approval.required = true;
};

File reference

FileRole
modules/network.nixLinux netns cowboy-ns + veth + iptables DNAT
modules/darwin/network.nixDarwin pf UID-scoped redirect
modules/secrets-proxy.nixProxy service + domain mappings
proxy/addon.pymitmproxy addon: injection + method gating
crates/sheepdog/seccomp-notify sandbox
modules/lib/policy.nixgenerates the sheepdog OCI policy
modules/bridges.nixbridge services + approval lifecycle