Skip to content

fix(launcher): coexist with another MITM via ca-trust.d instead of clobbering NODE_EXTRA_CA_CERTS - #283

Merged
vsits-proxy-builder[bot] merged 8 commits into
cnighswonger:mainfrom
codeslake:ca-trust-upstream
Aug 1, 2026
Merged

fix(launcher): coexist with another MITM via ca-trust.d instead of clobbering NODE_EXTRA_CA_CERTS#283
vsits-proxy-builder[bot] merged 8 commits into
cnighswonger:mainfrom
codeslake:ca-trust-upstream

Conversation

@codeslake

Copy link
Copy Markdown
Contributor

Goal

NODE_EXTRA_CA_CERTS accepts exactly one file. On a host where something else also MITMs api.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:

  • each component writes only <config>/ca-trust.d/<component>.pem — own filename, never a sibling's, rewritten every launch, content-compare to skip a no-op
  • exactly one external writer builds the merged <config>/ca-trust.pem = ambient/corporate roots + sort(ca-trust.d/*.pem); components never write it
  • consumers read the merged bundle if usable, else fall back to their own CA
  • <config> = CLAUDE_CONFIG_DIR || ~/.claude, resolved identically by all three

A 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 uses node:crypto's X509Certificate rather than forking openssl.

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:

  • accepting a bad bundle makes the client distrust the very proxy it is routed through — every request fails TLS. Strictly worse than not using the bundle at all.
  • rejecting a good bundle costs only the other components' CAs; the session still works.

So the guard is allowed to be conservative, never permissive. Verified: ca.pem is cert-only, no private key is read or published, and nothing is written outside ca-trust.d/. The publish path never follows a symlink into a victim file — rename replaces 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 X509Certificate constructions 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/END markers + 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:

bundle shape substring guard real 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. There was also a vacuous case: an empty ca.pem makes merged.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:

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), so a torn ccf.pem sorts early and lands in exactly the fatal position.

Atomic publish

ca-trust.d/ccf.pem is published via temp + rename rather than writeFileSync onto the target. A plain write opens with O_TRUNC and 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 would EXDEV) 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) and test/proxy-forward-ca.test.mjs (12), including:

  • publish happens before exec, to our filename only, never a sibling's, never the merged bundle
  • fallback to our own CA with no bundle — the unchanged-standalone case
  • every known-bad bundle shape, with the guard's verdict required to agree with a real handshake
  • an empty ca.pem does not make the check vacuous
  • two handshake tests standing up a TLS server with the ensureCA() leaf, with a no-extra-CA control required to FAIL so a green result cannot pass for an unrelated reason

Every guard was mutation-tested, so none is decoration:

mutation result
drop the block-parse check only the torn-bundle case fails
drop containment only the stale-bundle case fails
writeFileSync(dst) (O_TRUNC) only the truncation case fails
bundle without our CA only the positive handshake fails

One test is named for less than it might appear to prove. The publish test is called "never leaves a truncated ccf.pem visible" rather than "publishes atomically", because mutation testing showed unlink; 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 for IN_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-control tests set no CLAUDE_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 from 5dc414fc to 3773c611, 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_PROXY leaked into the child and made the lowercase-no_proxy merge test read the shell's value instead of the fixture — pre-existing, unrelated to ca-trust, fixed by the same change.

Docs

  • README: the ca-trust.d contract, the fallback ladder, and the consumer/builder boundary.
  • README: CACHE_FIX_DOWNLOAD_REWRITE=on disables claude update entirely. 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-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 — HTTPS_PROXY, /etc/hosts, /etc/resolv.conf, NODE_EXTRA_CA_CERTS were each disproved against a control.
  • Missing CACHE_FIX_CA_TRUST_DIR row 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 status empty), 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-timeout does not cut it. Run with env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy or it never terminates.

Base

Branches from current main; independent of #261 (touches no file that PR touches).

@vsits-codex-review-agent vsits-codex-review-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:286 only accepts ca-trust.pem if every PEM block parses via X509Certificate and one block DER-equals our CA; otherwise it falls back to our own ca.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_CERTS at ${CACHE_FIX_CA_DIR||claudeHome()/cache-fix-ca}/ca.pem (bin/claude-via-proxy.mjs:284-342), and test/proxy-wrapper.test.mjs:324-348 pins that unchanged fallback.
  • CLAUDE_CONFIG_DIR resolution matches the existing claudeHome() 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-349 verifies the guard against real TLS authorization outcomes, and the merged tree passes the full suite (node --test: 1448 passing tests on my run).

Blockers

  1. bin/claude-via-proxy.mjs:257-261 reaps every ccf.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 executed renameSync(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 because CACHE_FIX_CA_DIR points 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.
  2. The PR says the contract is a fixed on-disk rendezvous at <config>/ca-trust.d/<component>.pem plus <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 in README.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.pem with 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 X509Certificate behavior, 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_DIR from 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

@vsits-codex-review-agent vsits-codex-review-agent Bot added changes-requested Blocking review findings are outstanding schema-change Changes affect extension pipeline interface, telemetry format, or config schema needs-sim-validation Requires integration testing with live CC traffic labels Jul 31, 2026
@vsits-proxy-builder

Copy link
Copy Markdown
Contributor

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 (bin/claude-via-proxy.mjs:260-262)

for (const f of readdirSync(caTrustDir)) {
  if (f.startsWith("ccf.pem.")) try { rmSync(join(caTrustDir, f)); } catch { }
}

This runs unconditionally after your own renameSync, and deletes every ccf.pem.* — including a sibling launcher's temp that has been written but not yet renamed. That launcher's renameSync then throws, and it logs a publish failure it didn't cause. If the two launchers aren't byte-identical (different CACHE_FIX_CA_DIR), whichever won first is what stays on disk. The PR body states orphan reaping is safe under concurrency; as written it isn't.

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. CACHE_FIX_CA_TRUST_DIR breaks the rendezvous the PR is establishing

Publish path is CACHE_FIX_CA_TRUST_DIR || <config>/ca-trust.d (line 227); read path is hardcoded <config>/ca-trust.pem (line 284). Set the override and this launcher publishes where no builder is looking while still consuming the canonical bundle — silently dropping out of the contract while appearing to implement it. For a three-language contract that's meant to be durable, one participant being able to unilaterally relocate its half is the thing most likely to cause a confusing outage a year from now.

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 X509Certificate + DER comparison is the correct fix — the vacuous merged.includes("") case was a genuine hole. The no-other-MITM path is a real no-op, which matters because that's every user who isn't you. And the TLS-handshake tests are doing actual work rather than asserting on string shapes.

Two process notes:

  • This is load-bearing (TLS trust path + cross-component on-disk contract), so it needs human review from Chris in addition to ours before merge. Labelled schema-change and needs-sim-validation accordingly.
  • We can't run CI on fork PRs (workflow approval is gated and our bot can't clear it), so we verify the full suite locally at the merge commit. Yours passed at 1448 merged onto current main.

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

codeslake and others added 7 commits August 1, 2026 02:08
… 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>
codeslake added a commit to codeslake/claude-code-cache-fix that referenced this pull request Aug 1, 2026
…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.
codeslake added a commit to codeslake/claude-code-cache-fix that referenced this pull request Aug 1, 2026
…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>
codeslake added a commit to codeslake/claude-code-cache-fix that referenced this pull request Aug 1, 2026
…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.

@vsits-codex-review-agent vsits-codex-review-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --test in a detached worktree with PR head merged onto origin/main passed 1499 tests, 0 failed, in 31428.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 removes ccf.pem.* files older than 60_000ms by statSync(...).mtimeMs rather 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 alone passed 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)...' for 5000 iterations in one directory; result: iterations=5000 max_ms=0.880873. That is ample headroom for ordinary host load relative to a 60_000ms gate.
  • [Read] A deliberately frozen process could still exceed the age gate, but I did not reproduce that and the code path between writeFileSync and renameSync has 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.mjs returned no matches, and the code now hard-codes the canonical rendezvous paths <config>/ca-trust.d/ccf.pem and <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 BEGIN counts vs parsed blocks, constructs X509Certificate for 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 in test/proxy-forward-ca.test.mjs:242-349, and the wrapper-level stale/torn bundle tests still cover the launcher path in test/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-validation label 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-head shows 248 added production lines (README.md 91, CHANGELOG.md 8, bin/claude-via-proxy.mjs 149) and 545 added test lines (test/proxy-forward-ca.test.mjs 144, test/proxy-wrapper.test.mjs 401), for an added-lines test:prod ratio of about 2.2x.
  • [Measured] Surface-area growth is restrained at head: 0 new files, 0 new exports, 0 new env vars, and 2 on-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 counted 105 added comment lines and 44 added 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

@vsits-codex-review-agent vsits-codex-review-agent Bot added reviewed-by-codex-agent Directive/spec reviewed by Codex — no blocking findings approved-by-codex-agent Final implementation approval from Codex Agent and removed changes-requested Blocking review findings are outstanding labels Aug 1, 2026
@vsits-proxy-builder

Copy link
Copy Markdown
Contributor

Round 2: both blockers confirmed fixed. Codex approves; I verified independently before dispatching.

Age-gated reaperstatSync(...).mtimeMs against a 60s gate (bin/claude-via-proxy.mjs:275-283). What makes this the right fix isn't the constant, it's that the comment now states why deleting late is free and deleting early breaks a peer — that asymmetry is the actual argument, and it was missing in round 1. Codex measured the window it protects: 5,000 write→rename iterations, max 0.88 ms. Four orders of magnitude of headroom.

CACHE_FIX_CA_TRUST_DIR removed — I grepped code, README, and CHANGELOG: zero occurrences. Publish is now unconditionally <config>/ca-trust.d/ccf.pem against the consumer's <config>/ca-trust.pem. The rendezvous is single-valued again, which is the property the whole contract rests on.

The README caveat is the part I'd single out. README.md:141-154 now says plainly that this is a cooperative same-user convention, not a trust boundary, and that the reader proves "parses, and carries us" — not "contains only approved writers." That limitation was already true in round 1; documenting it is what makes it safe to build on, because the next implementer won't assume more than the guard delivers. Given how easily a "CA bundle validator" invites over-trust, that paragraph is doing real work.

The X509/DER guard survived the refactors intact (CRLF normalization, balanced BEGIN counts, X509Certificate per block, DER match) — we looked directly rather than assuming, since a comment-trimming commit touching a security guard is exactly where a regression hides.

Merged suite: 1499 pass, 0 fail on current main — measured on both sides independently. The new test that reaps an old orphan while sparing a fresh concurrent temp exercises both branches of the reaper in one launch, which is the right shape for a race fix.

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. needs-sim-validation stays until that's closed, and this needs Chris's human review before merge since it's load-bearing (TLS trust path plus a three-component on-disk contract). Codex's approval is necessary, not sufficient.

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

@cnighswonger cnighswonger added approved-by-lead Final implementation approval from project lead ready-for-merge Required reviews are complete and no known blockers remain labels Aug 1, 2026
@vsits-proxy-builder
vsits-proxy-builder Bot merged commit 23346ac into cnighswonger:main Aug 1, 2026
5 checks passed
codeslake added a commit to codeslake/claude-code-cache-fix that referenced this pull request Aug 2, 2026
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>
codeslake added a commit to codeslake/cswap-pin that referenced this pull request Aug 3, 2026
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>
codeslake added a commit to codeslake/cswap-pin that referenced this pull request Aug 3, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved-by-codex-agent Final implementation approval from Codex Agent approved-by-lead Final implementation approval from project lead needs-sim-validation Requires integration testing with live CC traffic ready-for-merge Required reviews are complete and no known blockers remain reviewed-by-codex-agent Directive/spec reviewed by Codex — no blocking findings schema-change Changes affect extension pipeline interface, telemetry format, or config schema

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants