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

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:

ConcernTodayTarget
Inbox keydiscord:inbox (shared)alice:discord:inbox (alice only)
Outbox keydiscord:outbox (shared)alice:discord:outbox, bridge fans in
ACL identityone agent, ~*:inboxagent_alice, ~alice:*
Agent credsnone (proxy injects)none (per-agent proxy injects)
sources.jsonidentical to all agentsper-agent stream names
Routingnonechannel/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-proxy process 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>. No SO_PEERCRED (unreliable across user namespaces), no source-IP map.
  • redis-cli speaks -s <sock>; the harness cli_prefix() (crates/core/src/pubsub/redis.rs) swaps -h/-p for -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:

DeploymentEnforcement
Shared host, multi-uidsocket mode 0600 owned by the agent’s uid
Containershost 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)

  1. Fix the fallback (audit Finding 4). ✅ Always run the system cowboy-redis when backend == redis; delete the per-agent user-level fallback; re-point Redis’s wantedBy/partOf to the always-present cowboy.target. Guarded by checks.nixos-multiagent-eval.

  2. Per-agent keyspace + ACL + sockets. ✅ ACL gen emits agent_<name> (~<name>:* + %W~*:outbox) with per-agent password files. One redis-auth-proxy socket-activated off per-agent 0600 sockets injects the identity by FileDescriptorName. Per-agent sources.json uses {agent}:{source}:inbox over redis+unix:// the socket; harness cli_prefix() speaks -s.

  3. Bridge routing.routes/defaultAgent options; route_inbox sends inbound to {agent}:{source}:inbox; discord + email ingests and the IngestService base 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/user field, from $USER on its own socket) and the service writes its reply to {agent}:{source}:inbox, which ~<name>:* already covers. This replaced a briefly-shipped %R~*:inbox grant that let every agent read every other agent’s routed inbox (SLOP audit Finding 2); the wildcard is gone and no agent can XRANGE/ XREADGROUP another persona’s inbox.

  4. 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.