Skip to content

Commit 7e043b8

Browse files
authored
feat(proxy,mcp): tool argument and network response DLP hardening (#33)
* feat(mcp): add ExecInspector for trampoline and dangerous pattern detection * feat(mcp): wire ExecInspector into MCP gateway tool-call path * feat(proxy): add MITM response DLP scanning * feat(proxy): load MITM DLP redact rules from store * docs: describe ExecInspector and MITM response DLP * feat(cli,telegram): support adding redact rules * fix(proxy): silence errcheck on deferred decoder Close() * fix(cli): add explicit return after t.Fatal to satisfy staticcheck SA5011 * fix(test): add return after t.Fatal in more nil-checks for staticcheck SA5011 * ci(lint): exclude SA5011 in test files to stop false positives after t.Fatal
1 parent 6f673c4 commit 7e043b8

27 files changed

Lines changed: 6450 additions & 277 deletions

.golangci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ linters:
2323
linters:
2424
- revive
2525
text: "avoid package names that conflict"
26+
- path: _test\.go
27+
linters:
28+
- staticcheck
29+
text: "SA5011"
2630

2731
formatters:
2832
enable:

CLAUDE.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ Two governance layers work together:
8383
```
8484
sluice policy list [--verdict allow|deny|ask|redact] [--db sluice.db]
8585
sluice policy add allow|deny|ask <destination> [--ports 443,80] [--name "reason"]
86+
sluice policy add redact <pattern> --replacement "[REDACTED_X]" [--name "reason"]
8687
sluice policy remove <id>
8788
sluice policy import <path.toml> # seed DB from TOML (merge semantics)
8889
sluice policy export # dump current rules as TOML
@@ -190,7 +191,38 @@ Extends phantom swap to handle OAuth credentials bidirectionally. Static credent
190191

191192
**Per-request policy evaluation** applies to HTTP/HTTPS, gRPC-over-HTTP/2, and QUIC/HTTP3. Policy is re-evaluated for every HTTP request (or HTTP/2 stream, or HTTP/3 request), so "Allow Once" permits a single request and subsequent requests on the same connection re-trigger the approval flow. When a per-request approval resolves to "Always Allow" or "Always Deny", the `RequestPolicyChecker` persists the new rule to the policy store via its `PersistRuleFunc` callback and swaps in a freshly compiled engine, so subsequent requests match via the fast path instead of re-entering the approval flow. A fast path skips per-request checks when the SOCKS5 CONNECT matched an explicit allow rule (`RuleMatch`, not default verdict) so normally allowed destinations incur no extra overhead. WebSocket, SSH, and IMAP/SMTP remain connection-level on purpose: per-message or per-command policy on those would blow past the broker's 5/min per-destination rate limit and break normal usage.
192193

193-
**MITM library:** HTTPS interception uses go-mitmproxy (`github.com/lqqyt2423/go-mitmproxy`). The `SluiceAddon` struct in `internal/proxy/addon.go` implements go-mitmproxy's `Addon` interface. `Requestheaders` fires per HTTP/2 stream, giving true per-request policy for gRPC and other HTTP/2 traffic. `Request` handles credential injection (three-pass phantom swap). `Response` handles OAuth token interception.
194+
**MITM library:** HTTPS interception uses go-mitmproxy (`github.com/lqqyt2423/go-mitmproxy`). The `SluiceAddon` struct in `internal/proxy/addon.go` implements go-mitmproxy's `Addon` interface. `Requestheaders` fires per HTTP/2 stream, giving true per-request policy for gRPC and other HTTP/2 traffic. `Request` handles credential injection (three-pass phantom swap). `Response` handles OAuth token interception and response DLP scanning.
195+
196+
**Response DLP** (`internal/proxy/response_dlp.go`, wired via `SluiceAddon.Response` in `internal/proxy/addon.go`) scans HTTPS response bodies and header values for credential patterns using `InspectRedactRule` regexes from the policy store. Redact rules can be managed via CLI (`sluice policy add redact <pattern> --replacement "..."`), Telegram (`/policy redact <pattern> [replacement]`), or HTTP API (`POST /api/rules` with `verdict="redact"`).
197+
198+
* Complements phantom token stripping. Phantom stripping protects outbound requests so real credentials never leak to upstreams. Response DLP protects inbound responses so real credentials from upstream bodies (echoed auth headers in API errors, debug endpoints leaking env vars, misconfigured services returning secrets) never reach the agent.
199+
* Header scan runs unconditionally. Headers are scanned regardless of content type and regardless of whether the body scan later succeeds. A decompression failure or a binary Content-Type cannot suppress redaction of a header-borne leak.
200+
* Body scan skips binary content. `image/*`, `video/*`, `audio/*`, `application/octet-stream`, `application/pdf`, `application/zip`, and `font/*` responses skip the body pass.
201+
* Hop-by-hop headers are never mutated. `Connection`, `Transfer-Encoding`, `Keep-Alive`, etc. are left alone. When the body is rewritten, `Transfer-Encoding` is stripped and `Content-Length` rewritten so the agent receives a well-framed response.
202+
* Compressed bodies are decoded. A safe wrapper around go-mitmproxy's `ReplaceToDecodedBody` handles single-value `Content-Encoding: gzip | br | deflate | zstd` (all four have unit tests), multi-value `Content-Encoding: gzip, identity` (identity tokens are stripped then the remaining single encoding is decoded), and stacked encodings like `gzip, br` (rejected as unsupported, body scan skipped with a warning log so a still-compressed body is never scanned as plaintext). The wrapper restores the original `Content-Encoding` header values on decode failure so callers see a consistent pre-state on error.
203+
* Oversized bodies fail-open. Bodies over `maxProxyBody` (16 MiB) skip the body scan because the data already left the upstream.
204+
* Streamed responses are not scanned. `f.Stream=true` skips the `Response` addon callback, which go-mitmproxy sets automatically for `text/event-stream` (SSE, LLM streaming) and for bodies above `StreamLargeBodies` (default 5 MiB). `StreamResponseModifier` emits a one-shot WARNING per client connection when DLP rules are configured and the stream path fires (deduped by `dlpStreamWarned` sync.Map, keyed by client connection id). When the connection state is unavailable (`f.ConnContext` or `f.ConnContext.ClientConn` nil, rare defensive case), the warning falls back to a non-dedup log so the bypass notification is never silently suppressed. See "Known limitation: streaming bypass" below.
205+
* Audit event. Redactions emit a `response_dlp_redact` audit action whose `Reason` field is formatted as `rule1=count1,rule2=count2` so ops can distinguish one Bearer token from fifty AWS keys. No-match scans emit a rate-limited debug log (one line per 500 scans).
206+
* Rule loading. Rules are loaded at startup via `SluiceAddon.SetRedactRules` (all-or-nothing compile: if any rule pattern fails, the old rule set stays in place) and hot-reloaded on SIGHUP through `Server.UpdateInspectRules`, with lock-free swap via `atomic.Pointer`.
207+
208+
**Known limitation: streaming bypass.** Two response classes bypass Response DLP entirely:
209+
210+
1. **Server-Sent Events** (any response with `Content-Type: text/event-stream`). Used by SSE endpoints, LLM streaming completions, etc.
211+
2. **Bodies larger than `StreamLargeBodies`** (default 5 MiB). Anything between 5 MiB and `maxProxyBody` (16 MiB) lands here. Bodies over 16 MiB also bypass via the oversized-body path described above.
212+
213+
go-mitmproxy sets `f.Stream=true` for these classes and skips the `Response` addon callback that runs DLP scanning. Sluice substitutes a `StreamResponseModifier` that handles OAuth token swapping (small token bodies are buffered) and emits one of two log lines per client connection when DLP rules are configured:
214+
215+
```
216+
[ADDON-DLP] WARNING: streaming response bypasses DLP for <host> (<N> rules configured)
217+
```
218+
219+
or, when `f.ConnContext` is nil and dedup cannot be applied:
220+
221+
```
222+
[ADDON-DLP] WARNING: streaming response bypasses DLP for <host> (<N> rules configured; connection state unavailable, dedup disabled)
223+
```
224+
225+
**Operator guidance.** Treat these warning lines as a credential-leak monitoring signal. Pipe sluice's stderr/stdout to a log aggregator (Loki, Datadog, CloudWatch, etc.) and alert on the substring `[ADDON-DLP] WARNING`. The host field tells you which upstream is hot-pathed past DLP so you can decide whether to deny the destination, route around it, or accept the risk. The rule count tells you what would have been redacted had the body been buffered. Implementing chunked stream-aware scanning is on the future-work list (see `docs/plans/completed/20260405-tool-network-dlp-hardening.md`); until then, log-based alerting is the operator's only signal that a credential pattern may have flowed to the agent through a streaming response.
194226

195227
**QUIC per-request:** `EvaluateQUICDetailed` returns Ask when an ask rule matches and falls back to the engine's configured default verdict (not hardcoded Deny). The UDP dispatch loop creates a `RequestPolicyChecker` and passes it to `buildHandler`, which calls `CheckAndConsume` per HTTP/3 request. When the default verdict is "allow", a per-request checker is still attached (with seed credits of 1) so long-lived QUIC sessions re-evaluate policy on subsequent requests.
196228

@@ -224,6 +256,8 @@ Two-phase detection: port-based guess first, then byte-level for non-standard po
224256

225257
Optional. JSON lines with blake3 hash chain (`prev_hash` field). Genesis hash: blake3(""). Recovers chain across restarts by reading last line. `sluice audit verify` walks log and reports broken links.
226258

259+
Action names operators commonly grep for: `tool_call` (MCP tool call policy verdict), `inspect_block` (ContentInspector argument block), `exec_block` (ExecInspector trampoline/dangerous-command/env-override block), `response_dlp_redact` (MITM HTTPS response body or header redacted by InspectRedactRule), `inject` (phantom token injected into outbound request), and `deny` (network connection denied at SOCKS5 or SNI layer).
260+
227261
### MCP gateway
228262

229263
Three upstream transports: stdio (child processes), Streamable HTTP, WebSocket. All satisfy `MCPUpstream` interface. Tools namespaced as `<upstream>__<tool>`. Policy evaluation: deny/allow/ask priority. `ContentInspector` blocks arguments and redacts responses using regex (JSON parsed before matching to prevent unicode escape bypass). Per-upstream timeouts (default 120s).
@@ -232,6 +266,10 @@ Three upstream transports: stdio (child processes), Streamable HTTP, WebSocket.
232266

233267
Agent connection: OpenClaw is configured once (via `openclaw mcp set`) to connect to `http://sluice:3000/mcp`. Sluice's `SelfBypass` auto-allows connections to its own MCP listener so the traffic is not policy-checked.
234268

269+
**ExecInspector** (`internal/mcp/exec_inspect.go`) adds structural exec-argument inspection for tools whose names match configurable globs (defaults: `*exec*`, `*shell*`, `*run_command*`, `*terminal*`). It runs in `HandleToolCall` after the ContentInspector argument check and before the Ask/approval flow (exec-block is a hard deny: a dangerous command should not be presented to a human for approval). It detects trampoline patterns (`bash -c`, `sh -c`, `zsh -c`, `python[23]? -c`, `ruby -e`, `perl -e`, `node -e`, and combined-short-flag variants like `bash -ce` / `bash -ec` / `sh -xc`), shell metacharacters (`|`, `;`, `&`, `$`, `<`, `>`, backticks) in non-shell tools, dangerous commands (`rm -rf /`, `chmod 777` including `chmod 0777` octal and the full setuid/setgid/sticky combined-bit range `[0-7]?777` which covers 1777, 2777, 3777, 4777, 5777, 6777, 7777, `curl | sh/bash/python/ruby/perl/node/php/fish`, `wget | sh`, `dd if=/dev/`, `mkfs`), and blacklisted env overrides (`GIT_SSH_COMMAND`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`) matched case-insensitively (via `strings.EqualFold`, also whitespace-trimmed before comparison so padded keys like ` GIT_SSH_COMMAND ` cannot bypass) and recursively scanned through the full arg tree under any env-style slot (`env`, `envs`, `env_vars`, `envvars`, `environment`, `environments`, `environment_variables`, `environmentvariables`, `vars`). Command-string scanning is field-scoped: preferred command slots (`command`, `cmd`, `script`, `code`, `args`, `arguments`, `argv`) are always scanned, plus known smuggle slots (`input`, `stdin`, `body`, `data`, `payload`) when any preferred slot is present. Prose fields (`description`, `notes`, `comment`, `documentation`, `summary`, `title`, `name`) are never scanned because legitimate tool metadata can mention `bash -c` or `rm -rf /` as example text and would false-positive. Top-level non-object payloads (arrays, strings) are scanned as a whole because there is no field structure to lean on. Returned command strings are sorted before inspection so the first-match category is deterministic across runs. Dedicated shell tools (matched by the anchored globs `*__shell`, `*__bash`, or literal `shell`/`bash`) skip the metacharacter check because legitimate shell invocations contain `$`, `|`, etc. (e.g. `echo $HOME`). Trampoline and dangerous-command checks still apply. Because the shell-tool globs are anchored on `__`, tools like `github__shellcheck` and `vim__bashsyntax` will still receive the metacharacter check despite the substring match on the broader ExecTool globs (`*shell*`). That is by design: shellcheck is a linter, not a shell, so it must not get the shell-tool metachar bypass.
270+
271+
Wired in both production entry points via `mcp.NewExecInspector(nil)` which compiles the default patterns. The two entry points are `cmd/sluice/main.go` (the `sluice` command, which runs the full proxy plus MCP gateway) and `cmd/sluice/mcp.go` (the `sluice mcp` subcommand, which runs only the MCP gateway standalone). Both need wiring so the standalone mode is not silently missing exec inspection. A block emits an `exec_block` audit event with `Reason` set to `category:match` (e.g. `trampoline:bash -c` or `env_override:GIT_SSH_COMMAND`) for forensics, then returns an error ToolResult. This is separate from ContentInspector because exec inspection needs structural understanding of command arguments rather than pattern matching on arbitrary text.
272+
235273
### Vault providers
236274

237275
Seven providers via `Provider` interface. `NewProviderFromConfig` reads from SQLite config singleton:

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,17 @@ curl -X POST http://localhost:3000/api/credentials \
304304
-d '{"name":"openai_oauth","type":"oauth","token_url":"https://auth.example.com/token","access_token":"at-xxx","refresh_token":"rt-xxx","destination":"api.openai.com","env_var":"OPENAI_API_KEY"}'
305305
```
306306

307+
## Data Loss Prevention
308+
309+
Two complementary inspection layers protect against credential leakage and dangerous tool use:
310+
311+
**Exec argument inspection** (MCP layer): Tools whose names match `*exec*`, `*shell*`, `*run_command*`, or `*terminal*` patterns are scanned for trampoline interpreters (`bash -c`, `python -c`, `node -e`, ...), dangerous commands (`rm -rf /`, `chmod 777`/`chmod 0777`, `curl | sh` piped to any shell or scripting language, `dd if=/dev/`, `mkfs`), and blacklisted env overrides (`GIT_SSH_COMMAND`, `LD_PRELOAD`, `DYLD_*`). Blocks emit an `exec_block` audit event. Dedicated shell tools still accept legitimate `$VAR` expansion.
312+
313+
**Response DLP** (MITM layer): HTTPS response bodies and headers are scanned for credential patterns defined via `[[redact]]` rules in policy. Matches are redacted before the response reaches the agent. Catches credentials echoed in API errors, leaked by debug endpoints, or returned by misconfigured services. Supports `gzip`, `br`, `deflate`, and `zstd` compressed bodies (decompressed before scanning, recompressed headers stripped). Binary content types (images, fonts, archives) skip scanning. Redactions emit a `response_dlp_redact` audit event.
314+
307315
## Audit Log
308316

309-
Tamper-evident JSON Lines log with blake3 hash chaining. Every connection, tool call, approval, and denial is recorded.
317+
Tamper-evident JSON Lines log with blake3 hash chaining. Every connection, tool call, approval, and denial is recorded. Common action names include `tool_call`, `inspect_block`, `exec_block`, `response_dlp_redact`, and `inject`.
310318

311319
```bash
312320
sluice audit verify # check hash chain integrity

cmd/sluice/cert_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func TestCertGenerate(t *testing.T) {
5050
certBlock, _ := pem.Decode(certData)
5151
if certBlock == nil {
5252
t.Fatal("ca-cert.pem is not valid PEM")
53+
return
5354
}
5455
if certBlock.Type != "CERTIFICATE" {
5556
t.Errorf("unexpected PEM type: %s", certBlock.Type)
@@ -58,6 +59,7 @@ func TestCertGenerate(t *testing.T) {
5859
keyBlock, _ := pem.Decode(keyData)
5960
if keyBlock == nil {
6061
t.Fatal("ca-key.pem is not valid PEM")
62+
return
6163
}
6264
if keyBlock.Type != "EC PRIVATE KEY" {
6365
t.Errorf("unexpected key PEM type: %s", keyBlock.Type)

cmd/sluice/cred_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,7 @@ func TestHandleCredAddOAuth(t *testing.T) {
913913
}
914914
if meta == nil {
915915
t.Fatal("expected credential_meta row")
916+
return
916917
}
917918
if meta.CredType != "oauth" {
918919
t.Errorf("meta cred_type = %q, want %q", meta.CredType, "oauth")
@@ -1032,6 +1033,7 @@ func TestHandleCredAddOAuthWithoutDestination(t *testing.T) {
10321033
}
10331034
if meta == nil {
10341035
t.Fatal("expected credential_meta even without --destination")
1036+
return
10351037
}
10361038
if meta.CredType != "oauth" {
10371039
t.Errorf("cred_type = %q, want oauth", meta.CredType)
@@ -1185,6 +1187,7 @@ func TestHandleCredAddOAuthCreationFlow(t *testing.T) {
11851187
}
11861188
if meta == nil {
11871189
t.Fatal("expected credential_meta row")
1190+
return
11881191
}
11891192
if meta.CredType != "oauth" {
11901193
t.Errorf("meta cred_type = %q, want oauth", meta.CredType)

cmd/sluice/main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,15 @@ func main() {
487487
}
488488
}
489489

490+
// Wire the exec argument inspector with default tool name patterns
491+
// (*exec*, *shell*, *run_command*, *terminal*). Blocks trampoline
492+
// patterns, dangerous commands, and GIT_SSH_COMMAND-style env
493+
// overrides before the tool call reaches the upstream.
494+
execInspector, execErr := mcp.NewExecInspector(nil)
495+
if execErr != nil {
496+
log.Fatalf("create MCP exec inspector: %v", execErr)
497+
}
498+
490499
var credResolver mcp.CredentialResolver
491500
if provider != nil {
492501
credResolver = func(name string) (string, error) {
@@ -504,6 +513,7 @@ func main() {
504513
Upstreams: mcpUpstreams,
505514
ToolPolicy: toolPolicy,
506515
Inspector: mcpInspector,
516+
ExecInspector: execInspector,
507517
Audit: logger,
508518
Broker: broker,
509519
TimeoutSec: eng.TimeoutSec,

cmd/sluice/mcp.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ func handleMCPGateway(args []string) error {
179179
len(eng.InspectBlockRules), len(eng.InspectRedactRules))
180180
}
181181

182+
// Wire the exec argument inspector with default tool name patterns
183+
// (*exec*, *shell*, *run_command*, *terminal*). Blocks trampoline
184+
// patterns, dangerous commands, and GIT_SSH_COMMAND-style env
185+
// overrides before the tool call reaches the upstream.
186+
execInspector, err := mcp.NewExecInspector(nil)
187+
if err != nil {
188+
return fmt.Errorf("create exec inspector: %w", err)
189+
}
190+
182191
// Build credential resolver so vault: prefixed env values in upstream
183192
// configs are resolved to real credentials.
184193
var credResolver mcp.CredentialResolver
@@ -206,6 +215,7 @@ func handleMCPGateway(args []string) error {
206215
Upstreams: upstreams,
207216
ToolPolicy: toolPolicy,
208217
Inspector: inspector,
218+
ExecInspector: execInspector,
209219
Audit: logger,
210220
Broker: broker,
211221
TimeoutSec: eng.TimeoutSec,

cmd/sluice/mcp_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,3 +991,40 @@ func TestMCPGatewayStoreBackedUpstreams(t *testing.T) {
991991
t.Errorf("expected default timeout 120, got %d", fsUpstream.TimeoutSec)
992992
}
993993
}
994+
995+
// TestDefaultExecInspectorConstructs verifies that the production code
996+
// paths (cmd/sluice/main.go and cmd/sluice/mcp.go) can construct a default
997+
// ExecInspector without error. If NewExecInspector(nil) ever starts
998+
// erroring on default patterns, this smoke test fails and prevents the
999+
// CRITICAL regression where ExecInspector silently gets nil-ed out in
1000+
// production.
1001+
//
1002+
// This test checks ONLY that NewExecInspector(nil) succeeds and the
1003+
// returned inspector can ShouldInspect + Inspect a simple case. It does
1004+
// NOT exercise wiring into NewGateway. The wiring through Gateway is
1005+
// covered end-to-end by TestGatewayExecInspector* in the mcp package,
1006+
// which constructs a real Gateway and asserts the block path executes.
1007+
// The historical name was misleading (it implied wiring verification);
1008+
// this rename makes the scope explicit.
1009+
func TestDefaultExecInspectorConstructs(t *testing.T) {
1010+
ei, err := mcp.NewExecInspector(nil)
1011+
if err != nil {
1012+
t.Fatalf("default ExecInspector construction failed: %v", err)
1013+
}
1014+
if ei == nil {
1015+
t.Fatal("NewExecInspector(nil) returned nil inspector with no error")
1016+
}
1017+
1018+
// Basic sanity: a trampoline pattern must be blocked for an
1019+
// exec-matching tool name. If defaults ever drift, this catches it.
1020+
if !ei.ShouldInspect("sandbox__exec") {
1021+
t.Error("default ExecInspector does not match *exec* tools")
1022+
}
1023+
res := ei.Inspect("sandbox__exec", []byte(`{"command":"bash -c 'evil'"}`))
1024+
if !res.Blocked {
1025+
t.Error("default ExecInspector should block trampoline patterns")
1026+
}
1027+
if res.Category != "trampoline" {
1028+
t.Errorf("expected category trampoline, got %q", res.Category)
1029+
}
1030+
}

0 commit comments

Comments
 (0)