fix(launcher): coexist with another MITM via ca-trust.d instead of clobbering NODE_EXTRA_CA_CERTS - #283
Conversation
There was a problem hiding this comment.
Review: PR #283 — fix(launcher): coexist with another MITM via ca-trust.d instead of clobbering NODE_EXTRA_CA_CERTS
Date: 2026-07-31
Reviewed: bin/claude-via-proxy.mjs, test/proxy-forward-ca.test.mjs, test/proxy-wrapper.test.mjs, README.md, CHANGELOG.md at 2f8b2b939726f149cfbdd30bbe77b06813a04595, re-reviewed as a clean merge onto origin/main 0770147
Round: 1
Label applied: changes-requested
What Is Correct
- The trust-selection guard is conservative in the right direction.
bin/claude-via-proxy.mjs:286only acceptsca-trust.pemif every PEM block parses viaX509Certificateand one block DER-equals our CA; otherwise it falls back to our ownca.pem. That does prevent the two concrete failure modes called out in the PR description: stale bundles that do not carry our CA, and torn / malformed bundles that would make Node ignore the extra-CA file. - The no-other-MITM path is preserved. When no merged bundle exists, the wrapper still points
NODE_EXTRA_CA_CERTSat${CACHE_FIX_CA_DIR||claudeHome()/cache-fix-ca}/ca.pem(bin/claude-via-proxy.mjs:284-342), andtest/proxy-wrapper.test.mjs:324-348pins that unchanged fallback. CLAUDE_CONFIG_DIRresolution matches the existingclaudeHome()contract rather than freezing at import time. The launcher resolves the config root live per invocation (bin/claude-via-proxy.mjs:198-199,226,284), which matches the semantics introduced by PR #246.- The new tests are materially better than string-shape tests:
test/proxy-forward-ca.test.mjs:242-349verifies the guard against real TLS authorization outcomes, and the merged tree passes the full suite (node --test: 1448 passing tests on my run).
Blockers
bin/claude-via-proxy.mjs:257-261reaps everyccf.pem.*temp after each launch, which is not safe against a concurrent launcher. A second launcher can have already written its own temp sibling but not yet executedrenameSync(tmp, dst); the first launcher will then delete that still-live temp as an "orphan". Best case, the loser logs a spurious publish failure. Worse, if the two launchers are not byte-identical (for example becauseCACHE_FIX_CA_DIRpoints at a different CA root), the directory entry left behind is whichever launch happened to win first, not necessarily the current publisher's bytes. That contradicts the PR's claim that orphan-temp cleanup is safe under concurrency and leaves the on-disk trust contract nondeterministic.- The PR says the contract is a fixed on-disk rendezvous at
<config>/ca-trust.d/<component>.pemplus<config>/ca-trust.pem, but the implementation adds a unilateral publisher-only escape hatch:CACHE_FIX_CA_TRUST_DIR(bin/claude-via-proxy.mjs:226-227, documented inREADME.md:314,CHANGELOG.md:7). Consumers still read the merged bundle only from<config>/ca-trust.pem(bin/claude-via-proxy.mjs:284), and nothing in this PR defines a corresponding cross-component way for the external bundle builder to discover that alternate publish directory. For a load-bearing three-language contract that is supposed to be durable, this is too loose: one participant can silently stop publishing to the canonical rendezvous path while still claiming to implement the contract.
What Needs Attention
- The contract is operationally sound as a cooperative same-user convention, not as a tamper-evident trust boundary. A local actor who can replace
ca-trust.pemwith a fully-parseable bundle containing both our CA and an attacker CA will still pass the current guard, because the guard intentionally proves only "parses + carries us," not "contains only approved writers." I am not calling that a third blocker because the PR text already scopes completeness/integrity to the external builder, but I do think the limitation should be stated explicitly anywhere this contract is documented as load-bearing. - I could not reproduce the author's live multi-MITM host measurements. My validation of the accept/reject guard is based on the local TLS-handshake tests added in this PR plus direct Node v24.11.1
X509Certificatebehavior, not on a real second MITM or corporate bundle builder.
Bloat / Non-Functional
- None.
Recommendations
- Make temp cleanup ownership-safe before merge. At minimum, do not delete another process's still-live temp by glob alone; the reaper needs enough provenance (or age / successful-publication gating) to distinguish true leftovers from an in-flight sibling publish.
- Remove
CACHE_FIX_CA_TRUST_DIRfrom the contract, or elevate it into the shared spec with matching producer / builder / consumer semantics. Right now it weakens the very durability claim this PR is trying to establish. - Once those two issues are fixed, re-run the merged-suite path and live multi-MITM validation. This change still merits human review before merge because it fixes a real trust-path bug by introducing a new shared trust contract.
Bottom Line
The acceptance guard and the default fallback behavior are in good shape, and the tests do a credible job of proving the launcher now fails closed on the known bad bundle shapes. But I do not think the on-disk contract is sound enough to freeze yet: the current temp-file reaper can interfere with a concurrent publisher, and the new CACHE_FIX_CA_TRUST_DIR override means the launcher no longer implements a single canonical rendezvous path that other components can rely on. Please fix those two contract-level issues before this is approved. — Codex review
|
Thanks for this — the problem is real and the diagnosis is the useful kind: two components taking turns untrusting each other, with neither logging anything, is exactly the failure nobody finds by reading code. Review result: changes requested. Two blockers, both contract-level rather than defects in the guard itself. I verified each against the diff before posting. 1. The temp reaper isn't concurrency-safe ( for (const f of readdirSync(caTrustDir)) {
if (f.startsWith("ccf.pem.")) try { rmSync(join(caTrustDir, f)); } catch { }
}This runs unconditionally after your own Age-gating would probably do it — a temp older than any plausible write-to-rename window is genuinely orphaned, one that isn't may be live. 2. Publish path is Either drop it, or lift it into the shared spec with matching producer/builder/consumer semantics. Not blocking, but worth stating in the README where the contract is documented: the guard proves parses + carries us, not contains only approved writers. A local actor who can write a well-formed bundle containing your CA plus theirs passes it. You already scope completeness to the builder — this is the same boundary, and readers will assume more than it promises unless it's written down. What's good: the guard's failure direction is right, and the asymmetry argument holds. Replacing the substring checks with Two process notes:
Your live multi-MITM host isn't reproducible here — the handshake results and the 132-cert bundle validation are taken as your measurements, not independently confirmed. Flagging that so it's on the record rather than implied. Happy to re-review as soon as the two blockers are addressed. — Proxy Builder |
… instead of clobbering NODE_EXTRA_CA_CERTS
NODE_EXTRA_CA_CERTS takes ONE file, so `claudeEnv.NODE_EXTRA_CA_CERTS = caPem`
was not "set our CA" but "untrust whatever else needed trusting". Two real
losses from it: any OTHER component that also MITMs api.anthropic.com and also
sets the var (last writer wins, the loser's CA silently dropped), and an ambient
bundle the environment had already set.
Measured 2026-07-30: an account-switching pin proxy also MITMs api.anthropic.com and
also wrote this var. Last writer won, the other CA went untrusted, and Remote
Control inbound broke on one of them — CC's debug log showed the extra certs
appended from the other component's bundle at T+0.0, then 13x "unable to verify the first
certificate" on the SSE transport.
The irony is local: ten lines below that assignment, NO_PROXY is deliberately
MERGED, with a comment saying a corporate env may already have set one. Same
hazard, the rule had just never reached the CA.
So, per a contract agreed with the two other components that touch this:
write <config>/ca-trust.d/ccf.pem our CA only, byte-compare skip, EVERY
launch (the proxy regenerates its CA
when caDir is wiped, and a stale pem
would advertise a key nothing signs
with)
read <config>/ca-trust.pem when non-empty -> NODE_EXTRA_CA_CERTS
else our own caPem byte for byte what this did before
We deliberately do NOT build the merged bundle. Merging has to include the
ambient/corporate roots, and finding those is environment-specific: on one
measured Linux host four corporate CAs live in /usr/local/share/ca-certificates
and are absent from the 126-cert system bundle a shell points at, while a Mac
keeps them in the keychain. That knowledge does not belong in this repo. It also
keeps the writer count at one — two launchers both rebuilding the bundle would
race the same output. We are write-own + read-merged.
Ordering: publish happens before the client is exec'd (same process, and the
caPem existence gate above already guarantees the proxy generated it), so a
bundle builder that reads the dir on a cold start sees us.
Failure paths are deliberate. A publish failure warns on stderr and continues —
publishing is how OTHERS trust us, and must not kill this session, which only
needs its own CA. An absent, zero-byte, or unreadable bundle all resolve to the
same answer (our own CA), so one catch covers the three.
No override for the read path: the builder writes that exact path and resolves
the config dir the same way, so a knob could only ever point the two sides at
different files. CACHE_FIX_CA_TRUST_DIR overrides the publish dir, mirroring the
existing CACHE_FIX_CA_DIR.
Tests (4, each red before green): publish lands before exec and is our CA
verbatim; a present bundle wins; we never create the merged file and never touch
a sibling component's pem; with no bundle the env is our own CA exactly as
before.
Co-Authored-By: Claude <noreply@anthropic.com>
…m atomically
Two hardening gaps in the ca-trust contract, both hit in practice by the
sibling component that shares the directory, both reproduced here before
being fixed.
Read path: require balanced BEGIN/END markers as well as containment.
Containment alone cannot see a tear. A bundle whose EARLIER entry lost its
END line still literally contains our CA further down, so the contains-ours
gate accepts it — and that is the fatal ordering, not the benign one.
Measured on node v24.11.1 / openssl 3.5.4 against a leaf signed by the CCF
CA, with a real TLS handshake rather than a file-content check:
torn ahead of ours -> UNABLE_TO_VERIFY_LEAF_SIGNATURE
ours alone -> authorized
torn after ours -> authorized (warns "bad end line")
The builder concatenates sort(ca-trust.d/*.pem) and "ccf.pem" sorts first,
so a torn OURS lands in exactly the position that voids the whole bundle,
taking every other component CA and corporate root with it. The two checks
are complementary: containment catches a STALE bundle, the marker counts
catch a TORN one, and neither sees the other's case.
Write path: publish ca-trust.d/ccf.pem via temp + rename instead of
writeFileSync. A plain write opens the target with O_TRUNC and leaves a
torn pem visible to any builder reading the directory during the write —
producing exactly the bundle the read path now has to reject. The temp
lives in the same directory so the rename cannot cross a filesystem
boundary, and carries the pid so two concurrent launches do not collide.
Tests. Each guard is pinned by one case, and each was mutation-tested in
both directions so none is dead weight: dropping the marker check fails
only the torn-bundle case, dropping containment fails only the stale case,
and reverting the atomic publish fails only the atomicity case.
Also adds two handshake tests. Every other check in this area — ours and
the sibling components' — inspects file CONTENT, which never proves Node
verifies a leaf with the file we hand it. These stand up a TLS server using
the ensureCA() leaf and connect trusting only the selected bundle, with the
no-extra-CA control required to FAIL so a green result cannot pass for an
unrelated reason.
Test isolation, unrelated to the above but surfaced by it: cleanEnv() built
the child env by merging overrides into a copy of process.env, so ambient
variables leaked into the wrapper tests. Two cases read the developer's real
~/.claude/ca-trust.pem instead of a fixture, and the lowercase-no_proxy
merge case read the shell's NO_PROXY. Now stripped from the base before
overrides apply. The wrapper suite goes 15 pass / 2 fail -> 18 / 0 on a host
that exports NO_PROXY and has a merged bundle deployed.
README: document that CACHE_FIX_DOWNLOAD_REWRITE=on disables `claude update`
outright. It reads like a performance knob and was previously mentioned only
in a CHANGELOG parenthetical as "download-acceleration"; that misreading cost
several days of a broken updater. Rewriting a URL requires MITM-ing
downloads.claude.ai, whose release client pins public roots only. It cannot
be narrowed to the binary download (MITM is decided per host at CONNECT and
the version check shares the host), and no client-side override reaches that
client, so no CA injection can make it work.
Co-Authored-By: Claude <noreply@anthropic.com>
…ted comment ponytail-review on 226452e: runOnce was defined twice with byte-identical bodies, and the torn-PEM measurement was spelled out in full at both the write and the read path. Hoist runWrapper() next to cleanEnv and cross-reference the measurement instead of repeating it. No behaviour change. wrapper + forward-ca 28/28. Co-Authored-By: Claude <noreply@anthropic.com>
…ly check The README explained that --remote-control sets NODE_EXTRA_CA_CERTS but not how that coexists with another MITM on the same host, which is the case the publish/read paths exist for. Adds the contract: publish our own pem, read a bundle we never build, fall back to our own CA when it is absent or unusable. Includes the boundary the builder asked be stated explicitly — a consumer can verify the bundle is intact and carries its own CA, but not that it is complete. Completeness needs previous state to compare against, so it is the builder's guarantee; a legitimately small bundle and a narrowed one look identical to a reader. Every claim checked against the code, not written from the design notes. Co-Authored-By: Claude <noreply@anthropic.com>
…olate tests
Code review found the substring guards accept bundles that break the session.
Reproduced each against a real handshake before changing anything:
bundle shape old guard | handshake
corrupt base64 ahead, markers intact | accept | FAIL
torn BEGIN TRUSTED CERTIFICATE ahead | accept | FAIL
whole bundle CRLF-normalized | reject | authorized
Counting BEGIN/END says nothing about whether a body decodes, and hard-coding
the CERTIFICATE label makes any other label a corporate bundle carries invisible
to the count -- so a torn one lands in the fatal leading position unseen. Now
every PEM block must construct an X509Certificate and one must equal ours by
DER, which also fixes the CRLF false reject and closes a vacuous case: an empty
ca.pem made `merged.includes("")` true, accepting a bundle that did not carry us
at all. Verified against the live 132-cert bundle on this host: still accepted.
A rejected bundle that EXISTS now warns on stderr. Silence stays for absent,
which is the normal state on a host with no builder. In a three-component
contract a broken builder has to be visible: the session still works, while
every other component's CA is silently gone.
Tests were corrupting the developer's machine. Six --remote-control cases set no
CLAUDE_CONFIG_DIR, so the launcher published a throwaway temp CA over the real
~/.claude/ca-trust.d/ccf.pem -- measured going 5dc414fc -> 3773c611 from one
test, leaving the host's merged bundle advertising a CA nothing signs with, the
exact failure this feature prevents. It also poisoned a previous mutation
result. cleanEnv() now allocates a config dir by default rather than per-test.
The atomicity test was tautological: it passed against `unlink; write`, which is
maximally non-atomic. Rebuilt around sampling the path by name across the whole
launch, and renamed to what it actually proves -- no TRUNCATED file is ever
visible. Mutation-tested: O_TRUNC now fails it, unlink+write still passes (its
window is under the 1 ms sampler floor), rename passes. Named honestly rather
than claiming atomicity it cannot demonstrate.
Also: uuid in the temp name (pid collides across PID namespaces sharing a
bind-mounted config dir), reap orphaned temps, CHANGELOG entry, and the missing
CACHE_FIX_CA_TRUST_DIR row in the README env table.
wrapper + forward-ca 30/30, host pem unchanged across the run.
Co-Authored-By: Claude <noreply@anthropic.com>
…ns its place ponytail-review on ecd599c. Two of three findings applied: - the measurement table appeared in the commit message, the PR body, and the read-path comment. Comment now states the conclusion and points at the test that asserts it, which is the copy that cannot go stale. - noted at the impl site that test/proxy-forward-ca.test.mjs mirrors the same decision (the launcher is a top-level script, so a test cannot import it). The third finding was WRONG and is recorded here so it is not retried. It read the fd/inode assertions in the publish test as duplicated by the 1 ms sampler. Removing them was mutation-tested and silently dropped O_TRUNC detection: the sampler cannot see the truncation window for a ~1.2 KB write at all (100 samples across a truncate+write observed only the complete file, never length 0). The descriptor is the precise observer, the sampler is the cheap wide net, and they catch different things. Both kept, with the measurement in the comment. wrapper + forward-ca 30/30, host pem unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
The comments named the specific account-switching proxy this was measured against. Upstream readers have no such component; the hazard is generic to any second MITM on the same host, so name the shape rather than the tool. Co-Authored-By: Claude <noreply@anthropic.com>
…nighswonger#283 pending) integrated = upstream/main + every one of our still-open upstream PRs. Rebuilt from upstream/main, not cherry-picked onto the old integrated, so the branch stays reproducible from its inputs. cnighswonger#261 (absolute-form request-targets) is NO LONGER merged here: upstream took it as 8b25dc9 on 2026-07-31, so it arrives through upstream/main and merging the branch again would only replay it.
2f8b2b9 to
e25dd34
Compare
…nighswonger#283 pending) integrated = upstream/main + every one of our still-open upstream PRs. Rebuilt from upstream/main, not cherry-picked onto the old integrated, so the branch stays reproducible from its inputs. cnighswonger#261 (absolute-form request-targets) is NO LONGER merged here: upstream took it as 8b25dc9 on 2026-07-31, so it arrives through upstream/main and merging the branch again would only replay it.
Two contract-level fixes from review of this PR. The reaper deleted every ccf.pem.* after a publish, which cannot distinguish an orphan from a CONCURRENT launcher's temp — one that has been written but not yet renamed. Deleting that makes the peer's renameSync throw a publish failure we caused, and leaves whichever launcher won first on disk rather than the current publisher's bytes. Name carries no provenance, so age is the signal: the write-to-rename window is one small write to the same directory, microseconds, and a minute of gate is four orders of magnitude of headroom. Reaping late costs nothing (nothing reads these); reaping early breaks a peer. CACHE_FIX_CA_TRUST_DIR is removed. The publish path had an override while the read path was a fixed name, so setting it made this launcher publish where no builder looks while still consuming the canonical bundle — silently dropping out of the contract while appearing to implement it. The two paths are halves of one rendezvous and must move together; CLAUDE_CONFIG_DIR already does that. The new test asserts both reaper outcomes across ONE launch: an old orphan is collected and a fresh sibling survives. Mutation-checked in both directions — removing the age gate reddens the survives assertion, removing the rm reddens the collected one. README now also states the boundary explicitly: the guard proves parses + carries us, never contains only approved writers. This is a cooperative convention among same-user processes, not a defense against a local attacker, who could equally replace ccf.pem or the CA dir. Co-Authored-By: Claude <noreply@anthropic.com>
e25dd34 to
a329f21
Compare
…nighswonger#283 pending) integrated = upstream/main + every one of our still-open upstream PRs. Rebuilt from upstream/main, not cherry-picked onto the old integrated, so the branch stays reproducible from its inputs. cnighswonger#261 (absolute-form request-targets) is NO LONGER merged here: upstream took it as 8b25dc9 on 2026-07-31, so it arrives through upstream/main and merging the branch again would only replay it.
There was a problem hiding this comment.
Review: PR #283 — fix(launcher): coexist with another MITM via ca-trust.d instead of clobbering NODE_EXTRA_CA_CERTS
Date: 2026-08-01
Reviewed: README.md, CHANGELOG.md, bin/claude-via-proxy.mjs, test/proxy-forward-ca.test.mjs, test/proxy-wrapper.test.mjs at a329f21ae583ac3a7333197dd7cb7acb182aee97, re-reviewed as a clean merge onto origin/main 209f8679b1fea8daeac65a10cfec8db6ed3d63ee
Round: 2
Label applied: reviewed-by-codex-agent, approved-by-codex-agent
What Is Correct
- [Measured]
node --testin a detached worktree with PR head merged ontoorigin/mainpassed1499tests,0failed, in31428.832324ms. - [Read] Round-1 blocker 1 is fixed in the launcher. The publish path is now the canonical
join(configDir, "ca-trust.d")with no publisher-only override, and the temp reaper now only removesccf.pem.*files older than60_000msbystatSync(...).mtimeMsrather than deleting every temp unconditionally (bin/claude-via-proxy.mjs:233-280). - [Measured] The new wrapper test
--remote-control reaps an old orphan temp but leaves a concurrent publisher's fresh one alonepassed in the merged suite, exercising both sides of the reaper decision in one launch (test/proxy-wrapper.test.mjs:470-500). - [Measured] I benchmarked the vulnerable write→rename window on this host with
node -e '...writeFileSync(tmp); renameSync(tmp,dst)...'for5000iterations in one directory; result:iterations=5000 max_ms=0.880873. That is ample headroom for ordinary host load relative to a60_000msgate. - [Read] A deliberately frozen process could still exceed the age gate, but I did not reproduce that and the code path between
writeFileSyncandrenameSynchas no awaits or external RPCs (bin/claude-via-proxy.mjs:260-262). For the race I blocked on in round 1, this is now proportionate: deleting late has no functional cost, while deleting early is what breaks a peer. - [Measured] Round-1 blocker 2 is fixed.
rg -n "CACHE_FIX_CA_TRUST_DIR" README.md CHANGELOG.md bin/claude-via-proxy.mjs test/proxy-wrapper.test.mjs test/proxy-forward-ca.test.mjsreturnedno matches, and the code now hard-codes the canonical rendezvous paths<config>/ca-trust.d/ccf.pemand<config>/ca-trust.pem(bin/claude-via-proxy.mjs:223-235,287-358;README.md:112-139;CHANGELOG.md:7). - [Read] The round-1 documentation caveat is now stated honestly. The README now says this is a cooperative same-user convention, not a trust boundary, and that the reader proves only “parses, and carries us,” not “contains only approved writers” (
README.md:141-154). - [Read] The X509/DER guard remains intact after the refactors. The launcher still normalizes CRLF, requires balanced
BEGINcounts vs parsed blocks, constructsX509Certificatefor every block, and matches our CA by DER before accepting the merged bundle (bin/claude-via-proxy.mjs:335-349). The mirrored handshake coverage is still present intest/proxy-forward-ca.test.mjs:242-349, and the wrapper-level stale/torn bundle tests still cover the launcher path intest/proxy-wrapper.test.mjs:502-565. - [Measured] The no-other-MITM fallback remains byte-for-byte the old path in behavior. The merged suite passed
--remote-control falls back to its own CA when no merged bundle exists (unchanged standalone behaviour)and--remote-control wires forward-proxy env (BASE unset, HTTPS_PROXY + CA set)(test/proxy-wrapper.test.mjs:160-194,324-349).
Blockers
- None.
What Needs Attention
- [Reported] The author’s live multi-MITM host measurements remain unreproduced here. I did not independently recreate a second live MITM or a real external bundle builder; my approval is based on code read, targeted host measurements, and the merged automated suite. Because this PR is load-bearing (TLS trust path plus a shared on-disk contract), the existing
needs-sim-validationlabel should remain until human/live validation is complete.
Bloat / Non-Functional
- [Measured] Proportion is acceptable for the defect being fixed.
git diff --numstat origin/main...pr-283-headshows248added production lines (README.md91,CHANGELOG.md8,bin/claude-via-proxy.mjs149) and545added test lines (test/proxy-forward-ca.test.mjs144,test/proxy-wrapper.test.mjs401), for an added-lines test:prod ratio of about2.2x. - [Measured] Surface-area growth is restrained at head:
0new files,0new exports,0new env vars, and2on-disk contract paths (<config>/ca-trust.d/ccf.pem,<config>/ca-trust.pem). - [Measured] Comment density is high where it should be. On the production code diff for
bin/claude-via-proxy.mjs, I counted105added comment lines and44added non-comment code lines. Under this repo’s current anti-bloat rule that is a non-finding, because the comments explain the race and trust-path mechanics rather than restating the code.
Recommendations
- [Read] I now consider the on-disk contract sound enough to commit to for the three participating components: one fixed publish filename, one fixed merged-bundle filename, explicit same-user/cooperative scope, and a fail-closed reader that rejects stale or malformed bundles before they can break this session.
- [Reported] Keep the human review and live validation step. This change is materially better than round 1, but it still fixes a security-relevant trust path by introducing a cross-component filesystem contract, so Codex approval is necessary and not sufficient.
Bottom Line
The two round-1 contract blockers are genuinely fixed. The launcher now stays on the canonical rendezvous, the temp reaper no longer races an ordinary concurrent publisher, the trust-bundle reader keeps its parse-and-DER guard, and the documentation now states the trust boundary honestly. I am approving this PR, with the remaining live multi-MITM validation explicitly carried as reported rather than reproduced, and with the expectation that Chris will do the final human review because the contract is load-bearing. — Codex review
|
Round 2: both blockers confirmed fixed. Codex approves; I verified independently before dispatching. Age-gated reaper —
The README caveat is the part I'd single out. The X509/DER guard survived the refactors intact (CRLF normalization, balanced BEGIN counts, Merged suite: 1499 pass, 0 fail on current Remaining, and it's not on you: live multi-MITM validation can't be reproduced here — we have no second MITM or external bundle builder. Your measurements stand as reported, not confirmed. Nothing further from us. Thanks for the turnaround — and for tightening the reaper to three lines afterward rather than leaving the first working version. — Proxy Builder |
The guard shipped in cnighswonger#283 disagreed with a real handshake on 8 of 20 measured bundle shapes (node v24.11.1 / openssl 3.6.1). Seven were needless refusals of healthy bundles. Every PEM block was parsed as a certificate, so any non-certificate block a merged bundle legitimately carries — a CRL, a public key, key material — threw and voided the whole file; and the torn-block check counted raw occurrences of "-----BEGIN ", so a provenance comment that merely mentioned the marker made a healthy bundle look torn. Refusing is not the safe direction: the fallback drops every sibling and corporate CA for that session, which is the failure this contract exists to prevent. The eighth was the dangerous direction. Our own CA relabelled TRUSTED CERTIFICATE parses to byte-identical DER, so the guard reported "carries our CA" while node's loader skipped the block entirely, leaving the session trusting nothing and failing every request with UNABLE_TO_VERIFY_LEAF_SIGNATURE. The guard's own comment promised it was "allowed to be conservative, never permissive" — this was permissive. Markers are now anchored to line starts, non-CERTIFICATE blocks are skipped the way node skips them, and the DER match must land on a CERTIFICATE block. Where the guard cannot tell — a block damaged AFTER ours, whose truncated body may or may not still decode, since openssl's base64 reader treats the next '-' as end-of-data rather than an error — it refuses. The decision moved to bin/ca-trust.mjs so the tests drive the shipped code. It was inline in a top-level script with a hand-copied twin in the test file under a "change one, change both" comment; measured, mutating the real one left the entire suite green. The test file's oracle was also wrong: it verified through tls.connect({ca}), which ACCEPTS the relabelled bundle that NODE_EXTRA_CA_CERTS rejects, so it was certifying the guard against a mechanism the launcher does not use. Three related defects in the same block: - The orphan reaper shared the publish try, so rename() throwing skipped it. On exactly the hosts where publishing is persistently broken (a root-owned ccf.pem, a read-only mount, ENOSPC) each launch abandoned one full-CA temp and collected none. - Our own CA was parsed inside the bundle try, so an unparseable ca.pem was reported as `ignoring <ca-trust.pem> (...)` — naming a file that may be healthy — and then fell back to the file that had just failed to parse. - The spawned proxy's `export NODE_EXTRA_CA_CERTS=<our ca.pem>` recipe was relayed to the operator immediately after the launcher had wired claude via ca-trust.d, telling them to undo it. The launcher now drops those lines from the stderr it relays; standalone the recipe carries a same-host-MITM caveat. Every clause is covered in both directions: five mutations of the guard and one of each fix above were each caught by exactly one test. prod +58 code / +101 comment, tests +199 (3.4x), 1 new file, 0 new env vars. Full suite 1501 pass / 2 fail, both EMFILE from an fs.watch test that fails identically at the merge base (inotify max_user_instances=128 on this host, unrelated to this change). Co-Authored-By: Claude <noreply@anthropic.com>
Counting BEGIN/END markers cannot tell whether a block DECODES, and that is
the only thing node's CA loader cares about. Measured on this host with a real
TLS handshake through NODE_EXTRA_CA_CERTS, against a leaf signed by our own CA
and with a no-extra-CA control required to FAIL:
shape guard node
torn cert BEFORE ours accept REFUSE <-- false accept
Node aborts the ENTIRE extras load on one block it cannot decode, so a torn
certificate ahead of ours voids every component CA and every corporate root at
once. The session then cannot verify the very proxy it is routed through and
every request dies — strictly worse than not using the shared bundle at all.
Replaced with parsing. Every block must be provably loadable, and the bar
differs by label because node's does: a CERTIFICATE must parse as X.509 (valid
base64 that is not a certificate still kills the load), everything else needs
only intact armor — demanding X.509 of CRLs and key blocks would reject the
bundles a real corporate host legitimately carries, and that false reject costs
every sibling CA for the whole session. Identity is by DER, not by substring,
so a re-encoded copy of our CA is still our CA.
CROSS-CHECKED AGAINST THE SIBLING IMPLEMENTATION. This is one contract in three
languages (realiti4 pin, CCF launcher, bundle builder), so a guard that differs
from its siblings is a bug regardless of which one is "right". Ran both against
node itself over 11 shapes:
pin-vs-CCF disagreements: 0/11
including the shapes CCF's own review rounds found: a marker quoted in prose, a
BEGIN wearing a trailing space, CRLF endings, a corrupt non-certificate block,
our CA relabelled TRUSTED CERTIFICATE (byte-identical DER, and node skips it).
`torn cert AFTER ours` is refused by both while node accepts it — the allowed
direction, since a block damaged after ours may or may not still decode and
refusing costs one session's sibling CAs rather than the session.
My first attempt anchored the BEGIN match at `$`, which made every CRLF bundle
invisible — read as "carries no CA" and dropped the whole file. The test caught
it; the fix is `\r?$`.
THE FIXTURES WERE THE OTHER HALF OF THE BUG. Four helpers wrote "PIN" and
"OTHER" as certificate bodies. No X.509 reader can decode those, so the tests
were certifying the guard against bundles node itself would refuse — the exact
false accept the guard now exists to stop. They mint real CAs now, and the
helper returns a terminated PEM because concatenating stripped ones fuses
`-----END-----` into `-----BEGIN-----`, a fixture bug that reads exactly like a
guard bug.
Mutation-verified; each kills exactly its own test:
back to the marker count -> 3 fail
skip non-certificate blocks -> 1
unterminated block skipped -> 1
identity by substring instead of DER -> 1
Also here: `heal` now re-wires a daemon that is SERVING while the config names
nothing. That state is what a recovery leaves behind, and reading "already
serving" as "nothing to do" left the proxy serving a port no session was ever
told about — measured, and only a hand-typed `cswap pin <n>` fixed it.
Refs cnighswonger/claude-code-cache-fix#283, #293, #296.
216 -> 223 tests.
Co-Authored-By: Claude <noreply@anthropic.com>
…that records Ships three fixes whose common shape is a check that could not see what it claimed to check. CA-TRUST GUARD (the reason this release is not waiting). The merged `ca-trust.pem` guard counted BEGIN/END markers, which cannot tell whether a block DECODES — and that is the only thing node's CA loader cares about. Measured against a real handshake: a torn certificate ahead of ours was ACCEPTED by the guard and REFUSED by node, which voids every component CA and every corporate root at once and leaves the session unable to verify the proxy it is routed through. Now parses per label, with DER identity, and agrees with the sibling CCF implementation on 11/11 shapes. TLS-OVER-TLS. `wrap_socket` on an already-wrapped socket re-wraps the file descriptor, not the stream: it destroyed the outer session (fileno -1) and every pinned request through an https:// egress proxy died at EOF. Layered with memory BIOs now. MSG_PEEK ON AN SSLSocket raises ValueError, which is not an OSError — so the blind-tunnel health check did not merely fail, it killed the connection AND the direct-dial fallback it exists to trigger. CONCURRENT ensure_ca could produce a CA that does not sign the leaf, and never self-healed because the function is idempotent. One lock, one question: does the CA sign the leaf, for this host, unexpired. DAEMON LIFECYCLE IS NOW LOGGED. `daemon.log` was zero bytes for a daemon that served for hours and vanished, which is the only reason one outage stayed unattributable. Start, teardown-with-reason, and a failed unwire all recorded. HEAL RE-WIRES A SERVING DAEMON whose config names nothing — the state a recovery leaves behind. Reading "already serving" as "nothing to do" left the proxy serving a port no session was told about until someone re-pinned by hand. Also: the private key is created at 0600 rather than narrowed to it, the recorded upstream CA is finally consulted when dialling an https:// chain, the CONNECT status parse no longer accepts a refusal that merely mentions 200, and _pump drains the SSL buffer before returning to select. 207 -> 224 tests. New: a test that the two version strings agree, because nothing tied them together and a PyPI version cannot be re-uploaded. Refs cnighswonger/claude-code-cache-fix#283, #293, #296. Co-Authored-By: Claude <noreply@anthropic.com>
Goal
NODE_EXTRA_CA_CERTSaccepts exactly one file. On a host where something else also MITMsapi.anthropic.com— a corporate agent, an account-switching pin proxy — whoever assigns that variable last silently untrusts every other CA. Measured 2026-07-30: two such components on one machine took turns breaking each other's TLS, with no error attributable to either. Remote Control inbound stopped working and neither component logged anything.This implements the consumer + publisher half of a cross-component contract, negotiated with the two other components' owners and implemented independently in three languages:
<config>/ca-trust.d/<component>.pem— own filename, never a sibling's, rewritten every launch, content-compare to skip a no-op<config>/ca-trust.pem= ambient/corporate roots +sort(ca-trust.d/*.pem); components never write it<config>=CLAUDE_CONFIG_DIR || ~/.claude, resolved identically by all threeA host with no other MITM and no bundle builder sees no change at all — the launcher falls back to its own CA, which is byte-for-byte what it did before.
Non-Functional Requirements
Size/complexity budget. ~130 lines in
bin/claude-via-proxy.mjs(two blocks in one existing function, no new module), ~500 in tests, ~80 in docs. No new dependency: the guard usesnode:crypto'sX509Certificaterather than forkingopenssl.Threat model. This decides what the client trusts for the host its API keys travel to, so both failure directions matter and are treated asymmetrically:
So the guard is allowed to be conservative, never permissive. Verified:
ca.pemis cert-only, no private key is read or published, and nothing is written outsideca-trust.d/. The publish path never follows a symlink into a victim file —renamereplaces the link itself (verified).Maintainability. No new abstraction in the launcher. One test helper was hoisted because two tests need the same two-launch sequence; the older inline forks were left alone.
Performance/reliability. One extra file read and up to N
X509Certificateconstructions per launch. Measured against the live 132-cert bundle on the dev host: not perceptible next to spawning the proxy.Load-bearing? YES. TLS trust path, shared on-disk contract with two external components.
What the guards actually check
Both conditions are checked by parsing, not by matching substrings. Node's PEM reader aborts the whole extras load on one block it cannot decode, so a damaged entry does not merely lose itself — it can void every other component CA and corporate root in the file, ours included.
A first implementation used substring checks (balanced
BEGIN/ENDmarkers +includes(ourCA)). Code review found it accepts bundles that break the session. Reproduced with real TLS handshakes against a leaf signed by the CCF CA, node v24.11.1 / openssl 3.5.4:BEGIN TRUSTED CERTIFICATEaheadCounting
BEGIN/ENDsays nothing about whether a body decodes, and hard-coding theCERTIFICATElabel makes any other label a corporate bundle carries invisible to the count — so a torn one lands in the fatal leading position unseen. There was also a vacuous case: an emptyca.pemmakesmerged.includes("")true, accepting a bundle that does not carry us at all.Now: every PEM block must construct an
X509Certificate, and one must equal ours by DER (which also fixes the CRLF false reject). Verified against the live 132-cert bundle on the dev host — still accepted, so real-world behavior is unchanged.Position matters, and it is why this is not belt-and-braces:
The builder concatenates
sort(ca-trust.d/*.pem), so a tornccf.pemsorts early and lands in exactly the fatal position.Atomic publish
ca-trust.d/ccf.pemis published via temp +renamerather thanwriteFileSynconto the target. A plain write opens withO_TRUNCand leaves a torn pem visible to a builder reading the directory — producing precisely the bundle the read path now has to reject. Temp is in the same directory (a cross-filesystem rename is not atomic and wouldEXDEV) and carries pid + uuid: pid alone collides across PID namespaces sharing a bind-mounted config dir. Temps orphaned by a kill between write and rename are reaped on the next launch.Observability
A bundle that exists and is refused now warns on stderr. Silence is kept for absent, which is the normal state on a host with no builder. In a three-component contract a broken builder has to be visible: the session still works (we fall back), while every other component's CA is silently gone — the failure nobody would otherwise notice.
What a consumer cannot check
The guard establishes that the file parses and carries us. It cannot establish that the bundle is complete — a reader has no previous state to compare against, and a legitimately small bundle is indistinguishable from a narrowed one. Measured across three healthy hosts: 2, 132, and 168 certs. Any cert-count floor tuned to one breaks another. Completeness is therefore the builder's guarantee, stated as such in the README so nobody later adds a floor.
Nor do the guards prove usability: only a handshake does that, and the launcher performs none. They are pre-flight checks that keep a known-bad bundle away from the client.
Tests
test/proxy-wrapper.test.mjs(18) andtest/proxy-forward-ca.test.mjs(12), including:ca.pemdoes not make the check vacuousensureCA()leaf, with a no-extra-CA control required to FAIL so a green result cannot pass for an unrelated reasonEvery guard was mutation-tested, so none is decoration:
writeFileSync(dst)(O_TRUNC)One test is named for less than it might appear to prove. The publish test is called "never leaves a truncated
ccf.pemvisible" rather than "publishes atomically", because mutation testing showedunlink; write— maximally non-atomic — still passes it: that window is shorter than the sampler's 1 ms floor. Catching it would need an inotify watch forIN_DELETE-before-IN_CREATE, which is not worth a dependency for a shape nothing here writes. Named for what it demonstrates.Test isolation fix (please read even if skimming)
Six
--remote-controltests set noCLAUDE_CONFIG_DIR, so the launcher published a throwaway temp CA over the developer's real~/.claude/ca-trust.d/ccf.pem. Measured: one test run took the host's pem from5dc414fcto3773c611, leaving that machine's merged bundle advertising a CA nothing signs with — the exact failure this feature exists to prevent. It also silently poisoned an earlier mutation-test result.cleanEnv()now allocates a config dir by default rather than per-test opt-in, and each run is verified to leave the host pem untouched.Separately, ambient
NO_PROXYleaked into the child and made the lowercase-no_proxymerge test read the shell's value instead of the fixture — pre-existing, unrelated to ca-trust, fixed by the same change.Docs
ca-trust.dcontract, the fallback ladder, and the consumer/builder boundary.CACHE_FIX_DOWNLOAD_REWRITE=ondisablesclaude updateentirely. It reads like a performance knob and was previously mentioned only in a CHANGELOG parenthetical as "download-acceleration"; that misreading cost several days of a broken updater on a fleet. Rewriting a URL requires MITM-ingdownloads.claude.ai, whose release client pins public roots only. It cannot be narrowed to the binary download (MITM is decided per host atCONNECT, and the version check shares the host), and no client-side override reaches that client —HTTPS_PROXY,/etc/hosts,/etc/resolv.conf,NODE_EXTRA_CA_CERTSwere each disproved against a control.CACHE_FIX_CA_TRUST_DIRrow added to the env table; CHANGELOG entry added.Test results
Full suite on this branch: 1390 tests, 1389 pass, 1 fail.
The one failure is
proxy-server.test.mjs→ "POST /v1/messages routes to upstream", which needs real upstream reachability. Proven pre-existing rather than asserted: the entire change was stashed to a tree byte-identical to the base (git statusempty), and the same test fails the same way there.Worth knowing for anyone running the suite behind a proxy: that test hangs indefinitely rather than failing when a proxy env var is set, and
--test-timeoutdoes not cut it. Run withenv -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxyor it never terminates.Base
Branches from current
main; independent of #261 (touches no file that PR touches).