fix(pipelock): passthrough api.anthropic.com so Claude auth/chat works
Pipelock's BIP-39 seed-phrase scanner fires on Anthropic Messages API bodies because user-authored conversation text can hit 12 consecutive BIP-39 dictionary words that pass the checksum, returning a 403 `blocked: request body contains secret: BIP-39 Seed Phrase` that the Claude CLI surfaces as `Please run /login`. Pipelock's `suppress` section only covers git/file findings, not the inline body scanner, so the recommended treatment for LLM endpoints is `tls_interception.passthrough_domains`: CONNECT is still allowlist- gated, but the body is not MITM'd. The existing body-scan integration test moves to `raw.githubusercontent.com` so it still pins TLS body DLP on non-passthrough'd hosts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,22 @@ DEFAULT_ALLOWLIST: tuple[str, ...] = (
|
|||||||
"raw.githubusercontent.com",
|
"raw.githubusercontent.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Hosts pipelock should NOT TLS-MITM, even when tls_interception is
|
||||||
|
# enabled. The Claude API endpoint is an LLM provider — its request
|
||||||
|
# bodies are user-authored conversation text that legitimately can
|
||||||
|
# trigger DLP scanners (notably the BIP-39 seed-phrase detector, which
|
||||||
|
# fires on any 12+ consecutive English words that happen to be on the
|
||||||
|
# BIP-39 wordlist and pass the checksum). Per pipelock's own
|
||||||
|
# configuration.md, the recommended treatment for LLM API endpoints is
|
||||||
|
# `passthrough_domains`: pipelock still proxies the CONNECT (so the
|
||||||
|
# api_allowlist gate applies), but it does not generate a leaf cert or
|
||||||
|
# decrypt the body. Body scanning happens on hosts that aren't
|
||||||
|
# passthrough'd, so DLP protection against agent exfil to other
|
||||||
|
# allowlisted hosts is unchanged.
|
||||||
|
DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = (
|
||||||
|
"api.anthropic.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Allowlist resolution --------------------------------------------------
|
# --- Allowlist resolution --------------------------------------------------
|
||||||
|
|
||||||
@@ -119,6 +135,7 @@ def pipelock_build_config(
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"ca_cert": ca_cert_path,
|
"ca_cert": ca_cert_path,
|
||||||
"ca_key": ca_key_path,
|
"ca_key": ca_key_path,
|
||||||
|
"passthrough_domains": list(DEFAULT_TLS_PASSTHROUGH),
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
@@ -158,6 +175,11 @@ def pipelock_render_yaml(cfg: dict[str, object]) -> str:
|
|||||||
lines.append(f" enabled: {_bool(tls['enabled'])}")
|
lines.append(f" enabled: {_bool(tls['enabled'])}")
|
||||||
lines.append(f' ca_cert: "{tls["ca_cert"]}"')
|
lines.append(f' ca_cert: "{tls["ca_cert"]}"')
|
||||||
lines.append(f' ca_key: "{tls["ca_key"]}"')
|
lines.append(f' ca_key: "{tls["ca_key"]}"')
|
||||||
|
passthrough = cast(list[str], tls.get("passthrough_domains", []))
|
||||||
|
if passthrough:
|
||||||
|
lines.append(" passthrough_domains:")
|
||||||
|
for d in passthrough:
|
||||||
|
lines.append(f' - "{d}"')
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,18 @@ End-to-end: drives `BottleBackend.prepare → launch` so the real
|
|||||||
image build, network plumbing, pipelock_tls_init, sidecar bring-up,
|
image build, network plumbing, pipelock_tls_init, sidecar bring-up,
|
||||||
and provision_ca (CA install in the agent's trust store) are all in
|
and provision_ca (CA install in the agent's trust store) are all in
|
||||||
the loop. The probe is a single `curl --proxy "$HTTPS_PROXY" -X POST
|
the loop. The probe is a single `curl --proxy "$HTTPS_PROXY" -X POST
|
||||||
... https://api.anthropic.com/...` — curl natively does CONNECT
|
... https://raw.githubusercontent.com/...` — curl natively does
|
||||||
through the proxy, the agent's trust store now contains pipelock's
|
CONNECT through the proxy, the agent's trust store now contains
|
||||||
per-bottle CA so curl trusts pipelock's bumped leaf, and pipelock
|
pipelock's per-bottle CA so curl trusts pipelock's bumped leaf, and
|
||||||
sees the decrypted body and returns its known
|
pipelock sees the decrypted body and returns its known
|
||||||
`blocked: request body contains secret: <pattern>` 403."""
|
`blocked: request body contains secret: <pattern>` 403.
|
||||||
|
|
||||||
|
The host has to be allowlisted (so the CONNECT is accepted) but NOT
|
||||||
|
in `tls_interception.passthrough_domains` (so the body actually gets
|
||||||
|
scanned). `api.anthropic.com` is passthrough'd to skip MITM on the
|
||||||
|
LLM endpoint, so this probe targets `raw.githubusercontent.com` —
|
||||||
|
also on the baked allowlist (Claude Code fetches release assets from
|
||||||
|
it) and intercepted+scanned like any non-passthrough host."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -66,7 +73,7 @@ class TestPipelockBlocksSecretHttpsPost(unittest.TestCase):
|
|||||||
" -w 'status=%{http_code}\\n' \\\n"
|
" -w 'status=%{http_code}\\n' \\\n"
|
||||||
" -o /tmp/probe-body.txt \\\n"
|
" -o /tmp/probe-body.txt \\\n"
|
||||||
' -X POST -d "token=$FAKE_TOKEN" \\\n'
|
' -X POST -d "token=$FAKE_TOKEN" \\\n'
|
||||||
" https://api.anthropic.com/dlp-probe\n"
|
" https://raw.githubusercontent.com/dlp-probe\n"
|
||||||
'echo "body=$(head -c 200 /tmp/probe-body.txt)"\n'
|
'echo "body=$(head -c 200 /tmp/probe-body.txt)"\n'
|
||||||
)
|
)
|
||||||
result = bottle.exec(script)
|
result = bottle.exec(script)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Integration: pipelock's `tls_interception.passthrough_domains`
|
||||||
|
exempts api.anthropic.com from MITM, so request bodies that would
|
||||||
|
otherwise trip the body-scan layer (notably the BIP-39 seed-phrase
|
||||||
|
detector firing on user-authored Claude conversation text) are not
|
||||||
|
inspected and the request reaches Anthropic's TLS endpoint.
|
||||||
|
|
||||||
|
Probe: POST the canonical zero-entropy 12-word BIP-39 mnemonic
|
||||||
|
(`abandon` × 11 + `about`) — checksum-valid by construction — to
|
||||||
|
`https://api.anthropic.com/v1/messages`. Without the passthrough,
|
||||||
|
pipelock returns a 403 `blocked: request body contains secret:
|
||||||
|
BIP-39 Seed Phrase`. With it, pipelock relays the CONNECT opaquely
|
||||||
|
and the upstream replies with whatever it likes (401/4xx from
|
||||||
|
Anthropic for an unauthenticated junk POST). We assert that the
|
||||||
|
verdict is NOT pipelock's block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from claude_bottle.backend import BottleSpec, get_bottle_backend
|
||||||
|
from claude_bottle.manifest import Manifest
|
||||||
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
|
# Canonical BIP-39 12-word test mnemonic. Valid SHA-256 checksum —
|
||||||
|
# pipelock's seed-phrase scanner (default `verify_checksum: true`)
|
||||||
|
# fires on this exact string if it ever sees the cleartext body.
|
||||||
|
_BIP39_PHRASE = (
|
||||||
|
"abandon abandon abandon abandon abandon abandon "
|
||||||
|
"abandon abandon abandon abandon abandon about"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@skip_unless_docker()
|
||||||
|
class TestPipelockLlmPassthrough(unittest.TestCase):
|
||||||
|
@unittest.skipIf(
|
||||||
|
os.environ.get("GITEA_ACTIONS") == "true",
|
||||||
|
"skipped under act_runner: docker socket mount topology breaks "
|
||||||
|
"in-process visibility of networks created on the host daemon",
|
||||||
|
)
|
||||||
|
def test_bip39_body_to_anthropic_is_not_blocked(self):
|
||||||
|
manifest = Manifest.from_json_obj({
|
||||||
|
"bottles": {
|
||||||
|
"dev": {"env": {"SEED": _BIP39_PHRASE}},
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
backend = get_bottle_backend()
|
||||||
|
stage_dir = Path(tempfile.mkdtemp(prefix="cb-test-stage."))
|
||||||
|
try:
|
||||||
|
spec = BottleSpec(
|
||||||
|
manifest=manifest,
|
||||||
|
agent_name="demo",
|
||||||
|
copy_cwd=False,
|
||||||
|
user_cwd=str(stage_dir),
|
||||||
|
forward_oauth_token=False,
|
||||||
|
)
|
||||||
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
|
with backend.launch(plan) as bottle:
|
||||||
|
script = (
|
||||||
|
"set -eu\n"
|
||||||
|
'curl --proxy "$HTTPS_PROXY" -s --max-time 10 \\\n'
|
||||||
|
" -w 'status=%{http_code}\\n' \\\n"
|
||||||
|
" -o /tmp/probe-body.txt \\\n"
|
||||||
|
' -X POST -H "content-type: application/json" \\\n'
|
||||||
|
' --data "{\\"phrase\\": \\"$SEED\\"}" \\\n'
|
||||||
|
" https://api.anthropic.com/v1/messages\n"
|
||||||
|
'echo "body=$(head -c 200 /tmp/probe-body.txt)"\n'
|
||||||
|
)
|
||||||
|
result = bottle.exec(script)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(stage_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
0, result.returncode,
|
||||||
|
f"exec wrapper failed: stdout={result.stdout!r} "
|
||||||
|
f"stderr={result.stderr!r}",
|
||||||
|
)
|
||||||
|
# The pipelock block verdict starts with `blocked: ` in the
|
||||||
|
# body. Anything else (auth error, 401, 4xx from Anthropic) is
|
||||||
|
# an acceptable outcome — it means the body was NOT inspected
|
||||||
|
# by the proxy and the request was relayed to the upstream
|
||||||
|
# TLS endpoint.
|
||||||
|
self.assertNotIn(
|
||||||
|
"body=blocked: ", result.stdout,
|
||||||
|
f"unexpected pipelock body-scan block on api.anthropic.com; "
|
||||||
|
f"expected passthrough to skip MITM. got: {result.stdout!r}",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"BIP-39", result.stdout,
|
||||||
|
f"BIP-39 verdict should never appear for api.anthropic.com "
|
||||||
|
f"requests under tls_interception.passthrough_domains; "
|
||||||
|
f"got: {result.stdout!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -14,7 +14,11 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from claude_bottle.backend.docker.pipelock import DockerPipelockProxy
|
from claude_bottle.backend.docker.pipelock import DockerPipelockProxy
|
||||||
from claude_bottle.manifest import Manifest
|
from claude_bottle.manifest import Manifest
|
||||||
from claude_bottle.pipelock import pipelock_build_config, pipelock_render_yaml
|
from claude_bottle.pipelock import (
|
||||||
|
DEFAULT_TLS_PASSTHROUGH,
|
||||||
|
pipelock_build_config,
|
||||||
|
pipelock_render_yaml,
|
||||||
|
)
|
||||||
from tests.fixtures import fixture_minimal, fixture_with_ssh
|
from tests.fixtures import fixture_minimal, fixture_with_ssh
|
||||||
|
|
||||||
|
|
||||||
@@ -56,7 +60,11 @@ class TestBuildConfig(unittest.TestCase):
|
|||||||
|
|
||||||
def test_tls_interception_block_emitted_when_paths_supplied(self):
|
def test_tls_interception_block_emitted_when_paths_supplied(self):
|
||||||
# PRD 0006: paths flow in via DockerPipelockProxy's in-container
|
# PRD 0006: paths flow in via DockerPipelockProxy's in-container
|
||||||
# constants; this directly pins the dict shape.
|
# constants; this directly pins the dict shape. passthrough_domains
|
||||||
|
# is baked in so LLM provider endpoints (api.anthropic.com) skip
|
||||||
|
# MITM — pipelock's docs explicitly recommend this for LLM hosts,
|
||||||
|
# and without it the BIP-39 body scanner false-positives on
|
||||||
|
# Claude conversation traffic.
|
||||||
cfg = pipelock_build_config(
|
cfg = pipelock_build_config(
|
||||||
fixture_minimal().bottles["dev"],
|
fixture_minimal().bottles["dev"],
|
||||||
ca_cert_path="/etc/pipelock-ca.pem",
|
ca_cert_path="/etc/pipelock-ca.pem",
|
||||||
@@ -67,9 +75,11 @@ class TestBuildConfig(unittest.TestCase):
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"ca_cert": "/etc/pipelock-ca.pem",
|
"ca_cert": "/etc/pipelock-ca.pem",
|
||||||
"ca_key": "/etc/pipelock-ca-key.pem",
|
"ca_key": "/etc/pipelock-ca-key.pem",
|
||||||
|
"passthrough_domains": list(DEFAULT_TLS_PASSTHROUGH),
|
||||||
},
|
},
|
||||||
cfg["tls_interception"],
|
cfg["tls_interception"],
|
||||||
)
|
)
|
||||||
|
self.assertIn("api.anthropic.com", DEFAULT_TLS_PASSTHROUGH)
|
||||||
|
|
||||||
def test_tls_interception_requires_both_paths(self):
|
def test_tls_interception_requires_both_paths(self):
|
||||||
# Half-set is a programmer error, not a silent omission.
|
# Half-set is a programmer error, not a silent omission.
|
||||||
@@ -135,9 +145,10 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
def test_render_emits_tls_interception_via_prepare(self):
|
def test_render_emits_tls_interception_via_prepare(self):
|
||||||
"""`DockerPipelockProxy.prepare` plumbs its in-container CA
|
"""`DockerPipelockProxy.prepare` plumbs its in-container CA
|
||||||
constants through to the YAML. The block should land in the
|
constants through to the YAML. The block should land in the
|
||||||
rendered output with `enabled: true` and the configured paths.
|
rendered output with `enabled: true`, the configured paths,
|
||||||
The actual host-side CA generation happens in launch (not
|
and the baked LLM-provider passthrough list. The actual
|
||||||
prepare), so this test exercises only the YAML rendering."""
|
host-side CA generation happens in launch (not prepare), so
|
||||||
|
this test exercises only the YAML rendering."""
|
||||||
plan = DockerPipelockProxy().prepare(
|
plan = DockerPipelockProxy().prepare(
|
||||||
fixture_minimal().bottles["dev"], "demo", self.out_dir
|
fixture_minimal().bottles["dev"], "demo", self.out_dir
|
||||||
)
|
)
|
||||||
@@ -146,6 +157,8 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
self.assertIn("enabled: true", content)
|
self.assertIn("enabled: true", content)
|
||||||
self.assertIn('ca_cert: "/etc/pipelock-ca.pem"', content)
|
self.assertIn('ca_cert: "/etc/pipelock-ca.pem"', content)
|
||||||
self.assertIn('ca_key: "/etc/pipelock-ca-key.pem"', content)
|
self.assertIn('ca_key: "/etc/pipelock-ca-key.pem"', content)
|
||||||
|
self.assertIn("passthrough_domains:", content)
|
||||||
|
self.assertIn('- "api.anthropic.com"', content)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user