8000 I did an audit · Issue #813 · askalf/dario · GitHub
[go: up one dir, main page]

Skip to content

I did an audit #813

Closed
Closed

Description

@p-i-

Hello!
I ran an audit with ClaudeCode Opus 4.8.
Apologies in advance if this is noise / AI-slop.
It might be worth 5 mins of your time to run it thru your own agentic AI.

❤️
π

dario wire-fidelity — empirical findings

Independent measurement of the claim that dario's requests match Claude Code "byte-for-byte" / "packet-identical." Every result below is measured on one machine and reproduced; the harness scripts are included so anyone can re-run.

Rig: macOS arm64 · claude 2.1.214 (Mach-O standalone, embeds Bun/1.4.0, node-compat v26.3.0) · Node v23.7.0 · public Bun 1.0.9 / 1.3.14 / canary(→1.4.0) · dario @ HEAD (2901bc9).

Instruments (all local, no sudo, zero subscription cost except where noted):

  • capture-ch.mjs + ja3.mjs — grab a client's TLS ClientHello (first TCP payload, cleartext) without completing the handshake → JA3.
  • mitm-proxy.mjs — env-scoped HTTPS_PROXY CONNECT proxy that TLS-terminates api.anthropic.com with a trusted local cert, logs decrypted HTTP/1.1 (raw header order+case + body, token redacted), returns a stub. Only the one subprocess is affected.
  • collector.mjs + hdrtest.js/btest.js — local TLS server to compare Node-vs-Bun fetch behavior.
  • Real CC /v1/messages captured by running the user's authenticated claude through the MITM (stub reply ⇒ nothing billed). dario egress captured by running dario from source with its upstream const pointed at the collector and a dummy account (shape-only; no real creds, no cost).

Layer 0 — TLS ClientHello (JA3). Deterministic (3/3, no GREASE).

Client Runtime JA3
claude 2.1.214 Bun 1.4.0 (embedded) e97f5146a7009cc2918b50e903b6ff8d
bare Bun 1.3.14 / canary BoringSSL e97f5146… (byte-identical to CC)
bare Bun 1.0.9 BoringSSL 2ae7eb4b… (diverges: +3DES, +ECH, +padding)
bare Node v23.7.0 OpenSSL 53321030… (52 ciphers)
  • Node ≠ Bun, massively. The TLS gap is closable with public Bun ≥ 1.3.14 (reproduces CC exactly). No public bun-v1.4.0 exists (canary line).
  • Gap dario misses: runtime-fingerprint.ts treats bun-match as "under Bun at all" and never checks the Bun version's JA3. On this box dario would auto-relaunch under the Bun 1.0.9 on PATH and report a green bun-match while emitting 2ae7… ≠ CC's e97f…. → measure JA3, or pin/warn a min Bun version.

Layer 1 — protocol. HTTP/1.1 (ALPN http/1.1 only). No H2/HPACK axis; header order+case are literal on the wire. CC does not cert-pin.

Layer 2 — the actual /v1/messages request: CC vs dario (measured)

Header order — real CC (SDK insertion order, preserved on the wire):
Accept · Authorization · Content-Type · User-Agent · X-Claude-Code-Session-Id · X-Stainless-Arch · X-Stainless-Lang · X-Stainless-OS · X-Stainless-Package-Version · X-Stainless-Retry-Count · X-Stainless-Runtime · X-Stainless-Runtime-Version · X-Stainless-Timeout · anthropic-beta · anthropic-dangerous-direct-browser-access · anthropic-version · x-app · x-client-request-id · Connection · Host · Accept-Encoding · Content-Length

dario under Node (undici):
host · connection · accept · content-type · user-agent · x-claude-code-session-id · x-stainless-*(8) · anthropic-beta · anthropic-dangerous… · anthropic-version · x-app · Authorization · x-client-request-id · accept-language · sec-fetch-mode · accept-encoding · content-length
→ replays dario's captured order, BUT: all names lowercased; Authorization appended near the end (pos ~19, vs CC pos 2); host/connection hoisted to front; undici injects accept-language + sec-fetch-mode (CC never sends these); accept-encoding: br, gzip, deflate (CC: gzip, deflate, br, zstd).

dario under Bun 1.3.14 (fetch sorts):
Accept · Authorization · Content-Type · User-Agent · anthropic-beta · anthropic-dangerous… · anthropic-version · x-app · x-claude-code-session-id · x-client-request-id · x-stainless-*(8) · Connection · Host · Accept-Encoding · Content-Length
→ alphabetically sorted; x-*/x-stainless-* lowercased and placed after anthropic-*, unlike CC. dario's captured header_order is discarded by Bun's fetch.

Root cause (measured): plain fetch() under both public Bun 1.3.14 and canary (== CC's 1.4.0) sorts headers (zeta,accept,m-custom,Authorization → Accept,Authorization,m-custom,zeta). Real CC on the same Bun is not sorted ⇒ the Anthropic SDK bypasses fetch's Headers sort via raw dispatch. dario uses fetch(), so it cannot reproduce CC's header order under Bun — its header_order machinery is a no-op there. Under Node it survives but undici adds its own fingerprint.

Verdict grid

Property dario/Node dario/Bun 1.3.14 matches CC?
TLS JA3 ✗ (5332…) ✓ (e97f…, if Bun≥1.3.14) Bun only
Body top-level key order ✓ ✓ yes
metadata.user_id shape, system-prompt/billing-header injection ✓ ✓ yes (rebuild works)
x-client-request-id, x-claude-code-session-id present ✓ ✓ yes
header name casing ✗ lowercased ✗ lowercased no (CC mixed-case)
header order replayed but Authz misplaced ✗ alphabetized no
transport-injected headers ✗ accept-language,sec-fetch-mode, host/conn front ✓ clean Bun only
Accept-Encoding value ✗ br,gzip,deflate (Bun default) Node no
x-stainless-arch/os ✗ x64/Linux on fallback path ✓ arm64/MacOS (live) live-capture only
anthropic-beta set/order advisor-tool-2026-03-01 vs CC advanced-tool-use-2025-11-20; missing extended-cache-ttl-2025-04-11; oauth flag at tail vs pos 2 same no

What dario gets right (credit)

Body key order exact; live value overlay (UA (external, sdk-cli), pkg 0.94.0, runtime v26.3.0) correct; arch/os correct on the live path; injects the CC/Agent-SDK system prompt + x-anthropic-billing-header + thinking/context_management/output_config to rebuild a bare client into CC shape; sends x-client-request-id, session-id, presence pings; TLS reproducible under a recent Bun.

Bottom line

  • "byte-identical body": substantially TRUE (key order + serialization). ✅
  • "packet-identical" on the wire: FALSE as stated. Best achievable today is structurally close under a recent Bun, still distinguishable by: header-name casing (lowercased), header order (Bun's fetch sorts; dario's replay is defeated), anthropic-beta set, and — under Node — undici-injected headers. Plus the unverified-Bun-version JA3 gap.
  • The single highest-leverage fix: dario cannot match CC's header order/casing through fetch(). Matching CC requires the same raw-dispatch path the Anthropic SDK uses (or an http/1.1 client that preserves order+case). Everything else (casing, Authz position, beta set, JA3 version check) is incremental.

Bugs/observations to file

  1. bun-match doesn't verify Bun version/JA3 (false-green on old Bun).
  2. fetch() transport re-sorts (Bun) / injects (Node) headers, silently defeating header_order replay — the documented v3.13 wire-fidelity axis is a no-op on the recommended runtime.
  3. Header names emitted lowercase; CC is mixed-case.
  4. OAuth Authorization appended at tail, not CC's position 2 (template carries capture-artifact x-api-key instead).
  5. anthropic-beta drift: advisor-tool-2026-03-01 vs real advanced-tool-use-2025-11-20; missing extended-cache-ttl-2025-04-11.
  6. Bundled-fallback x-stainless-arch/os = x64/Linux (clobbers correct values when live capture unavailable).
  7. Hardcoded fallback x-stainless-runtime-version: v24.3.0 (real: v26.3.0).
  8. Context: CC itself phones home (/api/event_logging/v2/batch 163–252 KB + Datadog) — relevant to "nothing phones home" positioning.

Irreducible

Adversary feature set is unobservable (we can prove wire-identical across observed layers, never "undetectable"); results are point-in-time (CC/Bun version drift).

Activity

  1. askalf commented on Jul 18, 2026

    @askalf
    Owner

    This is the good kind of issue — thank you. Ran it back through our own agentic pass against 2901bc9 and current master, and it holds up. Definitely not slop; the included harness scripts are what made it checkable, so extra thanks for those.

    What reproduced against the code:

    #1 — version-blind bun-match. Confirmed. classifyRuntimeFingerprint returned bun-match for any Bun version behind a hardcoded "matches Claude Code" label, with nothing checking the JA3 floor — a relaunch into an old Bun on PATH false-greens exactly as you describe. Shipped a fix in #814: below 1.3.14 (your measured floor) is now bun-ja3-unverified, so doctor and the startup banner warn and --strict-tls refuses.

    #2 — header order/casing defeated by fetch(). Confirmed, and this is the real one. orderHeadersForOutbound() builds the ordered pair-array specifically to replay CC's sequence and then hands it straight to fetch() — the docstring literally asserts "the array form is the one variant where wire order is part of the spec," which your measurement disproves on both runtimes (Bun sorts; undici lowercases and injects accept-language/sec-fetch-mode). So that machinery is a no-op on the wire. One clarification in our favor and one against: header order was never a documented axis — the six in docs/wire-fidelity.md don't include it, so it's aspirational code rather than a shipped claim — but it is dead effort as written, you're right. The bind you surfaced is the part worth sitting with: Bun gives the matching JA3 but its fetch sorts with no raw-dispatch escape, while Node lets you control order/casing through undici's low-level dispatch but carries the wrong JA3. You can't win both axes in one process on the current stack. Matching CC's raw dispatch under Node and eating the JA3 cost is the direction; tracking it separately since it's an architectural change, not a patch.

    #3 — lowercased names. Confirmed, same root cause as #2.

    #6 / #7 — x64/Linux and runtime v24.3.0 on the fallback path. Confirmed but fallback-only, as you flagged yourself. The live-overlay path is correct; these only bite bundled-only / no-live-capture installs. The v24.3.0 is stale against your v26.3.0 — version drift the cc-drift bot should be chasing. Real, low priority.

    Two I'd push back on slightly:

    #4. Right that OAuth Authorization lands at the tail and the captured order carries an x-api-key capture artifact — but it's moot on the wire today because fetch() destroys order regardless. It becomes real the moment #2 is fixed, so it rides along with that work rather than standing alone.

    #5. Partly. advisor-tool-2026-03-01 is in our template and differs from your advanced-tool-use-2025-11-20 capture, so the template does need a drift reconcile. But two things your dummy-account/shape-only capture wouldn't have shown: in production the beta set is merged with the client's own anthropic-beta header, so a real CC client contributes its flags and dario ships a superset rather than the wrong flag; and extended-cache-ttl-2025-04-11 isn't missing — it's mirrored conditionally when the client sends it, matching CC's send-the-pair behavior. So "drift," yes; "missing," no.

    #8. Fair. That's positioning, not a bug, and worth being straight about.

    Net: you're right that "packet-identical on the wire" doesn't survive contact. Body fidelity is strong (glad that came through clearly), header-layer fidelity is defeated by the transport, and the honest framing is closer to "byte-identical body, structurally close headers under a recent Bun." Taking #1 now (#814), #2 as the architectural track, #5 as a template reconcile. Genuinely appreciate the rigor. ❤️

  2. p-i- commented on Jul 18, 2026

    @p-i-
    Author

    Ah, that really made my day.
    :)

    I forced Opus to verify by experiment, which shaved off a layer of bullshit.

    Do feel welcome to close out the issue, and thanks for your work!

  3. askalf commented on Jul 18, 2026

    @askalf
    Owner

    Following up on #5 — I need to correct myself. I said the template "needs a drift reconcile." That's not right, and the real story is more interesting.

    I checked what our account's CC 2.1.214 actually emits: the self-hosted drift-watch runner re-baked the template from a live 2.1.214 capture earlier today, and it still carries advisor-tool-2026-03-01. That bot explicitly diffs the installed CC's per-model anthropic-beta (it asserts betaForModel reproduces the captured header) and isn't flagging drift. So on our account, advisor-tool-2026-03-01 is what 2.1.214 sends.

    Your capture on the same 2.1.214 showed advanced-tool-use-2025-11-20. Both are correct — it's remote-config bucket variance: Anthropic flips these flags server-side within a single CC version (afk-mode does the same thing), which is exactly the drift class our template-watch exists to chase. Different account → different rollout → different flag, same binary.

    And it self-corrects in prod regardless: dario live-captures each machine's own CC to ~/.dario/cc-template.live.json and forwards whatever that deployment's bucket sends, rather than hard-coding one. The baked set is only the fallback — so there's no stale flag to reconcile; the base already tracks whichever bucket the operator is in. Good catch either way, since it's what made me go verify it.

  4. askalf commented on Jul 18, 2026

    @askalf
    Owner

    Correction, and I owe you a clean one. In my reply above I said the "you can't win both axes" bind dissolves — that raw HTTP over Bun.connect gets CC's JA3 and CC's header order together. That was wrong, and it was my error, not a subtlety.

    My JA3 harness had a bug: its mode switch only recognized tls, so the bun-connect mode silently ran the fetch path. I measured fetch twice and read it as "Bun.connect == fetch." Measured correctly:

    • fetch (what CC uses): JA3 d871d02c, extensions include ALPN (16), status_request (5), SCT (18), padding (21)
    • Bun.connect: JA3 203503b7, extensions 0,23,65281,10,11,35,13,51,45,43 — none of those

    And no Bun.connect TLS option adds them (alpn/ALPNProtocols/alpnProtocols are ignored or the wrong type). Bun.connect == node:tls == a leaner profile that can't be pushed to fetch's exact ClientHello.

    So the bind is real, exactly as your #2 framing implied. On Bun you get CC's JA3 (via fetch, which sorts headers) or raw header order/casing control (via Bun.connect, wrong JA3) — not both. Header-order fidelity isn't reachable on Bun without regressing the TLS axis. I've reverted the change (it was default-off, so nothing shipped enabled) rather than ship a knob that trades one fingerprint for the other.

    Your original point stands unqualified: "packet-identical on the wire" isn't achievable through fetch, and the header axis specifically isn't fixable on the recommended runtime. Thanks for the rigor that made this worth chasing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0