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:
| Option | Linux | Darwin |
|---|---|---|
services.cowboy.secretsProxy.enable | on | on |
services.cowboy.pubsub.redisAcl.enable | on | off |
services.cowboy.sheepdog.enable | on | off |
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~*:outboxgrant deliberately does not match the:*bookkeeping keys, so an agent cannot forge delivery state.%W~<agent>:<name>:inboxfor each agent, and%W~<name>:inboxas the un-routed fallback — write-only, so a bridge cannot read back another agent’s conversation through its own inbox stream.%W~<notify>:outboxif 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’sapproval.notifytarget). 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:
- Blocked —
SCMP_ACT_ERRNO(ENOSYS)at the BPF level (the configurable block-list, e.g.bpf,ptrace,mount). - Mediated —
SECCOMP_RET_USER_NOTIF; the gofer inspects arguments and decides. This set is fixed inseccomp.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
| File | Role |
|---|---|
modules/network.nix | Linux netns cowboy-ns + veth + iptables DNAT |
modules/darwin/network.nix | Darwin pf UID-scoped redirect |
modules/secrets-proxy.nix | Proxy service + domain mappings |
proxy/addon.py | mitmproxy addon: injection + method gating |
crates/sheepdog/ | seccomp-notify sandbox |
modules/lib/policy.nix | generates the sheepdog OCI policy |
modules/bridges.nix | bridge services + approval lifecycle |