Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,24 @@ Two credential types: `static` (default) for API keys and `oauth` for OAuth acce

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

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

## Agent Profiles

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

| Profile | Env file | Reload | MCP wiring |
|---------|----------|--------|------------|
| `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>` |
| `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) |

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.

Hermes-specific caveats:

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

## MCP Gateway Setup

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

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

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.

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

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

Expand Down
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# :shield: Sluice — Credential Governance Proxy for OpenClaw
# :shield: Sluice — Credential Governance Proxy for AI Agents

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

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

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

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

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

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

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

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

Expand Down Expand Up @@ -69,6 +69,22 @@ flowchart LR

## Quick Start

### Choosing an agent profile

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.

| Profile | Env file | In-place reload | MCP wiring |
|---------|----------|-----------------|------------|
| `openclaw` (default) | `~/.openclaw/.env` | Gateway WebSocket RPC (`secrets.reload`) | Patches `mcp.servers.<name>` via gateway RPC |
| `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) |

Select with `--agent <name>` (or `SLUICE_AGENT_PROFILE=<name>`). The default is `openclaw` so existing setups need no changes.

For Hermes specifically, two follow-up notes:

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

### Docker (Linux)

The recommended setup for Linux. Three containers share a network namespace: sluice (proxy), tun2proxy (routes all traffic through SOCKS5), and OpenClaw.
Expand Down
21 changes: 16 additions & 5 deletions cmd/sluice/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func main() {
certDir := flag.String("cert-dir", "", "shared volume path for CA certificate (enables MITM trust injection into guest)")
dnsResolver := flag.String("dns-resolver", "", "upstream DNS resolver address for DNS interception (default: 8.8.8.8:53)")
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")
agentFlag := flag.String("agent", envDefault("SLUICE_AGENT_PROFILE", "openclaw"), "agent profile: openclaw, hermes (controls env file path, reload mechanism, MCP config wiring)")
flag.Parse()

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

// Resolve the agent profile early so it can be threaded into every
// container manager constructor below.
agentProfile, err := container.ProfileFromName(*agentFlag)
if err != nil {
log.Fatalf("invalid --agent value: %v", err)
}
log.Printf("agent profile: %s (env file ~/%s)", agentProfile.Name, agentProfile.EnvFileRelPath)

// Open the SQLite store.
db, err := store.New(*dbPath)
if err != nil {
Expand Down Expand Up @@ -250,7 +259,7 @@ func main() {
} else {
if fi, statErr := os.Stat(sock); statErr == nil && fi.Mode().Type() == os.ModeSocket {
client := container.NewSocketClient(sock)
containerMgr = container.NewDockerManager(client, *containerName)
containerMgr = container.NewDockerManagerForProfile(client, *containerName, agentProfile)
log.Printf("docker manager enabled: socket=%s, container=%s", sock, *containerName)
} else if *runtimeFlag == "docker" {
log.Fatalf("--runtime docker: socket %q not found or not a socket", sock)
Expand All @@ -269,11 +278,12 @@ func main() {
containerMgr = container.NewAppleManager(container.AppleManagerConfig{
CLI: cli,
ContainerName: *containerName,
Profile: agentProfile,
})
log.Printf("apple container manager enabled: container=%s", *containerName)
}
case "macos":
tartMgr, tartRouter, containerMgr, tartVMOwned = startMacOSVM(*vmImage, *containerName, *certDir)
tartMgr, tartRouter, containerMgr, tartVMOwned = startMacOSVM(*vmImage, *containerName, *certDir, agentProfile)
case "none":
log.Printf("standalone mode: no container runtime (configure ALL_PROXY=socks5://%s manually)", *listenAddr)
case "":
Expand Down Expand Up @@ -1050,13 +1060,13 @@ func seedStoreFromConfig(db *store.Store, configPath string) error {
// routing. Returns the TartManager, NetworkRouter (for shutdown cleanup),
// the ContainerManager interface, and a boolean indicating whether sluice
// started the VM. Calls log.Fatalf on unrecoverable errors.
func startMacOSVM(vmImage, vmName, certDir string) (*container.TartManager, *container.NetworkRouter, container.ContainerManager, bool) {
func startMacOSVM(vmImage, vmName, certDir string, profile *container.AgentProfile) (*container.TartManager, *container.NetworkRouter, container.ContainerManager, bool) {
cli, cliErr := container.NewTartCLI(nil)
if cliErr != nil {
log.Fatalf("--runtime macos: tart CLI not available: %v", cliErr)
}

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

// Check if VM already exists.
Expand Down Expand Up @@ -1182,6 +1192,7 @@ func setupMacOSVM(cli *container.TartCLI, vmImage, vmName, certDir string) (*con
CLI: cli,
VMName: vmName,
RunConfig: runCfg,
Profile: profile,
})

// Set up pf routing to redirect VM traffic through tun2proxy to SOCKS5.
Expand Down
6 changes: 4 additions & 2 deletions cmd/sluice/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,8 @@ func TestHandleMCPGatewayNoUpstreams(t *testing.T) {
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestHandleMCPGatewayNoUpstreams")
cmd.Env = append(os.Environ(),
cmd.Env = append(
os.Environ(),
"TEST_MCP_SUBPROCESS=no_upstreams",
"TEST_DB_PATH="+dbPath,
"TELEGRAM_BOT_TOKEN=",
Expand Down Expand Up @@ -686,7 +687,8 @@ func TestHandleMCPGatewayInvalidChatID(t *testing.T) {
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestHandleMCPGatewayInvalidChatID")
cmd.Env = append(os.Environ(),
cmd.Env = append(
os.Environ(),
"TEST_MCP_SUBPROCESS=invalid_chat_id",
"TEST_DB_PATH="+dbPath,
)
Expand Down
3 changes: 2 additions & 1 deletion cmd/sluice/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,8 @@ func handlePolicyImport(args []string) error {
return fmt.Errorf("import: %w", err)
}

fmt.Printf("imported: %d rules (%d skipped), %d bindings (%d skipped), %d upstreams (%d skipped), %d config\n",
fmt.Printf(
"imported: %d rules (%d skipped), %d bindings (%d skipped), %d upstreams (%d skipped), %d config\n",
result.RulesInserted, result.RulesSkipped,
result.BindingsInserted, result.BindingsSkipped,
result.UpstreamsInserted, result.UpstreamsSkipped,
Expand Down
6 changes: 4 additions & 2 deletions e2e/apple_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ func (e *appleEnv) startTun2proxy() {
e.t.Skipf("tun2proxy not in PATH: %v", err)
}

cmd := exec.Command(tun2proxyBin,
cmd := exec.Command(
tun2proxyBin,
"--proxy", "socks5://"+e.sluice.ProxyAddr,
"--tun", e.tunIface,
)
Expand Down Expand Up @@ -655,7 +656,8 @@ func TestAppleContainerSluiceStartsWithRuntimeFlag(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, binary,
cmd := exec.CommandContext(
ctx, binary,
"--runtime", "apple",
"--listen", fmt.Sprintf("127.0.0.1:%d", proxyPort),
"--db", dbPath,
Expand Down
18 changes: 12 additions & 6 deletions e2e/credential_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,8 @@ func TestCredential_HeaderInjection(t *testing.T) {
_, port := mustSplitAddr(t, echo.URL)

// Add credential with binding for the echo server.
runCredAdd(t, setup.Proc, "test_api_key", "real-secret-value-123",
runCredAdd(
t, setup.Proc, "test_api_key", "real-secret-value-123",
"--destination", "127.0.0.1",
"--ports", port,
"--header", "X-Api-Key",
Expand Down Expand Up @@ -308,7 +309,8 @@ func TestCredential_PhantomInBody(t *testing.T) {
echo := startTLSEchoServerWithCA(t, setup.CA)
_, port := mustSplitAddr(t, echo.URL)

runCredAdd(t, setup.Proc, "body_cred", "body-secret-42",
runCredAdd(
t, setup.Proc, "body_cred", "body-secret-42",
"--destination", "127.0.0.1",
"--ports", port,
"--header", "Authorization",
Expand Down Expand Up @@ -352,7 +354,8 @@ func TestCredential_UnboundPhantomStripped(t *testing.T) {
_, unboundPort := mustSplitAddr(t, unboundEcho.URL)

// Add credential bound to the first echo server only.
runCredAdd(t, setup.Proc, "bound_cred", "bound-secret",
runCredAdd(
t, setup.Proc, "bound_cred", "bound-secret",
"--destination", "127.0.0.1",
"--ports", boundPort,
"--header", "X-Cred",
Expand Down Expand Up @@ -480,7 +483,8 @@ func TestCredential_Rotation(t *testing.T) {
_, port := mustSplitAddr(t, echo.URL)

// Add initial credential.
runCredAdd(t, setup.Proc, "rotate_key", "original-value",
runCredAdd(
t, setup.Proc, "rotate_key", "original-value",
"--destination", "127.0.0.1",
"--ports", port,
"--header", "X-Api-Key",
Expand Down Expand Up @@ -524,14 +528,16 @@ func TestCredential_MultipleDestinations(t *testing.T) {
_, portB := mustSplitAddr(t, echoB.URL)

// Add credential A bound to echo server A.
runCredAdd(t, setup.Proc, "cred_a", "secret-a",
runCredAdd(
t, setup.Proc, "cred_a", "secret-a",
"--destination", "127.0.0.1",
"--ports", portA,
"--header", "X-Key-A",
)

// Add credential B bound to echo server B.
runCredAdd(t, setup.Proc, "cred_b", "secret-b",
runCredAdd(
t, setup.Proc, "cred_b", "secret-b",
"--destination", "127.0.0.1",
"--ports", portB,
"--header", "X-Key-B",
Expand Down
3 changes: 2 additions & 1 deletion e2e/grpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ func TestGRPC_CredentialInjectionInMetadata(t *testing.T) {
_, port := splitHostPort(t, h2Addr)

// Add credential with binding for the H2 server.
runCredAdd(t, setup.Proc, "grpc_token", "grpc-real-secret-456",
runCredAdd(
t, setup.Proc, "grpc_token", "grpc-real-secret-456",
"--destination", "127.0.0.1",
"--ports", port,
"--header", "Authorization",
Expand Down
3 changes: 2 additions & 1 deletion e2e/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,8 @@ func sluiceWithWebhook(t *testing.T, policyTOML, webhookURL string) *SluiceProce
}

// Add the HTTP webhook channel to the pre-seeded DB.
channelCmd := exec.Command(binary, "channel", "add",
channelCmd := exec.Command(
binary, "channel", "add",
"--type", "http",
"--url", webhookURL,
"--db", dbPath,
Expand Down
3 changes: 2 additions & 1 deletion e2e/websocket_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ func TestWebSocket_CredentialInjectionInUpgradeHeaders(t *testing.T) {
_, port := splitHostPort(t, wsAddr)

// Add credential bound to the WS echo server.
runCredAdd(t, setup.Proc, "ws_api_key", "ws-real-secret-789",
runCredAdd(
t, setup.Proc, "ws_api_key", "ws-real-secret-789",
"--destination", "127.0.0.1",
"--ports", port,
"--header", "X-Ws-Key",
Expand Down
Loading
Loading