WSL · Corporate proxy · TLS
Diagnosing and fixing TLS-interception (MITM proxy) breakage in WSL. Written after fixing exactly
this on an Ubuntu 26.04 WSL2 distro, where in turn opencode, a remote MCP server, and
the Claude Code CLI all failed with Error: self signed certificate in certificate chain
— one root cause, three symptoms.
All corporate identifiers here are redacted: <CORP-ROOT-CA> is the self-signed
corporate root, proxy.<corp-domain> the proxy’s intermediate. Public SaaS hostnames
are kept as-is — they are needed to explain the selective-interception behaviour and identify no one.
The network runs a TLS-intercepting proxy. Every intercepted connection is re-signed on the fly:
leaf CN=<the site you asked for>
issued by CN=proxy.<corp-domain> (intermediate)
issued by CN=<CORP-ROOT-CA> (self-signed root, not publicly trusted)
Because <CORP-ROOT-CA> is not in any trust store, every TLS client reports
“self signed certificate in certificate chain” (OpenSSL verify code 19,
Node SELF_SIGNED_CERT_IN_CHAIN).
Interception is selective. Only some hosts are rewritten; others pass through with their genuine public chains.
| Intercepted | Untouched |
|---|---|
opencode.ai, models.opencode.ai, api.opencode.ai | registry.npmjs.org |
models.dev | github.com |
api.anthropic.com | raw.githubusercontent.com |
| the remote MCP endpoint |
So npm install and git clone work fine while opencode is dead.
Do not conclude “the network is fine” from a working npm/git — always probe
the host that is actually failing.
Everything in the left column shares one cause. Three separate-looking symptoms turned up over this
investigation — opencode failing, the MCP server not connecting, and the Claude Code CLI
erroring — and all three were this same proxy. Suspect one root cause before theorising about three.
The intuitive route — dump the Windows Root/CA stores, import into WSL — silently produced only public CAs and none of the corporate ones. Filtering with “exclude Windows built-in roots” does not guarantee the interception CA is present in the store at all.
Capture the CA off the wire while connected to the corporate network, using
openssl s_client -showcerts. That is what check-ssl.sh does, and it is
always correct because it records exactly what the proxy presented.
opencode is a Bun-compiled binary and ignores the system trust store
Its stack traces show /$bunfs/…. Bun does not read
/etc/ssl/certs and does not honour SSL_CERT_FILE.
It honours only NODE_EXTRA_CA_CERTS.
Consequence: update-ca-certificates alone fixes curl/git/python but
not opencode. The CA must land in both places:
| Target | Fixes |
|---|---|
/usr/local/share/ca-certificates/corp/*.crt → update-ca-certificates | curl, git, python, openssl |
/usr/local/share/corp-bundle.pem → NODE_EXTRA_CA_CERTS | node, Bun, opencode |
Claude Code ships as a native compiled binary too and had NODE_EXTRA_CA_CERTS in its
environment. Which of the two stores it actually reads was never determined — both were updated
within two seconds of each other, so the observation cannot separate them. Install to both and the
question does not matter.
Both the system bundle and NODE_EXTRA_CA_CERTS are read once, when a process first
builds its TLS trust store. Replacing those files afterwards does nothing for anything already running.
After the CA was installed, opencode — relaunched afterwards — worked on the corporate
network, while the Claude Code CLI session kept failing with the same self-signed error.
That session had started about 7 minutes before the bundle was replaced. Its
NODE_EXTRA_CA_CERTS pointed at the correct path; the file’s contents at the
moment it read them were still the two useless public CAs from the failed first attempt. Restarting
Claude Code fixed it with no further changes.
Symptom to recognise: on one network, some tools work and others fail, with identical env vars. Compare process start time against the bundle’s mtime:
ps -o pid,lstart -p <pid>
stat -c '%y %n' /usr/local/share/corp-bundle.pem /etc/ssl/certs/ca-certificates.crt
If the process is older than the file, restart it — that is the whole fix. It also explains the confusing “works on mobile hotspot, fails on corporate” pattern: an unfiltered network needs only the public roots the stale bundle already had, so the staleness stays invisible until you hit the proxy.
# 1) ON the corporate network — diagnose + capture. Read-only, no sudo.
bash check-ssl.sh
# -> ssl-report-<timestamp>/{report.txt,captured-cas/,captured-bundle.pem}
# 2) Install. Works on any network, needs sudo.
bash install-captured-ca.sh ssl-report-<timestamp>
# 3) Reload the shell AND restart every long-running client
# (opencode, Claude Code, editors, language servers, dev servers).
# See trap 3 - a running process keeps the trust store it started with.
exec $SHELL -l
# 4) Verify ON the corporate network
curl -sSI https://models.opencode.ai/api.json | head -1
opencode run 'hi'
opencode mcp list # remote MCP servers go through the same proxy
install-captured-ca.sh skips public CAs by issuer-name match and installs only the
corporate ones, so the trust store does not get polluted with redundant copies of public roots.
check-ssl.sh outputPer host it prints four client results. The important one:
node (builtin roots) : FAIL SELF_SIGNED_CERT_IN_CHAIN <- what Bun/opencode roughly sees
node (builtin roots) runs with NODE_EXTRA_CA_CERTS/SSL_CERT_FILE
stripped, approximating Bun’s embedded-roots-only view. If curl passes but that row
fails, the problem is the runtime’s trust store, not the network.
The verdict line matches the top-of-chain issuer against a list of known public CAs. A proxy impersonating a public CA name would be mislabelled “public” — trust the client rows over the verdict.
A remote MCP server ("type": "remote" in the opencode config) is just an HTTPS endpoint,
so it is subject to the same interception — plus proxy URL/category filtering, which the CA fix does
not address.
An MCP server that connected on a mobile hotspot but not on the corporate network turned out to be the same root cause — its host was also intercepted, it just was not in the original probe list, so the first capture never covered it. Installing the corporate CA fixed it with no MCP-specific work. Lesson: enumerate every failing host before concluding the CA fix is complete.
check-ssl.sh discovers MCP URLs from the opencode config at runtime (it greps
"url" values), so no private hostname is hard-coded. For each it reports DNS A/AAAA,
TCP/443 reachability, an unauthenticated GET, and a JSON-RPC initialize
POST.
No credentials are ever sent. An HTTP 401 with a JSON auth error is the success signal — it proves TLS and the HTTP round-trip completed end to end. Distinguish the failure modes by what comes back:
| Symptom | Meaning |
|---|---|
401 + JSON auth error | endpoint reachable, transport fine |
401/403 + HTML mentioning block/policy/filter | proxy block page, not the server |
GET status: FAILED with a cert error | TLS interception — re-run the CA capture/install |
TCP/443 v4 : BLOCKED | firewall drop, nothing to do with certificates |
GET works but POST rpc fails | proxy mangles POST or breaks SSE/streaming |
Keep a known-good run from an unfiltered network and diff against the corporate run — the difference localises the fault immediately.
Note getent hosts prints the AAAA record first on dual-stack hosts, which looks like
“IPv6 only” at a glance. Check getent ahostsv4 before blaming IPv6.
/etc/profile.d/*.sh only runs for login shells. The first attempt set
the CA env vars there; scripts and non-login shells got nothing, so behaviour differed depending on
how the shell was started. install-captured-ca.sh also appends a source line to
~/.bashrc.
~/.bashrc, the
current session still has it. Check with env -u VAR bash -ic 'echo $VAR', not a
plain child shell.
NODE_TLS_REJECT_UNAUTHORIZED=0, npm config set strict-ssl false,
git config http.sslVerify false, PYTHONHTTPSVERIFY=0 all mask the problem
and disable verification globally. (Note: PYTHONHTTPSVERIFY=0 is a no-op on modern
CPython, and HTTPS_VERIFYPEER is not read by anything standard — both are cargo-culted.)
Trusting the corporate root means the proxy can decrypt TLS traffic to the intercepted hosts. That is a property of how the network is operated — these scripts only make the existing interception explicit rather than fatal. The alternative (disabling verification) is strictly worse: it accepts any certificate, not just the proxy’s.
Captured CA material identifies the organisation. Report folders should stay gitignored and must not
be committed or shared. The scripts and this document contain no corporate hostname —
check-ssl.sh discovers the MCP URL from the opencode config at runtime rather than
hard-coding it.
| File | Purpose |
|---|---|
check-ssl.sh | Diagnose + capture CAs off the wire, and probe MCP endpoints. Run on the affected network. |
baseline-hotspot/ | Known-good run from an unfiltered network, for diffing. Gitignored. |
corporate-run-fixed/ | Corporate-network run after the fix: interception present, everything verifying. The reference for “healthy on the filtered network”. Gitignored. |
install-captured-ca.sh | Install captured non-public CAs into system store + NODE_EXTRA_CA_CERTS bundle. |