Per-Agent Isolation
Status: substrate A implemented (per-agent message-layer isolation). Substrate B (per-agent network namespaces / provider keys) is deliberately not planned — see “Why substrate B is not needed” below. This turned multi-agent from “one shared trust domain” into per-agent isolation at the message layer, which is the layer that matters here.
Implemented behind services.cowboy.pubsub.redisAcl.enable: each agent has its
own inbox keyspace, its own agent_<name> ACL identity, and reaches Redis only
through its own 0600 unix socket (holding no credential); the host-side bridge
routes inbound mail per agent. Eval/reasoning-verified — the socket-activation
and ACL behaviour still wants runtime confirmation on a real NixOS host.
Problem
Today multi-agent is honestly documented as one trust domain
(services.cowboy.mode): all enabled agents share one network namespace, one
secrets proxy, and one Redis with a single agent ACL identity. The pubsub
keyspace is per-source, not per-agent — bridges write discord:inbox, and
every agent reads it under the same consumer group (cowboy). Redis hands
each stream entry to exactly one consumer, so two agents on discord:inbox
load-balance the channel: a message destined for alice can be consumed by
bob. That is correct for an interchangeable worker pool, but wrong for distinct
agents (personas).
Two audit findings live here:
- N user-level Redis instances on port 6379 in the no-proxy/no-bridges
fallback (
modules/pubsub.nix) → port contention in multi mode. - The ACL cannot be finer-grained than the data model: with all sources mapped to all agents, there is nothing per-agent to scope a key to.
Target model
Agents become distinct personas, each owning its own keyspace:
| Concern | Today | Target |
|---|---|---|
| Inbox key | discord:inbox (shared) | alice:discord:inbox (alice only) |
| Outbox key | discord:outbox (shared) | alice:discord:outbox, bridge fans in |
| ACL identity | one agent, ~*:inbox | agent_alice, ~alice:* |
| Agent creds | none (proxy injects) | none (per-agent proxy injects) |
sources.json | identical to all agents | per-agent stream names |
| Routing | none | channel/guild → agent, bridge-side |
The trust direction holds because the bridge runs host-side as the
broker/infra user, outside any agent boundary, with the full-access bridge
ACL identity. It fans messages in (routes inbound to the right agent’s inbox)
and out (reads every agent’s outbox), while each agent’s scoped identity sees
only its own lane.
Inbound: shared bridge, route by channel
One external identity (one discord bot). The bridge consults a
channel/guild → agent routing table and writes {agent}:{source}:inbox
instead of {source}:inbox. The routing chokepoint is IngestService.publish
(pkgs/bridge/ingest.py), which today writes the single self.stream.
Outbound: fan-in
For scoped ACLs to hold, alice writes alice:…:outbox (she cannot write a
shared discord:outbox under ~alice:*). So the outbox service
(pkgs/bridge/base.py) must read across all per-agent outbox streams and
merge — the asymmetry to inbound’s one-to-one routing.
Identity: per-agent proxy on per-agent unix sockets (substrate A)
The agent must hold zero Redis credentials — consistent with how the HTTP
mitmproxy already injects provider keys by topology. A per-agent proxy only
enforces this if an agent cannot reach another agent’s proxy. On the current
single shared cowboy-ns, TCP ports do not enforce that (alice can dial bob’s
port). The enforceable substrate on today’s shared-host/multi-uid model is the
filesystem:
- One
redis-auth-proxyprocess listens on per-agent unix sockets/run/cowboy/<agent>.sock, owned by that agent’s uid. (N processes only if each should hold just its own password, for blast-radius compartmentalization.) - Identity is determined by which socket accepted the connection → inject
agent_<name>. NoSO_PEERCRED(unreliable across user namespaces), no source-IP map. redis-clispeaks-s <sock>; the harnesscli_prefix()(crates/core/src/pubsub/redis.rs) swaps-h/-pfor-s.
The unix socket is a non-exfiltrable capability — there is no credential the agent can copy out. The same mechanism spans shared-host and container deployments; only the substrate that scopes the socket to one agent changes:
| Deployment | Enforcement |
|---|---|
| Shared host, multi-uid | socket mode 0600 owned by the agent’s uid |
| Containers | host bind-mounts only that agent’s socket into the container (mount-namespace scoping) |
In containers the bind-mount is the real guard, not the uid bits: user-namespace remapping means the in-container uid need not equal the host uid that owns the socket. A further benefit there — Redis stays entirely off the container network (unix socket, no TCP), so there is nothing to firewall. This ties into the container-proxy direction (proxy on by default, credential injection).
This was chosen over two alternatives:
- Scoped key held by the agent — simplest, works on shared netns with no proxy, but makes Redis the one credential the agent holds (exfiltratable, asymmetric with the HTTP model). Rejected for inconsistency.
- Per-agent network namespace (substrate B) — the container-grade endgame. It additionally gives per-agent provider keys (today one mitmproxy injects the same provider creds for all agents — the real shared trust domain). Out of scope here; substrate A delivers per-agent Redis identity on the current topology without the netns project, and B remains the upgrade path.
Implementation slices (all landed)
-
Fix the fallback (audit Finding 4). ✅ Always run the system
cowboy-rediswhenbackend == redis; delete the per-agent user-level fallback; re-point Redis’swantedBy/partOfto the always-presentcowboy.target. Guarded bychecks.nixos-multiagent-eval. -
Per-agent keyspace + ACL + sockets. ✅ ACL gen emits
agent_<name>(~<name>:*+%W~*:outbox) with per-agent password files. Oneredis-auth-proxysocket-activated off per-agent0600sockets injects the identity byFileDescriptorName. Per-agentsources.jsonuses{agent}:{source}:inboxoverredis+unix://the socket; harnesscli_prefix()speaks-s. -
Bridge routing. ✅
routes/defaultAgentoptions;route_inboxsends inbound to{agent}:{source}:inbox; discord + email ingests and theIngestServicebase route per message. The outbox stays shared — agents get write-only (%W) access and the bridge drains it — rather than the originally-sketched per-agent-outbox fan-in (simpler, same isolation: an agent can send but cannot read others’ pending outbound).Agent-initiated responders (consult, rebuild) follow the same rule: the request carries the originating agent (
agent/userfield, from$USERon its own socket) and the service writes its reply to{agent}:{source}:inbox, which~<name>:*already covers. This replaced a briefly-shipped%R~*:inboxgrant that let every agent read every other agent’s routed inbox (SLOP audit Finding 2); the wildcard is gone and no agent canXRANGE/XREADGROUPanother persona’s inbox. -
Cleanup + contract. ✅ The socket-activated per-agent proxy replaced the single shared-identity TCP proxy.
mode = "multi"(modules/options/user.nix), this doc, and the SLOP audit’s Finding 4 are updated to the layered-isolation reality.
Why substrate B is not needed
Substrate B would give each agent its own network namespace and therefore its own mitmproxy with per-agent provider credentials. We are not doing this.
All agents on a host are the operator’s own agents, billed to the operator’s own provider accounts — they are one provider-credential trust domain by design. Isolating provider keys per agent would add real complexity (N namespaces, N proxies, IP allocation, the systemd fan-out) to defend against a threat that does not exist in this deployment: there is no adversarial tenant whose access to a shared key would matter. The isolation that does matter — one agent reading or spoofing another’s mail — is exactly what substrate A delivers at the message layer.
If the model ever changes to running mutually-untrusted agents on one host (true multi-tenant), revisit this: per-agent netns is the mechanism, and the unix-socket Redis identity from substrate A already composes with it. Until then, shared provider credentials across an operator’s own agents is the intended design, not a gap.