diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index 7721968..db85926 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -99,29 +99,35 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]: return sorted(seen.keys()) -def pipelock_effective_suppress(bottle: Bottle) -> list[dict[str, str]]: - """Per-bottle pipelock detector suppressions. +def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool: + """Whether pipelock's BIP-39 seed-phrase detector stays on for + this bottle. - Pipelock's `suppress:` block silences a named rule on a path glob. - LLM conversation bodies legitimately trip detectors that look for - natural-language token shapes — most famously the BIP-39 seed- - phrase detector, which fires on any 12+ English words that pass - the BIP-39 checksum. The direct path to `api.anthropic.com` is - already on tls_interception.passthrough_domains so no body scan - runs there, but the cred-proxy hop (where the agent dials - `http://cred-proxy:9099/anthropic/...`) is plain HTTP through - pipelock — body scanning fires. + LLM conversation bodies legitimately trip the detector — any 12+ + English words that pass the BIP-39 checksum match — so any + bottle that routes claude through pipelock's body scanner gets + blocked on the first real chat. We tried two narrower knobs + first: - For each route with the `anthropic-base-url` role, suppress - BIP-39 on `**` so claude-code's chat bodies make it - through. All other detectors (credit-card, IBAN, token regexes, - etc.) keep firing — those are unambiguous credential shapes - that have no legitimate reason to appear in a chat completion.""" - out: list[dict[str, str]] = [] - for r in bottle.cred_proxy.routes: - if "anthropic-base-url" in r.Role: - out.append({"rule": "BIP-39 Seed Phrase", "path": f"{r.Path}**"}) - return out + - `suppress: [{rule, path}]` — pipelock accepts the schema + but the entry only silences the alert; the body_dlp block + still fires. + - `rules.disabled: ["dlp:BIP-39 Seed Phrase"]` — same shape, + same outcome: 403 still returned. + + Empirically only `seed_phrase_detection.enabled: false` + actually stops the block (verified by sending a 12-word BIP-39 + body through three pipelock instances). It is a global toggle + — there is no per-path / per-host knob in pipelock 2.3.0 — so + we turn the detector off for the entire bottle when an + `anthropic-base-url` route is declared. The trade-off is + accepted: BIP-39 detection has little value in claude-bottle's + threat model (the agent has no access to a user's crypto + wallet seeds; the patterns that matter — gh*_, sk-ant-, AKIA, + etc. — keep firing).""" + return not any( + "anthropic-base-url" in r.Role for r in bottle.cred_proxy.routes + ) def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]: @@ -210,9 +216,8 @@ def pipelock_build_config( "api_allowlist": pipelock_effective_allowlist(bottle), "forward_proxy": {"enabled": True}, } - suppress = pipelock_effective_suppress(bottle) - if suppress: - cfg["suppress"] = suppress + if not pipelock_seed_phrase_detection_enabled(bottle): + cfg["seed_phrase_detection"] = {"enabled": False} cfg["dlp"] = {"include_defaults": True, "scan_env": True} # Body-scan enforcement is a separate pipelock section (each DLP # "surface" — body, MCP, response — has its own action). Pipelock's @@ -253,11 +258,10 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str: for h in cast(list[str], cfg["api_allowlist"]): lines.append(f' - "{h}"') lines.append("") - if "suppress" in cfg: - lines.append("suppress:") - for entry in cast(list[dict[str, str]], cfg["suppress"]): - lines.append(f' - rule: "{entry["rule"]}"') - lines.append(f' path: "{entry["path"]}"') + if "seed_phrase_detection" in cfg: + lines.append("seed_phrase_detection:") + spd = cast(dict[str, object], cfg["seed_phrase_detection"]) + lines.append(f" enabled: {_bool(spd['enabled'])}") lines.append("") lines.append("forward_proxy:") fp = cast(dict[str, object], cfg["forward_proxy"]) diff --git a/tests/unit/test_pipelock_yaml.py b/tests/unit/test_pipelock_yaml.py index f6fefd8..68caed6 100644 --- a/tests/unit/test_pipelock_yaml.py +++ b/tests/unit/test_pipelock_yaml.py @@ -92,15 +92,21 @@ class TestBuildConfig(unittest.TestCase): self.assertIn("ssrf", cfg) self.assertEqual({"ip_allowlist": ["172.20.0.0/16"]}, cfg["ssrf"]) - def test_suppress_absent_when_no_anthropic_route(self): + def test_seed_phrase_detection_left_at_default_when_no_anthropic_route(self): + # No override emitted -> pipelock keeps its built-in default + # (BIP-39 detection enabled). Bottles that don't carry an + # Anthropic route don't need the false-positive workaround. cfg = pipelock_build_config(fixture_minimal().bottles["dev"]) - self.assertNotIn("suppress", cfg) + self.assertNotIn("seed_phrase_detection", cfg) - def test_suppress_emits_bip39_for_anthropic_route(self): + def test_seed_phrase_detection_disabled_for_anthropic_route(self): # claude-code's chat bodies trip pipelock's BIP-39 detector - # (12+ English words). Suppress just that detector on the - # cred-proxy's anthropic path — all the other DLP patterns - # keep firing. + # (12+ English words that pass the checksum). pipelock 2.3.0 + # has no per-path knob for this detector, and both `suppress` + # and `rules.disabled` only silence alerts — the block still + # fires. The only knob that actually skips the block is the + # global on/off, so we flip it off whenever the bottle is set + # up to route claude through pipelock. from claude_bottle.manifest import Manifest bottle = Manifest.from_json_obj({ "bottles": {"dev": {"cred_proxy": {"routes": [ @@ -112,10 +118,7 @@ class TestBuildConfig(unittest.TestCase): "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] cfg = pipelock_build_config(bottle) - self.assertEqual( - [{"rule": "BIP-39 Seed Phrase", "path": "/anthropic/**"}], - cfg["suppress"], - ) + self.assertEqual({"enabled": False}, cfg["seed_phrase_detection"]) class TestRenderAndWrite(unittest.TestCase): @@ -200,7 +203,7 @@ class TestRenderAndWrite(unittest.TestCase): self.assertIn("ip_allowlist:", text) self.assertIn('- "172.20.0.0/16"', text) - def test_render_emits_suppress_block_for_anthropic_route(self): + def test_render_emits_seed_phrase_off_for_anthropic_route(self): from claude_bottle.manifest import Manifest bottle = Manifest.from_json_obj({ "bottles": {"dev": {"cred_proxy": {"routes": [ @@ -212,9 +215,8 @@ class TestRenderAndWrite(unittest.TestCase): "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] text = pipelock_render_yaml(pipelock_build_config(bottle)) - self.assertIn("suppress:", text) - self.assertIn('rule: "BIP-39 Seed Phrase"', text) - self.assertIn('path: "/anthropic/**"', text) + self.assertIn("seed_phrase_detection:", text) + self.assertIn("enabled: false", text) if __name__ == "__main__":