anthropics / anthropics/claude-code

[BUG] NODE_OPTIONS=--use-system-ca replaces instead of augments the bundled CA store — confirmed regression #72066 was auto-closed as not_planned

Aperta
#91,426 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
area:networking bug has repro platform:macos regression
Lingua principale
Python
Stelle
145k
Fork
23.1k
Metriche di merge delle PR
Metriche PR in attesa

Descrizione

### Preflight Checklist

- [x] I have searched [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug) and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code

### What's Wrong?

`NODE_OPTIONS=--use-system-ca` makes Claude Code discard its bundled public root certificates
and trust **only** the OS certificate store. On a managed macOS machine the OS store as read
here contains just the admin/user-added organization certificates and no public roots, so every
endpoint anchored on a public root fails TLS verification and Claude Code cannot start a
conversation.

Claude Code's own debug log shows the whole thing in two lines. Without the flag:

```
CA certs: stores=bundled,system, extraCertsPath=undefined
CA certs: Loaded 121 bundled root certificates
CA certs: Loaded 9 system CA certificates
```

With `NODE_OPTIONS=--use-system-ca`:

```
CA certs: stores=system, extraCertsPath=undefined
CA certs: Loaded 9 system CA certificates
```

**121 bundled roots are dropped**, and the 9 that remain are organization certificates that
cannot anchor any public chain.

This is the regression already confirmed and reproduced by a maintainer in **#72066**, which the
lifecycle sweep closed as `not_planned` about nine hours *before* that confirmation was posted.
The issue was never reopened, no other open issue tracks it, and the behaviour is unchanged in
2.1.252.

I am filing a new issue only because #72066 is closed and its closing comment instructs
reporters to do so. **Please reopen or relink #72066 rather than re-triaging this from scratch**
— the diagnosis there is already correct. Timeline:

| When (UTC) | Event |
|---|---|
| 2026-06-28 | Reported |
| 2026-08-01 | Labeled `stale` by the sweep |
| 2026-08-16 10:10 | Auto-closed as `not_planned` (14 days after the `stale` label) |
| 2026-08-16 18:58 | Maintainer confirms and reproduces on 2.1.233, dates the regression to ~2.1.70, states *"We'll fix Claude Code to treat the flag additively, matching Node."*, and adds the `regression` and `reproduced` labels — but does not reopen |
| 2026-08-16 18:59 | Sweep removes the `stale` label |
| 2026-09-01 | Still reproduces on 2.1.252 |

So the labels read `bug` / `reproduced` / `regression` / `has repro` while the state is
`closed / not_planned`, which keeps it out of `is:open` searches.

**Root cause, unchanged in 2.1.252.** Strings from the shipped binary (identifiers are minified;
comments mine):

```js
// cert store resolution
if (a$e("--use-system-ca") || a$e("--use-openssl-ca")) return ["system"];
return DEFAULT; // DEFAULT = ["bundled", "system"]

// NODE_OPTIONS is parsed as a plain string and token-matched
function a$e(e) {
let n = process.env.NODE_OPTIONS;
if (!n) return !1;
return n.split(/\s+/).includes(e);
}
```

`return ["system"]` builds a **new** array rather than appending to the default, so `"bundled"`
is discarded. I confirmed the identical branch in 2.1.232, 2.1.233, 2.1.238, 2.1.247 and
2.1.252, all with the same `["bundled","system"]` default.

**Bisected: the regression landed in 2.1.101, and Claude Code used to do this correctly.** I
pulled the `darwin-arm64` release binaries and compared the extracted strings (no execution):

| Version | How `--use-system-ca` is handled | Result |
|---|---|---|
| 2.1.69 | referenced only in startup telemetry (`has_use_system_ca`); no certificate logic at all | flag inert → **works** |
| 2.1.70 – 2.1.100 | flag is honoured, and the bundled roots are loaded **as the base** before anything is appended | **additive** → **works** |
| 2.1.101 and later | `CLAUDE_CODE_CERT_STORE` is introduced and the branch becomes `return ["system"]` | **replacement** → **breaks** |

The 2.1.100 binary still contains the original additive intent verbatim:

```js
K.push(...q.rootCertificates);
L(`CA certs: Loaded ${K.length} bundled root certificates as base`);
// ^^^^^^^ as base, then append
```

2.1.101 is the first binary containing the replacing branch, alongside the first appearance of
`CLAUDE_CODE_CERT_STORE` (0 occurrences in 2.1.100, 10 in 2.1.101):

```js
if (v2H("--use-system-ca") || v2H("--use-openssl-ca")) return ["system"];
```

So **the last working version is 2.1.100**, and the regression shipped inside the 2.1.101
changelog entry *"Added OS CA certificate store trust by default, so enterprise TLS proxies work
without extra setup"*. The refactor that introduced the store list dropped the "bundled roots as
base" behaviour that the previous 31 releases had.

Small correction to the assessment in #72066, which dates the regression to ~2.1.70: 2.1.70 is
where the flag *started* being honoured, and it was honoured **additively**. Nothing regressed
there.

This also means the requested fix is a restoration rather than a new behaviour: Claude Code's own
implementation was additive from 2.1.70 through 2.1.100.

**This is not documented behaviour.** On the page linked from #72066
(https://code.claude.com/docs/en/network-config#ca-certificate-store) the strings
`use-system-ca`, `use-openssl-ca` and `NODE_OPTIONS` do not appear at all — zero occurrences
each. What the page does say is the opposite: *"By default, Claude Code trusts **both** its
bundled Mozilla CA certificates **and** your operating system's certificate store"*, and
*"`CLAUDE_CODE_CERT_STORE` … The default is `bundled,system`."* So an environment variable that
silently narrows that to one source is undocumented, and cannot be read as intended policy.

I want to address one likely response up front: #72066 notes that *"you shouldn't need
`--use-system-ca` for Claude Code at all"*, which is true — Claude Code has read the OS store by
default since 2.1.101. But nobody sets this variable *for* Claude Code. It is set for `npm` /
`pnpm`, and `NODE_OPTIONS` is process-global, so Claude Code picks it up from the same shell.
"You don't need it" therefore doesn't help: the user cannot scope it away without knowing that
Claude Code reads `NODE_OPTIONS` at all, which is not documented anywhere. That is what makes
this a bug rather than a configuration mistake.

**Why it is easy to hit.** `NODE_OPTIONS=--use-system-ca` is a reasonable thing to have
exported — it is a documented way to make `npm` / `pnpm` trust an internally-issued
certificate. In my case the line had been in `~/.zshrc` for three days for exactly that reason,
from unrelated work. The user never asks Claude Code for anything: it opts itself into another
runtime's environment variable and then interprets it more aggressively than that runtime does.

### What Should Happen?

`--use-system-ca` should be **additive**, matching Node.js: the OS certificate store on top of
the bundled roots, not instead of them. The resolved store should stay `bundled,system` — i.e.
identical to the default — so the flag becomes a no-op for Claude Code rather than a
connectivity failure.

For reference, this is what Node.js v24.15.0 actually does on the same machine.
`default` is the set actually used for verification:

| Invocation | certs used | bundled certs missing from the used set | Semantics |
|---|---|---|---|
| `node` (no flag) | 145 | 0 | — |
| `node --use-system-ca` | **156** | **0** | **additive** |
| `node --use-openssl-ca` | **0** | **145** | **replacement** |
| `claude` with `NODE_OPTIONS=--use-system-ca` | 9 | all 121 | **replacement** |

Measured with:

```console
$ node --use-system-ca -e 'const t=require("tls");
const d=new Set(t.getCACertificates("default")), b=new Set(t.getCACertificates("bundled"));
let m=0; for (const c of b) if (!d.has(c)) m++;
console.log(`default=${d.size} bundled=${b.size} bundled_missing=${m}`)'
default=156 bundled=145 bundled_missing=0
```

Node treats the two flags **differently**. Claude Code collapses them into one branch and makes
both replacing.

Node docs for the flag: https://nodejs.org/api/cli.html#--use-system-ca
Claude Code docs for the store: https://code.claude.com/docs/en/network-config#ca-certificate-store

### Error Messages/Logs

```shell
$ NODE_OPTIONS=--use-system-ca claude -p 'Reply with just OK'
API Error: Unable to connect to API: SSL certificate verification failed.
Check your proxy or corporate SSL certificates

# --debug-file, with the flag set
[DEBUG] CA certs: stores=system, extraCertsPath=undefined
[DEBUG] CA certs: Loaded 9 system CA certificates
[ERROR] Connection error details: code=UNABLE_TO_GET_ISSUER_CERT_LOCALLY (SSL error), message=unable to get local issuer certificate

# --debug-file, same machine, flag unset
[DEBUG] CA certs: stores=bundled,system, extraCertsPath=undefined
[DEBUG] CA certs: Loaded 121 bundled root certificates
[DEBUG] CA certs: Loaded 9 system CA certificates
```

### Steps to Reproduce

Requires a macOS machine whose OS certificate store contains only admin/user-added
certificates and no public roots — i.e. any MDM-managed corporate Mac. No TLS-inspecting proxy
is needed; I verified with `openssl s_client` and `curl` that the failing path presents the
genuine upstream certificate with `Verify return code: 0 (ok)`.

Note: `--debug -p` does not emit the cert-store lines. Use `--debug-file` to capture them.

1. Confirm Claude Code works normally:

```console
$ claude -p 'Reply with just OK'
OK
```

2. Set the Node flag exactly as one would for `npm` / `pnpm`, and re-run:

```console
$ NODE_OPTIONS=--use-system-ca claude -p 'Reply with just OK'
API Error: Unable to connect to API: SSL certificate verification failed.
Check your proxy or corporate SSL certificates
```

3. Capture the resolved store both ways and compare:

```console
$ NODE_OPTIONS=--use-system-ca claude --debug-file /tmp/with.log -p 'hi' >/dev/null 2>&1
$ claude --debug-file /tmp/without.log -p 'hi' >/dev/null 2>&1
$ grep 'CA certs: \(stores\|Loaded\)' /tmp/with.log /tmp/without.log
/tmp/with.log: CA certs: stores=system, extraCertsPath=undefined
/tmp/with.log: CA certs: Loaded 9 system CA certificates
/tmp/without.log: CA certs: stores=bundled,system, extraCertsPath=undefined
/tmp/without.log: CA certs: Loaded 121 bundled root certificates
/tmp/without.log: CA certs: Loaded 9 system CA certificates
```

The 121 bundled roots are gone in the first case.

4. Confirm the bundled store alone works, i.e. the machine and network are fine — only the
store selection is wrong:

```console
$ NODE_OPTIONS=--use-system-ca CLAUDE_CODE_CERT_STORE=bundled claude -p 'Reply with just OK'
OK
```

Note for anyone arriving from a search engine: removing the line from `~/.zshrc` does **not**
fix an already-running shell — the variable is still exported in that process. Open a new
terminal or `unset NODE_OPTIONS`.

### Claude Model

Not sure / Multiple models

### Is this a regression?

Yes, this worked in a previous version

### Last Working Version

2.1.100

### Claude Code Version

2.1.252 (Claude Code)

### Platform

AWS Bedrock

### Operating System

macOS

### Terminal/Shell

Terminal.app (macOS)

### Additional Information

### Environment

- macOS 26.4.1 (build 25E253), arm64
- Node.js v24.15.0 — used only as the reference implementation in the table above. Claude Code
itself is a compiled binary and does not run on Node, which is part of what makes this
surprising: `NODE_OPTIONS` has no legitimate effect on this process.
- MDM-managed Mac. The system keychain holds organization certificates only.
- No TLS-inspecting proxy on the failing path.

The failure is not specific to a model provider. Anthropic-owned endpoints fail in the same run:

```
Failed to fetch version from https://downloads.claude.ai/... : unable to get local issuer certificate
```

### Two adjacent defects in the same line

Both live in the same `if (a$e("--use-system-ca") || a$e("--use-openssl-ca")) return ["system"]`
line, so I have kept them here rather than opening more issues. **Happy to split either into
its own report if you prefer.**

**(a) `--use-openssl-ca` is aliased to the wrong store.** In Node, `--use-openssl-ca` selects
**OpenSSL's** default certificate store (`SSL_CERT_FILE` / `SSL_CERT_DIR`) — a different
location from the macOS keychains. On this machine it resolves to an empty set (0 certificates,
see the table above). Mapping it to `"system"` silently redirects the user to macOS keychains
they did not ask for. Even after `--use-system-ca` is made additive, this would remain
incorrect. Suggest honouring `SSL_CERT_FILE` / `SSL_CERT_DIR`, or ignoring the flag with a
warning, rather than aliasing it.

**(b) The degradation is silent, and the error message misdirects.** The cert-store code already
warns on bad explicit configuration — the binary contains:

```
certs: unrecognized CLAUDE_CODE_CERT_STORE source '${s}', ignoring
```

But dropping 121 bundled roots because of a `NODE_OPTIONS` token produces no warning at all.
The only user-visible output is:

```
API Error: Unable to connect to API: SSL certificate verification failed.
Check your proxy or corporate SSL certificates
```

That points the user at their proxy and their organization's certificates — i.e. at their IT
department — when the actual cause is that Claude Code discarded its own public root bundle. On
a managed corporate Mac this is an expensive false lead: it cost me most of a day auditing
MDM-deployed certificates, TLS-inspection settings, MDM policy-change timestamps, and older
Claude Code versions before I found the one line in `~/.zshrc`.

Two low-cost mitigations, independent of the additive fix:

1. Warn whenever `NODE_OPTIONS` causes the resolved cert store to differ from the default,
naming the flag and the resulting store list.
2. Include the resolved store list in TLS verification errors, e.g.
`... (CA stores: system; 9 certificates loaded, 121 bundled roots not used)`. The debug log
already has this; surfacing it in the error itself would have ended this investigation
immediately.

### Workarounds (confirmed on 2.1.252)

```bash
CLAUDE_CODE_CERT_STORE=bundled,system claude # keeps organization certs working too
CLAUDE_CODE_CERT_STORE=bundled claude
```

Or scope the Node flag so it never reaches Claude Code, per the workaround in #72066:

```bash
alias node='NODE_OPTIONS=--use-system-ca node'
```

For the original `npm` / `pnpm` need, `NODE_EXTRA_CA_CERTS=/path/to/internal-ca.pem` is a better
fit than `NODE_OPTIONS=--use-system-ca`: it adds the one certificate that is actually required
and has no effect on which store Claude Code selects.

Guida per i contributori

Nessuna guida per i contributori indicizzata per questo repository

Direzione di ricerca

Start by reproducing the entry point with NODE_OPTIONS=--use-system-ca and compare the --debug-file CA-cert lines with and without the flag; the report identifies the compiled binary's certificate-store resolution branch but names no source file or test. Trace the implementation of CLAUDE_CODE_CERT_STORE and the NODE_OPTIONS handling, then verify that the flag preserves bundled,system roots and the reproduction succeeds.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
node.js
Ambito
cli, networking, security
Tipo di issue
Bug
Difficoltà
4/5
Tempo stimato
3-5 giorni
Stato di attività
Attiva
Chiarezza
Abbastanza chiara
Idoneità per principianti
45/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.