0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
412 lines
15 KiB
Python
412 lines
15 KiB
Python
"""Differential network capture reporting for Claude Code vs Headroom.
|
|
|
|
The capture format is intentionally JSONL so mitmproxy addons, tests, and
|
|
future packet capture tools can all produce the same records without a heavy
|
|
dependency in the Headroom package.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SENSITIVE_HEADER_PARTS = ("authorization", "api-key", "apikey", "token", "secret", "cookie")
|
|
SENSITIVE_QUERY_PARTS = ("key", "token", "secret", "signature", "code")
|
|
MAX_BODY_PREVIEW_CHARS = 1200
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CapturedExchange:
|
|
"""A sanitized HTTP request/response pair captured by the harness."""
|
|
|
|
lane: str
|
|
sequence: int
|
|
method: str
|
|
url: str
|
|
host: str
|
|
path: str
|
|
request_headers: dict[str, str] = field(default_factory=dict)
|
|
response_status: int | None = None
|
|
response_headers: dict[str, str] = field(default_factory=dict)
|
|
request_body_sha256: str | None = None
|
|
request_body_size: int = 0
|
|
request_json: Any | None = None
|
|
request_body_preview: str | None = None
|
|
|
|
@property
|
|
def route_key(self) -> str:
|
|
return f"{self.method.upper()} {self.host}{self.path}"
|
|
|
|
@property
|
|
def path_key(self) -> str:
|
|
return f"{self.method.upper()} {self.path}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CaptureDiff:
|
|
"""Comparison result between a direct lane and a Headroom lane."""
|
|
|
|
direct_count: int
|
|
headroom_count: int
|
|
only_direct: list[str]
|
|
only_headroom: list[str]
|
|
paired: list[dict[str, Any]]
|
|
generated_at: str
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"generated_at": self.generated_at,
|
|
"direct_count": self.direct_count,
|
|
"headroom_count": self.headroom_count,
|
|
"only_direct": self.only_direct,
|
|
"only_headroom": self.only_headroom,
|
|
"paired": self.paired,
|
|
}
|
|
|
|
|
|
def _redact_value(value: object) -> str:
|
|
if value is None:
|
|
return ""
|
|
text = str(value)
|
|
if not text:
|
|
return text
|
|
return "<redacted>"
|
|
|
|
|
|
def sanitize_headers(headers: dict[str, Any] | None) -> dict[str, str]:
|
|
sanitized: dict[str, str] = {}
|
|
for key, value in (headers or {}).items():
|
|
lower = str(key).lower()
|
|
if any(part in lower for part in SENSITIVE_HEADER_PARTS):
|
|
sanitized[str(key)] = _redact_value(value)
|
|
else:
|
|
sanitized[str(key)] = str(value)
|
|
return sanitized
|
|
|
|
|
|
def sanitize_url(url: str) -> str:
|
|
parsed = urlsplit(url)
|
|
pairs = []
|
|
for key, value in parse_qsl(parsed.query, keep_blank_values=True):
|
|
if any(part in key.lower() for part in SENSITIVE_QUERY_PARTS):
|
|
pairs.append((key, "<redacted>"))
|
|
else:
|
|
pairs.append((key, value))
|
|
query = urlencode(pairs, doseq=True)
|
|
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, ""))
|
|
|
|
|
|
def _body_bytes(record: dict[str, Any]) -> bytes:
|
|
body_b64 = record.get("request_body_b64")
|
|
if isinstance(body_b64, str):
|
|
try:
|
|
return base64.b64decode(body_b64, validate=True)
|
|
except Exception:
|
|
return b""
|
|
body = record.get("request_body")
|
|
if isinstance(body, str):
|
|
return body.encode("utf-8", errors="replace")
|
|
return b""
|
|
|
|
|
|
def _parse_json_body(body: bytes) -> Any | None:
|
|
if not body:
|
|
return None
|
|
try:
|
|
return json.loads(body.decode("utf-8"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _preview_body(body: bytes) -> str | None:
|
|
if not body:
|
|
return None
|
|
text = body[:MAX_BODY_PREVIEW_CHARS].decode("utf-8", errors="replace")
|
|
return text.replace("\r\n", "\n")
|
|
|
|
|
|
def exchange_from_record(
|
|
record: dict[str, Any], *, fallback_lane: str, sequence: int
|
|
) -> CapturedExchange:
|
|
url = sanitize_url(str(record.get("url") or ""))
|
|
parsed = urlsplit(url)
|
|
path = parsed.path or "/"
|
|
if parsed.query:
|
|
path = f"{path}?{parsed.query}"
|
|
body = _body_bytes(record)
|
|
request_json = record.get("request_json")
|
|
if request_json is None:
|
|
request_json = _parse_json_body(body)
|
|
body_sha = record.get("request_body_sha256")
|
|
if body_sha is None and body:
|
|
body_sha = hashlib.sha256(body).hexdigest()
|
|
return CapturedExchange(
|
|
lane=str(record.get("lane") or fallback_lane),
|
|
sequence=int(record.get("sequence") or sequence),
|
|
method=str(record.get("method") or "GET").upper(),
|
|
url=url,
|
|
host=parsed.netloc or str(record.get("host") or ""),
|
|
path=path,
|
|
request_headers=sanitize_headers(record.get("request_headers")),
|
|
response_status=record.get("response_status"),
|
|
response_headers=sanitize_headers(record.get("response_headers")),
|
|
request_body_sha256=str(body_sha) if body_sha else None,
|
|
request_body_size=int(record.get("request_body_size") or len(body)),
|
|
request_json=request_json,
|
|
request_body_preview=_preview_body(body),
|
|
)
|
|
|
|
|
|
def load_capture_file(path: str | Path, *, fallback_lane: str) -> list[CapturedExchange]:
|
|
"""Load a JSONL capture file produced by the mitmproxy addon."""
|
|
|
|
exchanges: list[CapturedExchange] = []
|
|
capture_path = Path(path)
|
|
skipped = 0
|
|
for line_number, line in enumerate(capture_path.read_text(encoding="utf-8").splitlines(), 1):
|
|
if not line.strip():
|
|
continue
|
|
# mitmproxy captures can be truncated mid-write; skip a corrupt line
|
|
# rather than aborting the whole diff with a raw JSONDecodeError.
|
|
try:
|
|
record = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
skipped += 1
|
|
continue
|
|
exchanges.append(
|
|
exchange_from_record(record, fallback_lane=fallback_lane, sequence=line_number)
|
|
)
|
|
if skipped:
|
|
logger.warning("Skipped %d malformed line(s) in capture file %s", skipped, capture_path)
|
|
return exchanges
|
|
|
|
|
|
def _json_paths(value: Any, prefix: str = "$") -> dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
paths: dict[str, Any] = {}
|
|
for key, child in sorted(value.items()):
|
|
paths.update(_json_paths(child, f"{prefix}.{key}"))
|
|
return paths or {prefix: {}}
|
|
if isinstance(value, list):
|
|
paths = {}
|
|
for index, child in enumerate(value):
|
|
paths.update(_json_paths(child, f"{prefix}[{index}]"))
|
|
return paths or {prefix: []}
|
|
return {prefix: value}
|
|
|
|
|
|
def _header_delta(
|
|
direct: dict[str, str], headroom: dict[str, str]
|
|
) -> tuple[list[str], list[str], list[str]]:
|
|
direct_keys = {key.lower(): key for key in direct}
|
|
headroom_keys = {key.lower(): key for key in headroom}
|
|
only_direct = sorted(direct_keys[key] for key in set(direct_keys) - set(headroom_keys))
|
|
only_headroom = sorted(headroom_keys[key] for key in set(headroom_keys) - set(direct_keys))
|
|
changed: list[str] = []
|
|
for lower in sorted(set(direct_keys) & set(headroom_keys)):
|
|
d_key = direct_keys[lower]
|
|
h_key = headroom_keys[lower]
|
|
if direct[d_key] != headroom[h_key]:
|
|
changed.append(d_key)
|
|
return only_direct, only_headroom, changed
|
|
|
|
|
|
def _header_value(headers: dict[str, str], name: str) -> str | None:
|
|
target = name.lower()
|
|
for key, value in headers.items():
|
|
if key.lower() == target:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _anthropic_request_summary(exchange: CapturedExchange) -> dict[str, Any]:
|
|
request_json = exchange.request_json if isinstance(exchange.request_json, dict) else {}
|
|
tools = request_json.get("tools")
|
|
tool_count = len(tools) if isinstance(tools, list) else 0
|
|
tool_bytes = (
|
|
len(json.dumps(tools, sort_keys=True, separators=(",", ":")).encode("utf-8"))
|
|
if isinstance(tools, list)
|
|
else 0
|
|
)
|
|
return {
|
|
"anthropic_beta": _header_value(exchange.request_headers, "anthropic-beta"),
|
|
"tools_count": tool_count,
|
|
"tools_bytes": tool_bytes,
|
|
}
|
|
|
|
|
|
def _pair_exchanges(
|
|
direct: list[CapturedExchange], headroom: list[CapturedExchange], *, pair_by: str = "path"
|
|
) -> tuple[list[tuple[CapturedExchange, CapturedExchange]], list[str], list[str]]:
|
|
direct_by_key: dict[str, list[CapturedExchange]] = {}
|
|
headroom_by_key: dict[str, list[CapturedExchange]] = {}
|
|
for item in direct:
|
|
key = item.route_key if pair_by == "route" else item.path_key
|
|
direct_by_key.setdefault(key, []).append(item)
|
|
for item in headroom:
|
|
key = item.route_key if pair_by == "route" else item.path_key
|
|
headroom_by_key.setdefault(key, []).append(item)
|
|
|
|
pairs: list[tuple[CapturedExchange, CapturedExchange]] = []
|
|
only_direct: list[str] = []
|
|
only_headroom: list[str] = []
|
|
for key in sorted(set(direct_by_key) | set(headroom_by_key)):
|
|
direct_items = direct_by_key.get(key, [])
|
|
headroom_items = headroom_by_key.get(key, [])
|
|
shared = min(len(direct_items), len(headroom_items))
|
|
pairs.extend(zip(direct_items[:shared], headroom_items[:shared], strict=False))
|
|
only_direct.extend([item.route_key for item in direct_items[shared:]])
|
|
only_headroom.extend([item.route_key for item in headroom_items[shared:]])
|
|
return pairs, only_direct, only_headroom
|
|
|
|
|
|
def compare_captures(
|
|
direct: list[CapturedExchange], headroom: list[CapturedExchange], *, pair_by: str = "path"
|
|
) -> CaptureDiff:
|
|
pairs, only_direct, only_headroom = _pair_exchanges(direct, headroom, pair_by=pair_by)
|
|
paired: list[dict[str, Any]] = []
|
|
for direct_item, headroom_item in pairs:
|
|
direct_paths = (
|
|
_json_paths(direct_item.request_json) if direct_item.request_json is not None else {}
|
|
)
|
|
headroom_paths = (
|
|
_json_paths(headroom_item.request_json)
|
|
if headroom_item.request_json is not None
|
|
else {}
|
|
)
|
|
only_direct_json = sorted(set(direct_paths) - set(headroom_paths))
|
|
only_headroom_json = sorted(set(headroom_paths) - set(direct_paths))
|
|
changed_json = sorted(
|
|
path
|
|
for path in set(direct_paths) & set(headroom_paths)
|
|
if direct_paths[path] != headroom_paths[path]
|
|
)
|
|
headers_only_direct, headers_only_headroom, headers_changed = _header_delta(
|
|
direct_item.request_headers, headroom_item.request_headers
|
|
)
|
|
paired.append(
|
|
{
|
|
"route": direct_item.route_key,
|
|
"headroom_route": headroom_item.route_key,
|
|
"direct_sequence": direct_item.sequence,
|
|
"headroom_sequence": headroom_item.sequence,
|
|
"status": {
|
|
"direct": direct_item.response_status,
|
|
"headroom": headroom_item.response_status,
|
|
},
|
|
"request_body_size": {
|
|
"direct": direct_item.request_body_size,
|
|
"headroom": headroom_item.request_body_size,
|
|
"delta": headroom_item.request_body_size - direct_item.request_body_size,
|
|
},
|
|
"request_body_sha256": {
|
|
"direct": direct_item.request_body_sha256,
|
|
"headroom": headroom_item.request_body_sha256,
|
|
"same": direct_item.request_body_sha256 == headroom_item.request_body_sha256,
|
|
},
|
|
"anthropic": {
|
|
"direct": _anthropic_request_summary(direct_item),
|
|
"headroom": _anthropic_request_summary(headroom_item),
|
|
},
|
|
"headers": {
|
|
"only_direct": headers_only_direct,
|
|
"only_headroom": headers_only_headroom,
|
|
"changed": headers_changed,
|
|
},
|
|
"json": {
|
|
"only_direct": only_direct_json,
|
|
"only_headroom": only_headroom_json,
|
|
"changed": changed_json,
|
|
},
|
|
}
|
|
)
|
|
return CaptureDiff(
|
|
direct_count=len(direct),
|
|
headroom_count=len(headroom),
|
|
only_direct=only_direct,
|
|
only_headroom=only_headroom,
|
|
paired=paired,
|
|
generated_at=datetime.now(timezone.utc).isoformat(),
|
|
)
|
|
|
|
|
|
def _list_or_dash(values: list[str]) -> str:
|
|
return ", ".join(values) if values else "-"
|
|
|
|
|
|
def render_markdown_report(diff: CaptureDiff) -> str:
|
|
lines = [
|
|
"# Differential Network Capture Report",
|
|
"",
|
|
f"Generated: `{diff.generated_at}`",
|
|
"",
|
|
"## Summary",
|
|
"",
|
|
f"- Direct exchanges: `{diff.direct_count}`",
|
|
f"- Headroom exchanges: `{diff.headroom_count}`",
|
|
f"- Paired exchanges: `{len(diff.paired)}`",
|
|
f"- Only direct: `{len(diff.only_direct)}`",
|
|
f"- Only Headroom: `{len(diff.only_headroom)}`",
|
|
"",
|
|
]
|
|
if diff.only_direct:
|
|
lines.extend(["## Only Direct", "", *[f"- `{route}`" for route in diff.only_direct], ""])
|
|
if diff.only_headroom:
|
|
lines.extend(
|
|
["## Only Headroom", "", *[f"- `{route}`" for route in diff.only_headroom], ""]
|
|
)
|
|
|
|
lines.extend(
|
|
[
|
|
"## Paired Exchanges",
|
|
"",
|
|
"| Route | Status | Body Bytes | Body SHA | Header Delta | JSON Delta |",
|
|
"| --- | --- | ---: | --- | --- | --- |",
|
|
]
|
|
)
|
|
for item in diff.paired:
|
|
route = item["route"]
|
|
if item.get("headroom_route") and item["headroom_route"] != route:
|
|
route = f"{route} -> {item['headroom_route']}"
|
|
status = f"{item['status']['direct']} -> {item['status']['headroom']}"
|
|
sizes = item["request_body_size"]
|
|
body = f"{sizes['direct']} -> {sizes['headroom']} ({sizes['delta']:+})"
|
|
sha = "same" if item["request_body_sha256"]["same"] else "changed"
|
|
headers = item["headers"]
|
|
header_delta = (
|
|
f"+{_list_or_dash(headers['only_headroom'])}; "
|
|
f"-{_list_or_dash(headers['only_direct'])}; "
|
|
f"changed={_list_or_dash(headers['changed'])}"
|
|
)
|
|
json_delta = item["json"]
|
|
json_text = (
|
|
f"+{_list_or_dash(json_delta['only_headroom'])}; "
|
|
f"-{_list_or_dash(json_delta['only_direct'])}; "
|
|
f"changed={_list_or_dash(json_delta['changed'])}"
|
|
)
|
|
anthropic = item.get("anthropic", {})
|
|
direct_anthropic = anthropic.get("direct", {})
|
|
headroom_anthropic = anthropic.get("headroom", {})
|
|
tool_delta = headroom_anthropic.get("tools_bytes", 0) - direct_anthropic.get(
|
|
"tools_bytes", 0
|
|
)
|
|
json_text = (
|
|
f"{json_text}; tools={direct_anthropic.get('tools_count', 0)}"
|
|
f"->{headroom_anthropic.get('tools_count', 0)}"
|
|
f" ({tool_delta:+} bytes)"
|
|
)
|
|
lines.append(
|
|
f"| `{route}` | `{status}` | `{body}` | `{sha}` | {header_delta} | {json_text} |"
|
|
)
|
|
lines.append("")
|
|
return "\n".join(lines)
|