PRD 0010: Credential proxy for agent-bound API tokens #14

Merged
didericis merged 24 commits from cred-proxy into main 2026-05-24 14:24:52 -04:00
2 changed files with 49 additions and 43 deletions
Showing only changes of commit 4662087b32 - Show all commits
+33 -29
View File
@@ -99,29 +99,35 @@ def pipelock_effective_allowlist(bottle: Bottle) -> list[str]:
return sorted(seen.keys()) return sorted(seen.keys())
def pipelock_effective_suppress(bottle: Bottle) -> list[dict[str, str]]: def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool:
"""Per-bottle pipelock detector suppressions. """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 the detector — any 12+
LLM conversation bodies legitimately trip detectors that look for English words that pass the BIP-39 checksum match — so any
natural-language token shapes — most famously the BIP-39 seed- bottle that routes claude through pipelock's body scanner gets
phrase detector, which fires on any 12+ English words that pass blocked on the first real chat. We tried two narrower knobs
the BIP-39 checksum. The direct path to `api.anthropic.com` is first:
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.
For each route with the `anthropic-base-url` role, suppress - `suppress: [{rule, path}]` — pipelock accepts the schema
BIP-39 on `<path>**` so claude-code's chat bodies make it but the entry only silences the alert; the body_dlp block
through. All other detectors (credit-card, IBAN, token regexes, still fires.
etc.) keep firing — those are unambiguous credential shapes - `rules.disabled: ["dlp:BIP-39 Seed Phrase"]` — same shape,
that have no legitimate reason to appear in a chat completion.""" same outcome: 403 still returned.
out: list[dict[str, str]] = []
for r in bottle.cred_proxy.routes: Empirically only `seed_phrase_detection.enabled: false`
if "anthropic-base-url" in r.Role: actually stops the block (verified by sending a 12-word BIP-39
out.append({"rule": "BIP-39 Seed Phrase", "path": f"{r.Path}**"}) body through three pipelock instances). It is a global toggle
return out — 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]: def pipelock_effective_tls_passthrough(bottle: Bottle) -> list[str]:
@@ -210,9 +216,8 @@ def pipelock_build_config(
"api_allowlist": pipelock_effective_allowlist(bottle), "api_allowlist": pipelock_effective_allowlist(bottle),
"forward_proxy": {"enabled": True}, "forward_proxy": {"enabled": True},
} }
suppress = pipelock_effective_suppress(bottle) if not pipelock_seed_phrase_detection_enabled(bottle):
if suppress: cfg["seed_phrase_detection"] = {"enabled": False}
cfg["suppress"] = suppress
cfg["dlp"] = {"include_defaults": True, "scan_env": True} cfg["dlp"] = {"include_defaults": True, "scan_env": True}
# Body-scan enforcement is a separate pipelock section (each DLP # Body-scan enforcement is a separate pipelock section (each DLP
# "surface" — body, MCP, response — has its own action). Pipelock's # "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"]): for h in cast(list[str], cfg["api_allowlist"]):
lines.append(f' - "{h}"') lines.append(f' - "{h}"')
lines.append("") lines.append("")
if "suppress" in cfg: if "seed_phrase_detection" in cfg:
lines.append("suppress:") lines.append("seed_phrase_detection:")
for entry in cast(list[dict[str, str]], cfg["suppress"]): spd = cast(dict[str, object], cfg["seed_phrase_detection"])
lines.append(f' - rule: "{entry["rule"]}"') lines.append(f" enabled: {_bool(spd['enabled'])}")
lines.append(f' path: "{entry["path"]}"')
lines.append("") lines.append("")
lines.append("forward_proxy:") lines.append("forward_proxy:")
fp = cast(dict[str, object], cfg["forward_proxy"]) fp = cast(dict[str, object], cfg["forward_proxy"])
+16 -14
View File
@@ -92,15 +92,21 @@ class TestBuildConfig(unittest.TestCase):
self.assertIn("ssrf", cfg) self.assertIn("ssrf", cfg)
self.assertEqual({"ip_allowlist": ["172.20.0.0/16"]}, cfg["ssrf"]) 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"]) 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 # claude-code's chat bodies trip pipelock's BIP-39 detector
# (12+ English words). Suppress just that detector on the # (12+ English words that pass the checksum). pipelock 2.3.0
# cred-proxy's anthropic path — all the other DLP patterns # has no per-path knob for this detector, and both `suppress`
# keep firing. # 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 from claude_bottle.manifest import Manifest
bottle = Manifest.from_json_obj({ bottle = Manifest.from_json_obj({
"bottles": {"dev": {"cred_proxy": {"routes": [ "bottles": {"dev": {"cred_proxy": {"routes": [
@@ -112,10 +118,7 @@ class TestBuildConfig(unittest.TestCase):
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
cfg = pipelock_build_config(bottle) cfg = pipelock_build_config(bottle)
self.assertEqual( self.assertEqual({"enabled": False}, cfg["seed_phrase_detection"])
[{"rule": "BIP-39 Seed Phrase", "path": "/anthropic/**"}],
cfg["suppress"],
)
class TestRenderAndWrite(unittest.TestCase): class TestRenderAndWrite(unittest.TestCase):
@@ -200,7 +203,7 @@ class TestRenderAndWrite(unittest.TestCase):
self.assertIn("ip_allowlist:", text) self.assertIn("ip_allowlist:", text)
self.assertIn('- "172.20.0.0/16"', 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 from claude_bottle.manifest import Manifest
bottle = Manifest.from_json_obj({ bottle = Manifest.from_json_obj({
"bottles": {"dev": {"cred_proxy": {"routes": [ "bottles": {"dev": {"cred_proxy": {"routes": [
@@ -212,9 +215,8 @@ class TestRenderAndWrite(unittest.TestCase):
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
text = pipelock_render_yaml(pipelock_build_config(bottle)) text = pipelock_render_yaml(pipelock_build_config(bottle))
self.assertIn("suppress:", text) self.assertIn("seed_phrase_detection:", text)
self.assertIn('rule: "BIP-39 Seed Phrase"', text) self.assertIn("enabled: false", text)
self.assertIn('path: "/anthropic/**"', text)
if __name__ == "__main__": if __name__ == "__main__":