Skip to content

Commit 3987ec6

Browse files
committed
feat(container): add agent profile abstraction for hermes support
Introduce AgentProfile so container managers (Docker, Apple Container, tart) stay agent-agnostic. Each profile carries the env file path, secrets-reload command, and MCP wiring command for one agent runtime. OpenclawProfile preserves existing behavior (default). HermesProfile targets nousresearch/hermes-agent: writes phantom tokens to ~/.hermes/.env and patches mcp_servers in ~/.hermes/config.yaml via a small embedded python+pyyaml script. Hermes has no in-place reload; ReloadCmd is nil and ReloadSecrets logs a notice. Selectable via --agent (or SLUICE_AGENT_PROFILE).
1 parent 4943433 commit 3987ec6

10 files changed

Lines changed: 439 additions & 52 deletions

File tree

CLAUDE.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,24 @@ Two credential types: `static` (default) for API keys and `oauth` for OAuth acce
116116

117117
`sluice binding update --destination` also updates the paired auto-created allow rule (tagged `binding-add:<credential>` or `cred-add:<credential>`) so the new destination is not orphaned. If no paired rule exists (e.g. because it was manually removed), the binding destination is still updated and a warning is printed. No fallback rule is created so an operator's intentional removal is not silently reverted. `--env-var` on binding update can be used to change or clear the env var name after the initial binding was created.
118118

119-
Runtime flags: `--mcp-base-url` sets the external URL the agent uses to reach sluice's MCP gateway (e.g. `http://sluice:3000`). This is added to `SelfBypass` so sluice does not policy-check its own MCP traffic. Defaults to deriving from `--health-addr`.
119+
Runtime flags: `--mcp-base-url` sets the external URL the agent uses to reach sluice's MCP gateway (e.g. `http://sluice:3000`). This is added to `SelfBypass` so sluice does not policy-check its own MCP traffic. Defaults to deriving from `--health-addr`. `--agent <profile>` selects an agent profile (`openclaw`, `hermes`); the profile controls the env file path inside the container, the secrets-reload mechanism, and the MCP wiring command. The default is `openclaw`. May also be set via `SLUICE_AGENT_PROFILE`.
120+
121+
## Agent Profiles
122+
123+
Profiles abstract per-agent runtime conventions so sluice's container managers stay agent-agnostic. Each profile carries `EnvFileRelPath` (where to write phantom-token env vars), `ReloadCmd` (argv to exec for in-place secret reload, or nil), and `WireMCPCmd` (argv to register sluice as an MCP server in the agent's config).
124+
125+
| Profile | Env file | Reload | MCP wiring |
126+
|---------|----------|--------|------------|
127+
| `openclaw` (default) | `~/.openclaw/.env` | `node -e <gateway_rpc.js> secrets.reload` over the agent's WebSocket gateway | `node -e <gateway_rpc.js> wire-mcp <name> <url>` patches `mcp.servers.<name>` |
128+
| `hermes` | `~/.hermes/.env` | None — Hermes has no documented in-place reload; new env values take effect on next message / restart | `python3 -c <script> <name> <url>` patches `mcp_servers.<name>.url` in `~/.hermes/config.yaml` (idempotent; relies on PyYAML which Hermes already requires) |
129+
130+
Adding a new profile is a single edit to `internal/container/agent_profile.go`: register a struct in `builtinProfiles`. All three container backends (Docker, Apple Container, tart) consume the profile through `BuildEnvInjectionScriptForProfile`, `profile.ReloadCmd()`, and `profile.WireMCPCmd()`, so backend code does not need to know about specific agents.
131+
132+
Hermes-specific caveats:
133+
134+
- `ReloadCmd` is nil; `ReloadSecrets` logs a notice and returns nil. New phantom tokens take effect on the next Hermes message or `/reload-mcp` slash command.
135+
- `WireMCPCmd` rewrites `~/.hermes/config.yaml` directly. Hermes picks up the change on its next startup or via `/reload-mcp` from the chat session — sluice cannot trigger that command remotely.
136+
- Hermes' Modal, Daytona, and Vercel Sandbox terminal backends run code on third-party infrastructure that sluice cannot intercept. The local and Docker Hermes backends are the supported targets for sluice's network-layer governance.
120137

121138
## MCP Gateway Setup
122139

@@ -126,9 +143,11 @@ OpenClaw connects to sluice's MCP gateway via Streamable HTTP. This is a one-tim
126143
docker exec openclaw openclaw mcp set sluice '{"url":"http://sluice:3000/mcp"}'
127144
```
128145

129-
For the hostname `sluice` to resolve inside OpenClaw, the compose file pins sluice's IP on the internal network (172.30.0.2) and adds an `extra_hosts` entry on tun2proxy (which OpenClaw shares). Docker's embedded DNS (127.0.0.11) is not reachable from OpenClaw because its DNS is routed through the TUN device. The `/etc/hosts` entry bypasses DNS entirely.
146+
For Hermes, the equivalent runs once at sluice startup via `WireMCPGateway` and writes `mcp_servers.sluice.url` into `~/.hermes/config.yaml`. Trigger Hermes' `/reload-mcp` slash command (or restart Hermes) once after first wire-up so it picks up the new server.
147+
148+
For the hostname `sluice` to resolve inside the agent container, the compose file pins sluice's IP on the internal network (172.30.0.2) and adds an `extra_hosts` entry on tun2proxy (which the agent shares). Docker's embedded DNS (127.0.0.11) is not reachable from the agent because its DNS is routed through the TUN device. The `/etc/hosts` entry bypasses DNS entirely.
130149

131-
MCP upstreams can be managed via `sluice mcp add|list|remove`, the REST API (`/api/mcp/upstreams`), or the Telegram bot (`/mcp add|list|remove`). All three paths write to the same SQLite store. After any addition or removal, restart sluice so the gateway re-reads the upstream set. OpenClaw does not need to be restarted: its connection to `sluice:3000/mcp` is registered once at sluice startup (via `WireMCPGateway`, which patches `mcp.servers.sluice = {url: ...}` in the agent's openclaw.json) and stays valid across sluice restarts. The agent re-queries the tool list on subsequent agent runs.
150+
MCP upstreams can be managed via `sluice mcp add|list|remove`, the REST API (`/api/mcp/upstreams`), or the Telegram bot (`/mcp add|list|remove`). All three paths write to the same SQLite store. After any addition or removal, restart sluice so the gateway re-reads the upstream set. The agent does not need to be restarted: its connection to `sluice:3000/mcp` is registered once at sluice startup (via `WireMCPGateway`) and stays valid across sluice restarts. The agent re-queries the tool list on subsequent runs.
132151

133152
The Telegram `/mcp add` path auto-deletes the chat message because `--env KEY=VAL` pairs may contain secrets (use `KEY=vault:name` to keep the plaintext out of the SQLite store and `/mcp list` output entirely).
134153

README.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# :shield: Sluice — Credential Governance Proxy for OpenClaw
1+
# :shield: Sluice — Credential Governance Proxy for AI Agents
22

33
[![Tests](https://github.com/nnemirovsky/sluice/actions/workflows/test.yml/badge.svg)](https://github.com/nnemirovsky/sluice/actions/workflows/test.yml)
44
[![E2E](https://github.com/nnemirovsky/sluice/actions/workflows/e2e-linux.yml/badge.svg)](https://github.com/nnemirovsky/sluice/actions/workflows/e2e-linux.yml)
@@ -13,18 +13,18 @@ Keeps real secrets out of the agent, enforces per-request policy on every connec
1313

1414
AI agents need credentials to be useful. Giving them real credentials is dangerous.
1515

16-
**The problem:** OpenClaw makes API calls, opens network connections, and invokes MCP tools. Without governance, it can leak secrets in tool outputs, connect to unexpected endpoints, or make destructive API calls. No existing tool combines credential isolation, human approval, all-protocol interception, and MCP-level governance in one place.
16+
**The problem:** Agents like [OpenClaw](https://openclaw.ai) and [Hermes](https://github.com/NousResearch/hermes-agent) make API calls, open network connections, and invoke MCP tools. Without governance, they can leak secrets in tool outputs, connect to unexpected endpoints, or make destructive API calls. No existing tool combines credential isolation, human approval, all-protocol interception, and MCP-level governance in one place.
1717

18-
**The solution:** Sluice intercepts everything at two layers and never gives OpenClaw real credentials.
18+
**The solution:** Sluice intercepts everything at two layers and never gives the agent real credentials.
1919

2020
| Layer | What it sees | What it governs |
2121
|-------|-------------|-----------------|
2222
| **MCP Gateway** | Tool names, arguments, responses | File writes, exec, deletions, any MCP tool call |
2323
| **SOCKS5 Proxy** | Every TCP and UDP connection | HTTP, HTTPS, WebSocket, gRPC, SSH, IMAP, SMTP, DNS, QUIC/HTTP3 |
2424

25-
**Phantom token swap:** OpenClaw gets phantom tokens that look like real API keys, injected as environment variables via `docker exec` (no shared volume needed). Sluice's MITM proxy swaps them for real credentials in-flight. If a phantom token leaks, it is useless outside the proxy. OAuth credentials are handled bidirectionally: sluice intercepts token endpoint responses, captures real tokens, and returns phantom tokens to the agent. The entire OAuth lifecycle (initial auth, token refresh, token rotation) is transparent.
25+
**Phantom token swap:** the agent gets phantom tokens that look like real API keys, injected as environment variables via `docker exec` (no shared volume needed). Sluice's MITM proxy swaps them for real credentials in-flight. If a phantom token leaks, it is useless outside the proxy. OAuth credentials are handled bidirectionally: sluice intercepts token endpoint responses, captures real tokens, and returns phantom tokens to the agent. The entire OAuth lifecycle (initial auth, token refresh, token rotation) is transparent.
2626

27-
**Human approval:** Connections and tool calls matching "ask" policy rules trigger a notification via Telegram or HTTP webhook. OpenClaw blocks until a human responds with Allow or Deny.
27+
**Human approval:** Connections and tool calls matching "ask" policy rules trigger a notification via Telegram or HTTP webhook. The agent blocks until a human responds with Allow or Deny.
2828

2929
**Credential isolation:** Real secrets live in an encrypted vault (age, HashiCorp Vault, 1Password, Bitwarden, KeePass, or gopass). They are decrypted into zeroed memory only at injection time and never exposed to the agent process.
3030

@@ -69,6 +69,22 @@ flowchart LR
6969

7070
## Quick Start
7171

72+
### Choosing an agent profile
73+
74+
Sluice ships with profiles for the agents it knows about. The profile controls where credentials are written inside the container, how secret reload is signalled, and how sluice's MCP gateway is registered in the agent's config.
75+
76+
| Profile | Env file | In-place reload | MCP wiring |
77+
|---------|----------|-----------------|------------|
78+
| `openclaw` (default) | `~/.openclaw/.env` | Gateway WebSocket RPC (`secrets.reload`) | Patches `mcp.servers.<name>` via gateway RPC |
79+
| `hermes` | `~/.hermes/.env` | None — picked up on next agent run | Patches `mcp_servers.<name>.url` in `~/.hermes/config.yaml` (requires `python3` + `pyyaml` in the container, both shipped with Hermes) |
80+
81+
Select with `--agent <name>` (or `SLUICE_AGENT_PROFILE=<name>`). The default is `openclaw` so existing setups need no changes.
82+
83+
For Hermes specifically, two follow-up notes:
84+
85+
- Sluice cannot trigger Hermes' `/reload-mcp` slash command after wiring; either restart Hermes once after `sluice` first writes the config, or invoke `/reload-mcp` from your Hermes chat session.
86+
- Hermes' Modal/Daytona/Vercel Sandbox terminal backends run code on third-party infrastructure that sluice cannot intercept. Use the `local` or `docker` Hermes backend if you want sluice's network governance to apply.
87+
7288
### Docker (Linux)
7389

7490
The recommended setup for Linux. Three containers share a network namespace: sluice (proxy), tun2proxy (routes all traffic through SOCKS5), and OpenClaw.

cmd/sluice/main.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func main() {
8888
certDir := flag.String("cert-dir", "", "shared volume path for CA certificate (enables MITM trust injection into guest)")
8989
dnsResolver := flag.String("dns-resolver", "", "upstream DNS resolver address for DNS interception (default: 8.8.8.8:53)")
9090
mcpBaseURL := flag.String("mcp-base-url", "", "external base URL the agent uses to reach sluice's MCP gateway (e.g. http://sluice:3000); added to SelfBypass so sluice does not policy-check its own MCP traffic")
91+
agentFlag := flag.String("agent", envDefault("SLUICE_AGENT_PROFILE", "openclaw"), "agent profile: openclaw, hermes (controls env file path, reload mechanism, MCP config wiring)")
9192
flag.Parse()
9293

9394
// Validate --runtime flag early.
@@ -106,6 +107,14 @@ func main() {
106107
log.Fatalf("--runtime macos requires --vm-image (e.g. ghcr.io/cirruslabs/macos-sequoia-base:latest)")
107108
}
108109

110+
// Resolve the agent profile early so it can be threaded into every
111+
// container manager constructor below.
112+
agentProfile, err := container.ProfileFromName(*agentFlag)
113+
if err != nil {
114+
log.Fatalf("invalid --agent value: %v", err)
115+
}
116+
log.Printf("agent profile: %s (env file ~/%s)", agentProfile.Name, agentProfile.EnvFileRelPath)
117+
109118
// Open the SQLite store.
110119
db, err := store.New(*dbPath)
111120
if err != nil {
@@ -250,7 +259,7 @@ func main() {
250259
} else {
251260
if fi, statErr := os.Stat(sock); statErr == nil && fi.Mode().Type() == os.ModeSocket {
252261
client := container.NewSocketClient(sock)
253-
containerMgr = container.NewDockerManager(client, *containerName)
262+
containerMgr = container.NewDockerManagerForProfile(client, *containerName, agentProfile)
254263
log.Printf("docker manager enabled: socket=%s, container=%s", sock, *containerName)
255264
} else if *runtimeFlag == "docker" {
256265
log.Fatalf("--runtime docker: socket %q not found or not a socket", sock)
@@ -269,11 +278,12 @@ func main() {
269278
containerMgr = container.NewAppleManager(container.AppleManagerConfig{
270279
CLI: cli,
271280
ContainerName: *containerName,
281+
Profile: agentProfile,
272282
})
273283
log.Printf("apple container manager enabled: container=%s", *containerName)
274284
}
275285
case "macos":
276-
tartMgr, tartRouter, containerMgr, tartVMOwned = startMacOSVM(*vmImage, *containerName, *certDir)
286+
tartMgr, tartRouter, containerMgr, tartVMOwned = startMacOSVM(*vmImage, *containerName, *certDir, agentProfile)
277287
case "none":
278288
log.Printf("standalone mode: no container runtime (configure ALL_PROXY=socks5://%s manually)", *listenAddr)
279289
case "":
@@ -1050,13 +1060,13 @@ func seedStoreFromConfig(db *store.Store, configPath string) error {
10501060
// routing. Returns the TartManager, NetworkRouter (for shutdown cleanup),
10511061
// the ContainerManager interface, and a boolean indicating whether sluice
10521062
// started the VM. Calls log.Fatalf on unrecoverable errors.
1053-
func startMacOSVM(vmImage, vmName, certDir string) (*container.TartManager, *container.NetworkRouter, container.ContainerManager, bool) {
1063+
func startMacOSVM(vmImage, vmName, certDir string, profile *container.AgentProfile) (*container.TartManager, *container.NetworkRouter, container.ContainerManager, bool) {
10541064
cli, cliErr := container.NewTartCLI(nil)
10551065
if cliErr != nil {
10561066
log.Fatalf("--runtime macos: tart CLI not available: %v", cliErr)
10571067
}
10581068

1059-
mgr, router, owned, err := setupMacOSVM(cli, vmImage, vmName, certDir)
1069+
mgr, router, owned, err := setupMacOSVM(cli, vmImage, vmName, certDir, profile)
10601070
if err != nil {
10611071
log.Fatalf("--runtime macos: %v", err)
10621072
}
@@ -1105,7 +1115,7 @@ func waitForVMIP(ctx context.Context, cli *container.TartCLI, vmName string) (st
11051115
// indicates whether sluice started the VM (true) or attached to an
11061116
// already-running VM (false). Only VMs started by sluice should be
11071117
// stopped on shutdown.
1108-
func setupMacOSVM(cli *container.TartCLI, vmImage, vmName, certDir string) (*container.TartManager, *container.NetworkRouter, bool, error) {
1118+
func setupMacOSVM(cli *container.TartCLI, vmImage, vmName, certDir string, profile *container.AgentProfile) (*container.TartManager, *container.NetworkRouter, bool, error) {
11091119
ctx := context.Background()
11101120

11111121
// Check if VM already exists.
@@ -1182,6 +1192,7 @@ func setupMacOSVM(cli *container.TartCLI, vmImage, vmName, certDir string) (*con
11821192
CLI: cli,
11831193
VMName: vmName,
11841194
RunConfig: runCfg,
1195+
Profile: profile,
11851196
})
11861197

11871198
// Set up pf routing to redirect VM traffic through tun2proxy to SOCKS5.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package container
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// AgentProfile abstracts agent-specific runtime conventions so that sluice
9+
// can manage credential and MCP wiring for more than one agent. Each agent
10+
// stores its env file in a different location, has a different mechanism
11+
// for picking up secret changes, and registers MCP servers in a different
12+
// config format. A profile captures all three so that the container
13+
// managers (Docker, Apple Container, tart) stay agent-agnostic.
14+
type AgentProfile struct {
15+
// Name is the human-readable profile identifier (e.g. "openclaw").
16+
Name string
17+
18+
// EnvFileRelPath is the path to the agent's secret env file relative
19+
// to the in-container HOME directory (e.g. ".openclaw/.env").
20+
EnvFileRelPath string
21+
22+
// ReloadCmd returns the argv to exec inside the agent container in
23+
// order to make a freshly-written env file take effect. Returning nil
24+
// means the profile has no in-place reload mechanism; the caller
25+
// should log a notice and rely on the next agent run / container
26+
// restart picking up the new values.
27+
ReloadCmd func() []string
28+
29+
// WireMCPCmd returns the argv to exec for registering sluice's MCP
30+
// gateway URL inside the agent's config. It is invoked once per
31+
// sluice startup. Returning nil means the profile cannot patch
32+
// config in place and the operator must wire the MCP gateway
33+
// manually before starting the agent.
34+
WireMCPCmd func(name, url string) []string
35+
}
36+
37+
// OpenclawProfile is the default profile. Openclaw stores secrets at
38+
// ~/.openclaw/.env and exposes a JSON-RPC gateway (over WebSocket) for
39+
// reloading secrets and patching config. The embedded gateway_rpc.js
40+
// script handles the device-signed handshake.
41+
var OpenclawProfile = &AgentProfile{
42+
Name: "openclaw",
43+
EnvFileRelPath: ".openclaw/.env",
44+
ReloadCmd: func() []string {
45+
return GatewayRPCNodeCommand("secrets.reload")
46+
},
47+
WireMCPCmd: func(name, url string) []string {
48+
return GatewayRPCNodeCommand("wire-mcp", name, url)
49+
},
50+
}
51+
52+
// hermesMCPWireScript is a small Python script that registers an MCP
53+
// server inside ~/.hermes/config.yaml under the mcp_servers key. Hermes
54+
// reads MCP servers from this file at startup and on the /reload-mcp
55+
// slash command. We rely on PyYAML being available because Hermes itself
56+
// is a Python application that ships with PyYAML as a hard dependency.
57+
//
58+
// The script is idempotent: if mcp_servers.<name>.url already matches,
59+
// the file is not rewritten.
60+
const hermesMCPWireScript = `
61+
import os, sys, yaml
62+
name, url = sys.argv[1], sys.argv[2]
63+
cfg_path = os.path.expanduser("~/.hermes/config.yaml")
64+
os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
65+
data = {}
66+
if os.path.exists(cfg_path):
67+
with open(cfg_path) as fh:
68+
data = yaml.safe_load(fh) or {}
69+
servers = data.setdefault("mcp_servers", {})
70+
existing = servers.get(name) or {}
71+
if existing.get("url") == url:
72+
sys.exit(0)
73+
existing["url"] = url
74+
servers[name] = existing
75+
with open(cfg_path, "w") as fh:
76+
yaml.safe_dump(data, fh, sort_keys=False)
77+
`
78+
79+
// HermesProfile targets nousresearch/hermes-agent. Hermes stores secrets
80+
// at ~/.hermes/.env and MCP servers under mcp_servers in
81+
// ~/.hermes/config.yaml. There is no documented in-process reload for
82+
// .env, so ReloadCmd is nil and callers fall back to logging a notice;
83+
// new credentials take effect on the next agent message.
84+
//
85+
// MCP wiring patches config.yaml directly. Hermes picks up the change on
86+
// startup or via the /reload-mcp slash command (which the operator must
87+
// invoke from the agent UI; sluice cannot trigger it remotely).
88+
var HermesProfile = &AgentProfile{
89+
Name: "hermes",
90+
EnvFileRelPath: ".hermes/.env",
91+
ReloadCmd: nil,
92+
WireMCPCmd: func(name, url string) []string {
93+
return []string{"python3", "-c", hermesMCPWireScript, name, url}
94+
},
95+
}
96+
97+
// builtinProfiles is the registry consulted by ProfileFromName.
98+
var builtinProfiles = map[string]*AgentProfile{
99+
"openclaw": OpenclawProfile,
100+
"hermes": HermesProfile,
101+
}
102+
103+
// ProfileFromName returns the built-in profile matching name, or an
104+
// error listing the known profiles.
105+
func ProfileFromName(name string) (*AgentProfile, error) {
106+
if p, ok := builtinProfiles[name]; ok {
107+
return p, nil
108+
}
109+
known := make([]string, 0, len(builtinProfiles))
110+
for k := range builtinProfiles {
111+
known = append(known, k)
112+
}
113+
return nil, fmt.Errorf("unknown agent profile %q (known: %s)", name, strings.Join(known, ", "))
114+
}
115+
116+
// resolveProfile returns p when non-nil, or OpenclawProfile as the
117+
// default. This lets existing call sites that do not pass a profile
118+
// keep their previous behavior.
119+
func resolveProfile(p *AgentProfile) *AgentProfile {
120+
if p == nil {
121+
return OpenclawProfile
122+
}
123+
return p
124+
}

0 commit comments

Comments
 (0)