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 +47 -0
- claude_tap/__main__.py +36 -0
- claude_tap/certs.py +212 -0
- claude_tap/claw_session.py +42 -0
- claude_tap/cli.py +830 -0
- claude_tap/cursor_transcript.py +206 -0
- claude_tap/export.py +266 -0
- claude_tap/forward_proxy.py +822 -0
- claude_tap/live.py +308 -0
- claude_tap/proxy.py +760 -0
- claude_tap/py.typed +0 -0
- claude_tap/session_dispatcher.py +133 -0
- claude_tap/session_index.py +217 -0
- claude_tap/sse.py +243 -0
- claude_tap/trace.py +80 -0
- claude_tap/viewer.html +4154 -0
- claude_tap/viewer.py +366 -0
- claw_tap-0.0.5.dist-info/METADATA +259 -0
- claw_tap-0.0.5.dist-info/RECORD +23 -0
- claw_tap-0.0.5.dist-info/WHEEL +5 -0
- claw_tap-0.0.5.dist-info/entry_points.txt +2 -0
- claw_tap-0.0.5.dist-info/licenses/LICENSE +21 -0
- claw_tap-0.0.5.dist-info/top_level.txt +1 -0
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
|