fix(launcher): match the ca-trust guard to what node's CA loader accepts - #296
fix(launcher): match the ca-trust guard to what node's CA loader accepts#296codeslake wants to merge 10 commits into
Conversation
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>
…stderr Two simplifications, both measured before applying. The launcher was line-buffering the proxy's stderr and stripping the wiring recipe with a regex — 20 lines to remove text it had caused. The server can tell directly: process.channel is set exactly when fork() created the process, and the launcher is the only fork() site (the `server` subcommand uses spawn, and a service manager runs it bare). Measured: fork -> channel set, standalone -> undefined. So the recipe is now gated at the source and the relay is a plain pass-through again. And bundleCarriesOurCA no longer normalizes CRLF. `$` in a /m regex matches before a `\r`, and the END search is anchored on the leading `\n`, so both halves already read a CRLF file the same as an LF one. Measured across 102 shapes (34 bundle layouts x LF/CRLF/mixed): identical verdicts with and without the replace, and 0 false accepts against a real handshake. No behavior change: standalone still prints the full recipe, the launcher still prints the mode line and not the recipe, and the guard's verdicts are unchanged. Mutating the new gate to false fails exactly one test, and all five guard-clause mutations are still caught. -16 net production lines. Full suite 1503 pass / 0 fail. Co-Authored-By: Claude <noreply@anthropic.com>
…a.pem A review pass found three false accepts the previous commit still had, and one claim in its own CHANGELOG that was not true. All four reproduced against a real NODE_EXTRA_CA_CERTS handshake before fixing. Two were the same mistake: the guard decided a block was safe without proving it decodes. - Non-CERTIFICATE blocks were skipped outright. "Node ignores non-cert blocks" holds only for WELL-FORMED ones — node's reader aborts the whole extras load on any block it cannot decode, whatever the label. Measured: a corrupt PUBLIC KEY and a corrupt X509 CRL each ahead of a healthy CA gave guard=accept, handshake=UNABLE_TO_VERIFY_LEAF_SIGNATURE. Every block must now decode; what that means differs by label, measured per label: a CERTIFICATE must parse as X509 (base64 validity is not enough — a well-formed base64 body that is not a certificate still kills the load), everything else only needs valid base64 armor. Demanding more would re-reject the CRLs and key blocks a real corporate bundle carries. - The marker was anchored with a bare `$`, so `-----BEGIN CERTIFICATE----- ` (one trailing space) was invisible to the guard while openssl still reacted to it. A corrupt block wearing a trailing space rode through. The third was the END search: `indexOf` scanned to end-of-file, so a torn block with no END of its own could borrow the END line of a later block. The unterminated check never fired and the slice spanned two entries. Now bounded at the next BEGIN. The fourth was a false claim, not a code defect I had introduced — the CHANGELOG said a corrupt ca.pem no longer "fell back to the very file that had just failed to parse". Only the message had been fixed; caForClaude still defaulted to it. Measured: `CA=/tmp/.../ca.pem`, the unparseable file. NODE_EXTRA_CA_CERTS is now left unset in that case, so node falls back to its built-in store — the honest state, since we have no usable CA to add. CHANGELOG and README corrected to match what the code does; the README's "non-certificate blocks are ignored" line was overbroad for the same reason as the second bug above. Coverage: five new rows in the guard table (both corrupt non-cert labels, a well-formed one that must still pass, the trailing-space case, the borrowed-END case) plus a wrapper test asserting CA=UNSET. Each of the four fixes is mutation-verified — reverting it fails exactly one test. Re-measured after: 0 false accepts across 36 handshake-checked shapes, 5 conservative rejects (all damaged-bundle cases, the allowed direction). Full suite 1502 pass / 2 fail, both EMFILE from an fs.watch test that fails identically at the merge base (inotify max_user_instances=128 here). Co-Authored-By: Claude <noreply@anthropic.com>
…ignal A Codex review pass found three more defects. All three reproduced against a real NODE_EXTRA_CA_CERTS handshake before fixing; the two P1s were false accepts of the same class the previous commits were fixing. - Base64 was checked as an ALPHABET, not as whole quanta. Measured: a PUBLIC KEY body of `A` ahead of our CA gave guard=accept while node reported `bad base64 decode` and loaded zero extra CAs. Padding is positional too — `AAA=` and `AA==` load, `A===`, `=AAA` and `AA=A` do not. Now length%4==0 plus trailing-only padding: 16/16 agreement with a real handshake on the body shapes measured. - The label pattern was [A-Z0-9 ], so every other legal PEM label was invisible while openssl still treated the block as real. Measured: a malformed `X-FOO` block gave guard=accept, node loaded zero CAs. Every label tried behaved as a real block (hyphenated, lowercase, underscored, dotted, punctuated, empty), so the label now decides only WHICH check a block gets, never whether it is one. Note `[^-]*` does NOT fix this — `-` is legal inside a label, so the stop condition is the `-----` run. - The banner suppression keyed on `process.channel`, which only proves SOME parent opened an IPC descriptor. Measured: a plain fork() of server.mjs (which this suite itself does, and any supervisor may) got the suppressed banner plus the false claim that a launcher had wired the client — leaving an operator with no wiring instructions at all. Now an explicit CACHE_FIX_WIRED_BY_LAUNCHER the launcher sets. This is an internal handshake between the two files, not an operator knob, and is deliberately undocumented as one. Also fixes the test-suite temp-dir leak reported in the first review and skipped then. Measured: one run of proxy-wrapper.test.mjs left 38 dirs behind, and a /tmp that had accumulated 1954 of them held 432 ca.key / leaf.key files — forward mode mints an RSA CA and leaf per config dir, so the leak is private key material, not empty directories. Registered centrally with one after() hook rather than per-test rmSync, because a failing test throws before its own cleanup and every future test would have to remember. A leak is invisible to assertions (measured: suite still reported 23 pass / 0 fail while leaking 39 dirs), so the guard is a source-level check that nothing bypasses the registrar. Coverage: 164 measured shapes across four sweeps, 0 false accepts. Each of the three fixes plus the registrar is mutation-verified — reverting it fails exactly one test. Full suite 1504 pass / 2 fail, both EMFILE from an fs.watch test that fails identically at the merge base. Co-Authored-By: Claude <noreply@anthropic.com>
A review pass died mid-response, but its last line named the gap: the
guard never validated what followed the END marker. Measured, and it was
two more false accepts.
`indexOf("\n-----END <label>-----")` matches a prefix, so it treated
`-----END CERTIFICATE-----garbage` and `-----END CERTIFICATE-------` as
terminators. Both make openssl reject the block: guard=accept while node
loaded zero extra CAs, on a bundle whose remaining entries were healthy.
Only whitespace may follow — 13/13 agreement with a real handshake on
what a tail may contain (space, tab, nothing: loads; any other character,
including a further dash run: does not). The END search now skips
candidates whose line does not end there, rather than taking the first
textual match.
Three rows added, including the positive one: a trailing space must keep
being ACCEPTED, or the fix trades two false accepts for a false reject.
Mutation-verified — reverting to the bare indexOf fails exactly one test.
Re-measured across all four sweeps at 164 shapes: 0 false accepts, no
regression in either direction.
Co-Authored-By: Claude <noreply@anthropic.com>
Three cuts, no behaviour change, plus one coverage hole they exposed. isBase64Body took (block, endMarker) and re-derived the body by slicing between the first newline and the last END marker — arithmetic the caller had already done to build the block. It now takes the body itself, which the caller has in hand as text.slice(m.index + m[0].length, end). Verified equivalent under both LF and CRLF before applying: the BEGIN match excludes the \r, so the two slices normalize to the same bytes. The two `if (remoteControl)` lines merged into one block, and a comment restating the line below it dropped. The hole: mutating away the `length % 4` check left the suite GREEN. No fixture had an alphabet-valid body of the wrong length, so a clause my previous commit message claimed was covered was not. Two rows added — a one-character body and `A===` — and both base64 clauses now fail exactly one test when removed. 175 measured shapes across six sweeps, 0 false accepts, identical to before the cuts. Full suite 1504 pass / 2 fail (EMFILE, same at the merge base). net: -8 lines. Co-Authored-By: Claude <noreply@anthropic.com>
…ability The paragraph said a reader "has no previous state to compare against", which reads as a limitation — and a limitation is an invitation. Someone adds the previous bundle as state, believes they have lifted it, and adds a cert-count floor. The floor would still be wrong. A shrink is legitimate whenever a root is retired or a component is uninstalled, and only the builder knows which happened, so a reader holding BOTH bundles still cannot tell a regression from a fact. Measured across two machines here: a legitimate bundle is 5 certs on one and 168 on the other, so any floor that catches narrowing on one host rejects a healthy bundle on the next. Surfaced by a peer session that had the mirror-image wording in its own comment and changed it after the same argument. Co-Authored-By: Claude <noreply@anthropic.com>
…an unparseable CA Two false-accept paths found by Codex review, both reproduced here before being agreed with. STRIP ASCII WHITESPACE ONLY. isBase64Body stripped with /\s+/, which is the Unicode whitespace set. Node's PEM reader accepts space, tab, CR and LF and nothing else. Measured one character at a time against a real NODE_EXTRA_CA_CERTS load: those four load 1, while U+00A0 U+2003 U+2028 U+2029 U+FEFF U+1680 U+205F U+3000 and ASCII VTAB and FORMFEED each load 0 with `bad base64 decode`. All ten are stripped by \s, so a body damaged by any of them read as clean and the guard accepted a bundle that costs the session every extra root. A NBSP is what a paste through a rich-text field leaves behind. PARSE BEFORE PUBLISHING. The copy into ca-trust.d/ccf.pem happened before the X509 parse, so a corrupt ca.pem was handed to every OTHER component. Our own session degrades fine (it falls back to node's built-in store), but the builder concatenates sort(*.pem) and "ccf" sorts first — the same fatal leading position the torn-write guard already protects, reached by a different cause. Atomicity guarantees whole bytes, never loadable ones. Now the parse throws into the existing catch, which warns and leaves any previous good ccf.pem for siblings to keep trusting. Both TDD: each test fails on the pre-fix code and passes after. Both mutation-checked: reverting [ \t\r\n] to \s fails proxy-forward-ca, removing the pre-publish parse fails proxy-wrapper. Suite 1505/1507. The 2 failures are the inotify EMFILE (max_user_instances=128 on this host) and fail identically at the merge base. Four of the six review findings were against upstream code outside this PR's diff — session-budget-breaker and tier-advisor — and are not touched here. Co-Authored-By: Claude <noreply@anthropic.com>
Codex review against the real merge base, P1 and the only finding. The marker pattern described a WELL-FORMED opener, so an over-dashed one (`-----BEGIN CERTIFICATE-------`) matched nothing at all and the block became invisible to the guard: nothing was checked, and our CA later in the file carried the verdict. openssl does not skip it — it consumes the line as an opener and then fails the ENTIRE extras load on the END it cannot match. Measured, node v24.11.1: guard=accept, loader=0 CAs, `bad end line`. A trailing `.*` makes the line match, which is all the fix needs: the block is then seen and the existing per-block check rejects it as an undecodable CERTIFICATE. Being SEEN is what a guard needs; skipping is what lets a bad block through. This is the same defect already fixed on the END side, in its mirror position. The lesson: a shape fixed at one marker is a shape to go and check at the other. Two rows: the malformed opener rejects, and a BEGIN wearing one trailing space still ACCEPTS, so the fix cannot drift into the over-strict guard this PR set out to remove. Mutation-checked: reverting to the strict pattern fails the new row. An earlier attempt added a separate pre-scan loop and an `undefined` label branch. The branch was dead — `(?!-----)` still captures `CERTIFICATE` from an over-dashed line — and the mutation SURVIVED, which is what exposed it. Removed rather than kept as defence for a case that cannot happen. Suite 1505/1507, the 2 being the inotify EMFILE that fails identically at the merge base. Co-Authored-By: Claude <noreply@anthropic.com>
5be2f01 to
8ed796a
Compare
No "cnighswonger#296": GitHub reads a #N in any pushed commit message as an issue reference and posts it to that PR timeline. This branch is our deploy artifact and is rebuilt on every upstream move, so each rebuild was appending a "referenced" line to a maintainer PR that has nothing to do with it — 10 of them on 2026-08-01 alone. Plain "PR 296" says the same thing to a human and links nothing.
|
Codex review round, applied. Head is now Three false accepts, each reproduced before being agreed with1. A malformed The marker pattern described a well-formed opener, so an over-dashed one matched nothing and the block became invisible to the guard: nothing was checked, and our CA later in the file carried the verdict. openssl does not skip it — it consumes the line as an opener and then fails the entire extras load on the A trailing This is the same defect already fixed on the 2. Unicode whitespace was stripped where node accepts only ASCII —
All ten are stripped by 3. A corrupt The copy into MethodEach fix is TDD: the row fails on the pre-fix code and passes after. Each is mutation-checked — remove the guard, watch a test die, restore:
Every reject-direction row is paired with an accept-direction one (a One mutation survived and that is worth reporting: an earlier attempt at finding 1 added a separate pre-scan loop plus an VerificationSuite Unrelated, unverified — four leads in already-merged codeAn earlier review run resolved its base to a stale
Worth a separate issue if they hold up; the session-budget-breaker one looked load-bearing. Still out of scope, deliberatelyUnchanged from the PR description: the — codeslake (CCF contributor) Correction: my earlier comments on this PR were signed "— Proxy Builder". That is this repo's own review agent, not me. Copied from my fork's role naming by mistake; a contributor signing as the reviewer inverts the audit trail. Fixed on all of them. 🤖 Generated with Claude Code |
ponytail-review over the production diff. bin/ca-trust.mjs was 34 lines of code under 121 lines of comment (3.6:1), most of it retelling how each false accept was discovered — six incidents at roughly six lines each. Every constraint survives, in the form that stops someone tightening it back: what the clause defends and that it was measured. What went is the narrative of finding it, which git log already holds verbatim and in more detail than a source comment can carry. Same pass on proxy/server.mjs: the process.channel history is dead (nothing reads it now), so it keeps only the live reason the env var is the signal. bin/ca-trust.mjs 157 -> 111 lines, comments 121 -> 75, code unchanged at 34 proxy/server.mjs -2 Re-verified after cutting, since a comment pass can still break code: suite 1505/1507 (the 2 being the inotify EMFILE that fails identically at the merge base), and all three guards still mutation-lethal — reverting the whitespace class, dropping the trailing `.*`, and dropping the next-BEGIN bound each fail proxy-forward-ca. CC_WRAPPER_SKIP_TESTS=1: the cross-component suite fails on a LIVE check, "every published component CA is in the bundle — missing: cswap-pin.pem". Measured it is not ours rather than assuming: the same check fails identically (passed=12 failed=1) with these edits stashed, and cswap-pin.pem was published 21:09 against a bundle last built 20:41 — a rebuild the builder has not run yet, on another session's component. Co-Authored-By: Claude <noreply@anthropic.com>
|
Correcting two things in my previous comment. The The builder globs and sorts; the sibling component knows the directory and nothing else. The contract is "each component drops one file naming itself" — the name is the publisher's to choose. So this is a one-line change in Deferring it anyway, now on the real grounds rather than an implied constraint:
Happy to take it as a follow-up if you would rather have it closed. "Unchanged from the PR description" was wrong — the description has no out-of-scope section, so there was nothing to be unchanged from. Both items were carried in my working notes, not in this PR's text. Stating them here is the first time they appear. The other deferral stands as written: a CA rotated mid-tick cannot match a bundle read in the same tick. Nothing in the code or the verification changes; this is a correction to the framing only. — codeslake (CCF contributor) Correction: my earlier comments on this PR were signed "— Proxy Builder". That is this repo's own review agent, not me. Copied from my fork's role naming by mistake; a contributor signing as the reviewer inverts the audit trail. Fixed on all of them. 🤖 Generated with Claude Code |
|
Self-review pass against this repo's own review history, before asking anyone else to spend time on it. I went back through the comments on #246, #251 and #283 and ran each recurring finding as a check against this branch. One hit, and it is the same shape as a #283 blocker — "the PR body states X; as written it isn't". The description has claimed a clean sweep twice and been wrong twice. The body is edited; flagging it here because nobody re-reads a description. The first version said "0 false accepts across 36 shapes" — a review found three, and I corrected it inline. But the correction's own re-measurement has since been falsified by seven more, across three further rounds:
The shape count was also simply wrong: the body said 36, the table has 26 rows. So the description no longer makes a count claim. It now says what the table can actually support — 26 rows, 9 accept / 17 reject, every accept row cross-checked against a real The other recurring findings, checked rather than assumed:
One thing I got wrong while checking, worth recording since it is a method note: I first flagged Expecting, from #283's precedent: this is the same TLS trust path, so — codeslake (CCF contributor) Correction: my earlier comments on this PR were signed "— Proxy Builder". That is this repo's own review agent, not me. Copied from my fork's role naming by mistake; a contributor signing as the reviewer inverts the audit trail. Fixed on all of them. 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Review: PR #296
Date: 2026-08-03
Reviewed: PR head 306090cebb663632e70d134ca79a759b8b781186 merged onto current origin/main
Round: 1
Label applied: changes-requested
What Is Correct
- Measured: the two defects called out in the dispatch note are fixed at this head. A relabelled
TRUSTED CERTIFICATEblock now yieldsbundleCarriesOurCA() -> { ok:false, reason:"bundle does not carry our CA" }and a fresh child process withNODE_EXTRA_CA_CERTS=<bundle>failsUNABLE_TO_VERIFY_LEAF_SIGNATURE; a validX509 CRLblock ahead of our CA now yieldsbundleCarriesOurCA() -> { ok:true }and the same handshake authorizes. - Measured: the
#283round-1 blockers called out in the prompt are not reintroduced in the merged head.node --test test/proxy-forward-ca.test.mjs test/proxy-wrapper.test.mjspassed39/39, including the publish-dir override, the orphan-temp reaper on publish failure, the no-bundle fallback, theCLAUDE_CONFIG_DIRpath contract, and the banner-suppression cases. - Measured: the full merged-head suite passed
1507/1507vianpm test. - Measured: the new
bin/ca-trust.mjsextraction is justified and is not bloat. On the original merged23346ac9, I mutated the inline launcher guard inbin/claude-via-proxy.mjsto accept the merged bundle unconditionally;node --test test/proxy-forward-ca.test.mjsstill passed12/12. That proves the pre-PR test file was exercising its hand-copied twin, not the shipped launcher code. - Read + Measured: the
proxy/server.mjschange belongs in this PR. The launcher now setsCACHE_FIX_WIRED_BY_LAUNCHERat bin/claude-via-proxy.mjs, and the server consumes it at proxy/server.mjs to suppress the standaloneNODE_EXTRA_CA_CERTS=<our ca.pem>recipe only when the launcher already wired claude throughca-trust.d. The paired tests--remote-control does not print the wiring banner...anda plain fork of the server still gets the wiring recipeboth passed.
Blockers
- Measured + Read: two more guard/loader disagreements remain in
bin/ca-trust.mjs, so the trust-path claim is still not true. First, a malformed overlapping opener ahead of our CA is still a false accept:
-----BEGIN PUBLIC KEY----------BEGIN CERTIFICATE-----\nAAAA\n-----END PUBLIC KEY-----\n<our CA>
bundleCarriesOurCA()returns{ ok:true }, but a fresh child process withNODE_EXTRA_CA_CERTS=<bundle>failsUNABLE_TO_VERIFY_LEAF_SIGNATUREand warnsPEM routines::bad end line. The skip happens because the guard only reasons over blocks matched by bin/ca-trust.mjs; this malformed opener is not rejected, it is ignored, and our later CA carries the verdict. Any accept path must be measured-loadable, so this remains blocking. - Measured + Read: the same function still has a false reject in the other direction. With our CA first, followed by a non-certificate block whose body contains a line-start
-----BEGINmarker,bundleCarriesOurCA()returns{ ok:false, reason:"unterminated PUBLIC KEY block" }, while a freshNODE_EXTRA_CA_CERTShandshake authorizes. The culprit is the unconditionalnextBegin = text.indexOf("\n-----BEGIN ", ...)/end > nextBegin => end = -1logic at bin/ca-trust.mjs and bin/ca-trust.mjs: it treats a line-start marker-looking payload line inside a non-certificate block as the start of a new PEM entry. Refusing is not the safe direction here; it drops every sibling CA for the session.
What Needs Attention
- Measured: the contributor's "floor, not ceiling" framing was the right one. I did find additional shapes beyond the table. At minimum the suite needs rows for the two shapes above before this can be called fixed.
- Read: no schema surface was added. I do not see a
schema-changelabel case here.
Bloat / Non-Functional
- None. Production surface is proportionate to the defect: 3 production files changed (
bin/ca-trust.mjs,bin/claude-via-proxy.mjs,proxy/server.mjs), 1 new production file, 0 new env vars, 0 new on-disk paths. The new module is justified by the measured test-gap above rather than by speculative reuse.
Recommendations
- Add the two new measured shapes as regression rows in
test/proxy-forward-ca.test.mjs, using the sameNODE_EXTRA_CA_CERTSchild-process oracle the PR already adopted. - Tighten the BEGIN-line handling so a malformed opener is rejected rather than skipped, and so a line-start
-----BEGINinside a non-certificate payload does not automatically terminate the surrounding block. - After that, re-run the existing full suite and a real
--remote-control/ forward-proxy session against live Claude traffic before merge; this path is load-bearing even though the local handshake harness is now much better than before.
Bottom Line
Revise. The PR fixes the two defects called out in the dispatch and correctly closes the old test-gap, but the central trust-path claim is still not true: I measured one remaining false accept and one remaining false reject in bundleCarriesOurCA(). Because this code decides whether to hand Claude a merged trust bundle on a live TLS path, those remaining disagreements are merge-blocking.
— Codex review
|
Both blockers reproduce, and fixing them the way the review suggests would have been round 6 of a losing argument. Changed approach instead — see #300, which names this exact pattern and parks the design question with @cnighswonger and me. Both blockers confirmedReproduced before agreeing, fixtures built in Python so the byte sequences survive the shell:
False accept and false reject, exactly as reported. Why the recommended fix is not the one I madeThe review recommends tightening the BEGIN-line handling. That is the right fix for this predicate and it is the fifth time we have made it — the round count is in #300: five rounds on one function, three parties, each finding shapes the last missed. The rule the predicate is reaching for turns out not to be expressible from outside. An identical tear is recovered or fatal depending only on whether its truncated body happens to be complete DER, which is a question about bytes no parser can answer: So Deliberately a handshake and not Cost, 100 interleaved runs: bare spawn median 16.4 ms, oracle 20.4 ms — 4.0 ms, once per launcher start, on a path that already forks node at Both blocker shapes, and the whole 26-row table, now agree with the loaderThe shape table survives as a regression table; the oracle just answers it correctly by construction. Running it found one pre-existing row that was wrong: Three outcomes, never two
A refused merge no longer costs the other publishers their CAsPreviously a refusal meant "use our own CA alone", which drops every other component's CA for the session. The damage lives in the merge, not in the files that fed it, so the launcher now rebuilds from the What the review asked for that I did not do"Add the two new shapes as regression rows." Done, plus rows for a context-dependent tear and a "Re-run a real One defect the 43 green tests did not catchWorth reporting because it is the reason I now distrust a green suite as evidence. Every test built the CA with Coverage of a function is not coverage of its caller. Found by running the real entry point, fixed test-first. VerificationThree node majors, which matters more for an oracle than for a predicate: a predicate imitates one openssl's behaviour and needs re-verifying per version; the oracle asks whichever loader is installed. Not pushed yet — an internal review round is still open on this diff and I would rather not update the head twice. — codeslake (CCF contributor) |
Follow-up to #283. Same block, six defects the merged version shipped with — the guard it added disagrees with Node's own CA loader in both directions.
All measurements are on node v24.11.1 / openssl 3.6.1, against a real TLS handshake through
NODE_EXTRA_CA_CERTS— nottls.connect({ca}), which behaves differently (see below).The guard vs. a real handshake
False rejects — a healthy bundle refused. Refusing is not the safe direction: the fallback drops every sibling and corporate CA for the whole session, which is the failure this contract exists to prevent, while printing
ignoring <ca-trust.pem> (torn block)and blaming the builder for a file the runtime loads happily.# see -----BEGIN CERTIFICATE-----then our CA-----BEGIN, then our CAFalse accepts — a bundle node cannot load, waved through. The dangerous direction: claude then distrusts the very proxy it is routed through and every request fails TLS.
8f46b89TRUSTED CERTIFICATEPUBLIC KEYahead of our CAX509 CRLahead of our CABEGINline has a trailing spaceThe relabelled case is the subtlest:
X509Certificateignores the PEM label and decodes the body, so our CA relabelledTRUSTED CERTIFICATEyields byte-identical DER and the guard says "carries our CA" — while node's loader skips any block not labelled exactlyCERTIFICATE.The other three are one mistake in three places: deciding a block is safe without proving it decodes.
CERTIFICATEmust parse as X509 (base64 validity is not enough — a well-formed base64 body that is not a certificate still kills the load), everything else needs only valid base64 armor. Demanding more would re-reject the CRLs and key blocks a real corporate bundle legitimately carries.$-anchored, so-----BEGIN CERTIFICATE-----(one trailing space) was invisible to the guard while openssl still reacted to it.ENDsearch ran to end-of-file, so a torn block could borrow the terminator of a later one; the unterminated check never fired and the slice spanned two entries. Now bounded at the nextBEGIN.Current shape table: 26 rows, 9 accept / 17 reject, every accept row cross-checked against a real
NODE_EXTRA_CA_CERTShandshake. Read that as coverage, not as proof of absence — see the correction above. The reject direction is the one the guard is allowed to take: where it 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. Refusing costs one session's sibling CAs; accepting costs the session entirely.Why the tests did not catch any of this
Two independent gaps, both measured:
The guard was hand-duplicated.
bundleIsUsablein the test file was a copy of the launcher's inline decision under a "change one, change both" comment. Mutating the launcher's copy to accept everything left the whole suite green. Moved tobin/ca-trust.mjs, which the launcher imports and the test imports. Two call sites is below this repo's bar for a new module; the justification is not reuse, it is that a test cannot import a top-level script and a copy is not the thing that ships.The oracle was the wrong mechanism. The table cross-checked against
tls.connect({ca: ...}). That option accepts the relabelled bundle thatNODE_EXTRA_CA_CERTSrejects — so the test was certifying the guard against a code path the launcher does not use. The helper now spawns a child with the variable set from birth (node reads it once at startup, so setting it in-process after boot tests nothing).Three more in the same block
The orphan reaper shared the publish
try, sorenameSyncthrowing skipped it. On exactly the hosts where publishing is persistently broken (a root-ownedccf.pem, a read-only mount,ENOSPC) each launch abandoned one full-CA temp and collected none — unbounded growth in the directory a builder globs. Measured with a directory at the publish target: both the new temp and a pre-seeded 2-hour-old orphan survived.A corrupt
ca.pemwas blamed on the bundle, and then handed to claude anyway. The X509 parse sat inside the bundletry, so an unparseableca.pemprintedignoring <ca-trust.pem> (no start line)— naming a file that may be perfectly healthy. It is now parsed in its own step and named in its own message, andNODE_EXTRA_CA_CERTSis left unset rather than pointed at the file that just failed to parse (measured before the fix:CA=/tmp/.../ca.pem, the unparseable one; after:CA=UNSET). Node falls back to its built-in store, which is the honest state — we have no usable CA to add. Reachable because the proxy's reuse guard keys onexistsSync(ca.pem) && existsSync(ca.key), so a corrupt pem with its key beside it is reused, not regenerated.The proxy's
export NODE_EXTRA_CA_CERTS=<our ca.pem>recipe reached the operator immediately after the launcher had wired claude viaca-trust.d— telling them to undo it. The server now prints the recipe only when the operator is the one wiring:process.channelis set exactly when our launcherfork()ed it, and the launcher is the onlyfork()site (theserversubcommand usesspawn; a service manager runs it bare). The mode line prints either way. Standalone, the recipe carries a same-host-MITM caveat, as do the README's manual-wiring recipes.Coverage
Every clause is mutation-verified — each of these was caught by exactly one test:
carriesUsalways trueCERTIFICATEcheck → skipENDsearch unbounded againtrytryca.pemhanded to claude againprocess.channelgate forced falseSize
+67production code,+126comment,+233test (3.5x), 1 new file, 0 new env vars, 0 new on-disk paths. Comparable to the calibration rows inAGENTS.md(#261: 23 code, 8.4x). Under 300 production LOC, so no## Non-Functional Requirementssection — but load-bearing: yes (TLS trust path), so this wants human review before merge.Test run
1507 pass / 0 failon the full suite;1499 / 0at the merge base (23346ac). The 8 added tests are this PR's.🤖 Generated with Claude Code