"""Integration: with mitmproxy in front of pipelock, a credential POST sent over HTTPS is now blocked by pipelock's body-scan layer. This is the HTTPS variant of test_pipelock_blocks_secret_post — the two together prove the TLS-interception layer is doing the work the PRD targets. The earlier plain-HTTP test only fired because the agent was forced to bypass TLS; real Claude Code traffic to api.anthropic.com goes over CONNECT-tunneled HTTPS and would have slipped past pipelock prior to this PRD. End-to-end: drives `BottleBackend.prepare → launch` so the real image build, network plumbing, pipelock sidecar, mitmproxy sidecar, ephemeral CA generation, and trust-store install are all in the loop. """ 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 # Synthetic value shaped like a GitHub Personal Access Token; not a # real credential. Pipelock's default DLP rules pattern-match this # format and mitmproxy's addon short-circuits with the 403 it # receives back. _FAKE_TOKEN = "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ" # Build the request by hand using stdlib `http` (for CONNECT) and # `tls` (for the bumped tunnel). Node 22's `fetch` doesn't expose # proxy configuration without undici as an installable dep, and # this project keeps the bottle image dep-light. NODE_EXTRA_CA_CERTS # is wired by launch.py so the agent trusts mitmproxy's bumped cert. # # Output contract (parsed by the test): # - "status=" HTTP status of the decrypted response # - "bridge=" X-Pipelock-Bridge header from the addon's # short-circuit, empty on the allow path # - "error=<...>" thrown error _PROBE_JS = r""" const http = require('http'); const tls = require('tls'); const proxy = new URL(process.env.HTTPS_PROXY); const body = 'token=' + process.env.FAKE_TOKEN; const connectReq = http.request({ host: proxy.hostname, port: proxy.port, method: 'CONNECT', path: 'api.anthropic.com:443', }); connectReq.setTimeout(8000, () => { console.log('timeout=connect'); connectReq.destroy(); }); connectReq.on('error', (e) => { console.log('error=' + (e.code || '') + ' ' + e.message); }); connectReq.on('connect', (res, socket) => { if (res.statusCode !== 200) { console.log('status=' + res.statusCode); console.log('bridge=' + (res.headers['x-pipelock-bridge'] || '')); return; } const tlsSocket = tls.connect({ socket: socket, servername: 'api.anthropic.com', }); tlsSocket.on('secureConnect', () => { tlsSocket.write( 'POST /dlp-probe HTTP/1.1\r\n' + 'Host: api.anthropic.com\r\n' + 'Content-Type: application/x-www-form-urlencoded\r\n' + 'Content-Length: ' + Buffer.byteLength(body) + '\r\n' + 'Connection: close\r\n' + '\r\n' + body ); }); let buf = ''; tlsSocket.on('data', (c) => { buf += c.toString('utf8'); }); tlsSocket.on('end', () => { const lines = buf.split('\r\n'); const m = lines[0].match(/HTTP\/[\d.]+ (\d+)/); let bridge = ''; for (let i = 1; i < lines.length; i++) { if (lines[i] === '') break; const ix = lines[i].indexOf(': '); if (ix < 0) continue; if (lines[i].slice(0, ix).toLowerCase() === 'x-pipelock-bridge') { bridge = lines[i].slice(ix + 2); } } console.log('status=' + (m ? m[1] : '?')); console.log('bridge=' + bridge); }); tlsSocket.on('error', (e) => { console.log('tls_error=' + (e.code || '') + ' ' + e.message); }); }); connectReq.end(); """ @skip_unless_docker() class TestMitmproxyBlocksSecretHttpsPost(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_https_post_with_credential_body_is_blocked(self): manifest = Manifest.from_json_obj({ "bottles": { "dev": {"env": {"FAKE_TOKEN": _FAKE_TOKEN}}, }, "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 -e\n" "cat > /tmp/probe.js <<'PROBE_EOF'\n" f"{_PROBE_JS}\n" "PROBE_EOF\n" "node /tmp/probe.js\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} stderr={result.stderr!r}", ) # The addon short-circuits the flow with X-Pipelock-Bridge: block # on a pipelock block — the cleanest signal that the chain # mitmproxy(bump) -> addon(forward) -> pipelock(scan) -> block # all happened, end to end. self.assertIn( "status=403", result.stdout, f"expected 403 from pipelock block; got: {result.stdout!r}", ) self.assertIn( "bridge=block", result.stdout, f"X-Pipelock-Bridge header missing; the addon may not be " f"in path: {result.stdout!r}", ) if __name__ == "__main__": unittest.main()