claw-tap 0.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
claude_tap/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """claude-tap: Proxy to trace Claude Code API requests.
2
+
3
+ A CLI tool that wraps Claude Code with a local proxy (reverse or forward)
4
+ to intercept and record all API requests. Useful for studying Claude Code's
5
+ Context Engineering.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from claude_tap.certs import CertificateAuthority, ensure_ca
11
+ from claude_tap.cli import (
12
+ __version__,
13
+ _cleanup_traces,
14
+ async_main,
15
+ dashboard_main,
16
+ main_entry,
17
+ parse_args,
18
+ parse_dashboard_args,
19
+ )
20
+ from claude_tap.forward_proxy import ForwardProxyServer
21
+ from claude_tap.live import LiveViewerServer
22
+ from claude_tap.proxy import filter_headers
23
+ from claude_tap.session_dispatcher import SessionTraceDispatcher
24
+ from claude_tap.session_index import SessionIndex
25
+ from claude_tap.sse import SSEReassembler
26
+ from claude_tap.trace import TraceWriter
27
+ from claude_tap.viewer import _generate_html_viewer
28
+
29
+ __all__ = [
30
+ "__version__",
31
+ "_cleanup_traces",
32
+ "main_entry",
33
+ "parse_args",
34
+ "parse_dashboard_args",
35
+ "async_main",
36
+ "dashboard_main",
37
+ "CertificateAuthority",
38
+ "ensure_ca",
39
+ "ForwardProxyServer",
40
+ "SessionTraceDispatcher",
41
+ "SessionIndex",
42
+ "SSEReassembler",
43
+ "TraceWriter",
44
+ "LiveViewerServer",
45
+ "filter_headers",
46
+ "_generate_html_viewer",
47
+ ]
claude_tap/__main__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Allow running as `python -m claude_tap`."""
2
+
3
+ import asyncio
4
+ import sys
5
+
6
+ from claude_tap.cli import async_main, parse_args
7
+
8
+
9
+ def main():
10
+ # Check if first argument is "export" subcommand
11
+ if len(sys.argv) > 1 and sys.argv[1] == "export":
12
+ from claude_tap.export import export_main
13
+
14
+ sys.exit(export_main(sys.argv[2:]))
15
+
16
+ argv = sys.argv[1:]
17
+ if "--" in argv:
18
+ # Explicit separator: everything after "--" goes to claude
19
+ idx = argv.index("--")
20
+ our_args = argv[:idx]
21
+ claude_args = argv[idx + 1 :]
22
+ args = parse_args(our_args)
23
+ args.claude_args = claude_args
24
+ else:
25
+ # No separator: parse_args handles splitting via parse_known_args
26
+ args = parse_args(argv)
27
+ # claude_args already set by parse_args
28
+ try:
29
+ code = asyncio.run(async_main(args))
30
+ except KeyboardInterrupt:
31
+ code = 0
32
+ sys.exit(code)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
claude_tap/certs.py ADDED
@@ -0,0 +1,212 @@
1
+ """CA and per-host certificate generation for forward proxy TLS termination.
2
+
3
+ Generates a self-signed CA on first run and creates per-host certificates
4
+ signed by that CA. The CA cert/key are persisted to disk so they survive
5
+ restarts; host certs are cached in memory for the lifetime of the process.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import ipaddress
12
+ import logging
13
+ import ssl
14
+ from pathlib import Path
15
+
16
+ from cryptography import x509
17
+ from cryptography.hazmat.primitives import hashes, serialization
18
+ from cryptography.hazmat.primitives.asymmetric import rsa
19
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
20
+
21
+ log = logging.getLogger("claude-tap")
22
+
23
+ # Default directory for CA files
24
+ _DEFAULT_CA_DIR = Path.home() / ".claude-tap"
25
+
26
+ # CA validity: 5 years
27
+ _CA_VALIDITY_DAYS = 5 * 365
28
+ # Host cert validity: 1 year
29
+ _HOST_VALIDITY_DAYS = 365
30
+
31
+
32
+ def _generate_key() -> rsa.RSAPrivateKey:
33
+ return rsa.generate_private_key(public_exponent=65537, key_size=2048)
34
+
35
+
36
+ def ensure_ca(ca_dir: Path | None = None) -> tuple[Path, Path]:
37
+ """Ensure a CA certificate and key exist on disk.
38
+
39
+ Returns (ca_cert_path, ca_key_path). Creates them if they don't exist.
40
+ """
41
+ ca_dir = ca_dir or _DEFAULT_CA_DIR
42
+ ca_dir.mkdir(parents=True, exist_ok=True)
43
+
44
+ ca_cert_path = ca_dir / "ca.pem"
45
+ ca_key_path = ca_dir / "ca-key.pem"
46
+
47
+ if ca_cert_path.exists() and ca_key_path.exists():
48
+ # Validate existing files are loadable
49
+ try:
50
+ _load_ca(ca_cert_path, ca_key_path)
51
+ return ca_cert_path, ca_key_path
52
+ except Exception:
53
+ log.warning("Existing CA files are invalid, regenerating")
54
+
55
+ log.info(f"Generating new CA certificate in {ca_dir}")
56
+ key = _generate_key()
57
+ name = x509.Name(
58
+ [
59
+ x509.NameAttribute(NameOID.COMMON_NAME, "claude-tap CA"),
60
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "claude-tap"),
61
+ ]
62
+ )
63
+
64
+ now = datetime.datetime.now(datetime.timezone.utc)
65
+ cert = (
66
+ x509.CertificateBuilder()
67
+ .subject_name(name)
68
+ .issuer_name(name)
69
+ .public_key(key.public_key())
70
+ .serial_number(x509.random_serial_number())
71
+ .not_valid_before(now)
72
+ .not_valid_after(now + datetime.timedelta(days=_CA_VALIDITY_DAYS))
73
+ .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
74
+ .add_extension(
75
+ x509.KeyUsage(
76
+ digital_signature=True,
77
+ key_cert_sign=True,
78
+ crl_sign=True,
79
+ content_commitment=False,
80
+ key_encipherment=False,
81
+ data_encipherment=False,
82
+ key_agreement=False,
83
+ encipher_only=False,
84
+ decipher_only=False,
85
+ ),
86
+ critical=True,
87
+ )
88
+ .add_extension(
89
+ x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
90
+ critical=False,
91
+ )
92
+ .sign(key, hashes.SHA256())
93
+ )
94
+
95
+ ca_key_path.write_bytes(
96
+ key.private_bytes(
97
+ encoding=serialization.Encoding.PEM,
98
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
99
+ encryption_algorithm=serialization.NoEncryption(),
100
+ )
101
+ )
102
+ # Restrict key file permissions
103
+ ca_key_path.chmod(0o600)
104
+
105
+ ca_cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
106
+
107
+ log.info(f"CA certificate written to {ca_cert_path}")
108
+ return ca_cert_path, ca_key_path
109
+
110
+
111
+ def _load_ca(ca_cert_path: Path, ca_key_path: Path) -> tuple[x509.Certificate, rsa.RSAPrivateKey]:
112
+ """Load CA cert and key from PEM files."""
113
+ ca_cert = x509.load_pem_x509_certificate(ca_cert_path.read_bytes())
114
+ ca_key = serialization.load_pem_private_key(ca_key_path.read_bytes(), password=None)
115
+ return ca_cert, ca_key # type: ignore[return-value]
116
+
117
+
118
+ class CertificateAuthority:
119
+ """In-memory CA that generates per-host TLS certificates.
120
+
121
+ Caches generated host certs for the lifetime of the process.
122
+ """
123
+
124
+ def __init__(self, ca_cert_path: Path, ca_key_path: Path) -> None:
125
+ self._ca_cert, self._ca_key = _load_ca(ca_cert_path, ca_key_path)
126
+ self._host_cache: dict[str, tuple[bytes, bytes]] = {}
127
+
128
+ def get_host_cert_pem(self, hostname: str) -> tuple[bytes, bytes]:
129
+ """Return (cert_pem, key_pem) for the given hostname.
130
+
131
+ Generates and caches a new certificate signed by the CA if needed.
132
+ """
133
+ if hostname in self._host_cache:
134
+ return self._host_cache[hostname]
135
+
136
+ key = _generate_key()
137
+ now = datetime.datetime.now(datetime.timezone.utc)
138
+
139
+ subject = x509.Name(
140
+ [
141
+ x509.NameAttribute(NameOID.COMMON_NAME, hostname),
142
+ ]
143
+ )
144
+
145
+ # Build SAN: use IPAddress for IP addresses, DNSName for hostnames
146
+ san_names: list[x509.GeneralName] = []
147
+ try:
148
+ ip = ipaddress.ip_address(hostname)
149
+ san_names.append(x509.IPAddress(ip))
150
+ except ValueError:
151
+ san_names.append(x509.DNSName(hostname))
152
+
153
+ builder = (
154
+ x509.CertificateBuilder()
155
+ .subject_name(subject)
156
+ .issuer_name(self._ca_cert.subject)
157
+ .public_key(key.public_key())
158
+ .serial_number(x509.random_serial_number())
159
+ .not_valid_before(now)
160
+ .not_valid_after(now + datetime.timedelta(days=_HOST_VALIDITY_DAYS))
161
+ .add_extension(
162
+ x509.SubjectAlternativeName(san_names),
163
+ critical=False,
164
+ )
165
+ .add_extension(
166
+ x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
167
+ critical=False,
168
+ )
169
+ .add_extension(
170
+ x509.AuthorityKeyIdentifier.from_issuer_public_key(self._ca_key.public_key()),
171
+ critical=False,
172
+ )
173
+ .add_extension(
174
+ x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
175
+ critical=False,
176
+ )
177
+ )
178
+
179
+ cert = builder.sign(self._ca_key, hashes.SHA256())
180
+
181
+ cert_pem = cert.public_bytes(serialization.Encoding.PEM)
182
+ key_pem = key.private_bytes(
183
+ encoding=serialization.Encoding.PEM,
184
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
185
+ encryption_algorithm=serialization.NoEncryption(),
186
+ )
187
+
188
+ self._host_cache[hostname] = (cert_pem, key_pem)
189
+ return cert_pem, key_pem
190
+
191
+ def make_ssl_context(self, hostname: str) -> ssl.SSLContext:
192
+ """Create an SSL context for serving TLS as the given hostname."""
193
+ import tempfile
194
+
195
+ cert_pem, key_pem = self.get_host_cert_pem(hostname)
196
+
197
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
198
+ # Write cert and key to temp files (ssl module needs file paths)
199
+ with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as cf:
200
+ cf.write(cert_pem)
201
+ cert_path = cf.name
202
+ with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as kf:
203
+ kf.write(key_pem)
204
+ key_path = kf.name
205
+
206
+ try:
207
+ ctx.load_cert_chain(cert_path, key_path)
208
+ finally:
209
+ Path(cert_path).unlink(missing_ok=True)
210
+ Path(key_path).unlink(missing_ok=True)
211
+
212
+ return ctx
@@ -0,0 +1,42 @@
1
+ """Claw session ID extraction and normalization for multi-session traces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from collections.abc import Mapping
8
+
9
+ # Header clients send to route traces; stripped before forwarding upstream.
10
+ CLAW_SESSION_HEADER = "claw-session-id"
11
+
12
+ _MAX_SLUG_LEN = 48
13
+
14
+
15
+ def extract_claw_session_id(headers: Mapping[str, str]) -> str | None:
16
+ """Return the claw session id from headers, or ``None`` if absent or blank (no tracing)."""
17
+ for key, value in headers.items():
18
+ if key.lower() == CLAW_SESSION_HEADER:
19
+ if isinstance(value, str):
20
+ stripped = value.strip()
21
+ return stripped if stripped else None
22
+ return None
23
+ return None
24
+
25
+
26
+ def strip_claw_session_header(headers: dict[str, str]) -> None:
27
+ """Remove claw-session-id from a mutable header dict (any key casing)."""
28
+ to_drop = [k for k in list(headers.keys()) if k.lower() == CLAW_SESSION_HEADER]
29
+ for k in to_drop:
30
+ del headers[k]
31
+
32
+
33
+ def sanitize_filename_suffix(raw: str) -> str:
34
+ """Map a session id to a short filesystem-safe component (may truncate)."""
35
+ compact = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw.strip()).strip("._-")
36
+ if not compact:
37
+ compact = "session"
38
+ if len(compact) > _MAX_SLUG_LEN:
39
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
40
+ head = compact[: max(8, _MAX_SLUG_LEN - 13)]
41
+ compact = f"{head}_{digest}"
42
+ return compact