Skip to content

feat(ENG-15617): add an option to trust the operating system trust store - #603

Draft
cloudsmith-iduffy wants to merge 4 commits into
masterfrom
eng-15617-cli-opt-in-to-the-operating-system-trust-store-for-tls
Draft

cloudsmith-iduffy wants to merge 4 commits into
masterfrom
eng-15617-cli-opt-in-to-the-operating-system-trust-store-for-tls

Conversation

@cloudsmith-iduffy

@cloudsmith-iduffy cloudsmith-iduffy commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Resolves ENG-15617.

A customer ran the Cloudsmith Gradle plugin behind a TLS-intercepting proxy. cloudsmith domains list failed with a certificate error. The CLI verifies TLS with the certifi bundle, not with the trust store of the operating system. The proxy CA was in the operating system trust store, but the user had to set REQUESTS_CA_BUNDLE to make the CLI work.

This PR adds an opt-in setting that also trusts the operating system trust store. It uses the truststore package. The default behaviour does not change.

Kind Name
Flag --ssl-system-trust-store / --without-ssl-system-trust-store
Environment variable CLOUDSMITH_SSL_SYSTEM_TRUST_STORE=1
config.ini key ssl_system_trust_store = true (in [default] or a [profile:NAME] section)

Precedence is the same as for --ssl-verify-strict: the flag, then the environment variable, then config.ini, then the default (off). Tools that run the CLI as a subprocess, such as the Maven tools, can set only the environment variable.

Design decision: in addition to certifi, not instead of it

When the setting is on, the CLI trusts the operating system trust store in addition to the CA bundle that it uses today (certifi, or REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE when set; for the MCP server also SSL_CERT_FILE / SSL_CERT_DIR). A user who already set REQUESTS_CA_BUNDLE can turn the setting on and nothing breaks.

This works because truststore 0.10 adds the CA certificates that are loaded into its context to the trust anchors of the platform verifier:

  • macOS: SecTrustSetAnchorCertificates with SecTrustSetAnchorCertificatesOnly(false), so the Keychain stays trusted.
  • Windows: if the system chain engine rejects the chain, it tries again with a chain engine that trusts the loaded certificates.
  • Linux: OpenSSL loads the system bundle (SSL_CERT_FILE/SSL_CERT_DIR, the compiled-in path, or a list of well-known distribution paths) and the loaded bundle into one store.

requests and urllib3 already load certifi or REQUESTS_CA_BUNDLE into the context, so the sessions get this behaviour with no extra code. The MCP server loads the bundle that _resolve_ca_location gives into the context.

What changes

  • core/session.py: create_system_trust_store_context() makes a truststore.SSLContext with the urllib3 default verify flags. create_requests_session() takes ssl_system_trust_store and falls back to the SDK configuration, as ssl_verify_strict does. SSLContextAdapter gets the factory, so the context is not used when --without-ssl-verify is set.
  • core/api/init.py: initialise_api() stores ssl_system_trust_store on the cloudsmith_api configuration. The SDK RestClient uses create_requests_session(), so the SDK calls get the setting.
  • core/mcp/server.py: create_ssl_context() uses the truststore context when the setting is on, and loads the resolved CA bundle into it.
  • cli/decorators.py, cli/config.py, data/config.ini: the flag, the environment variable and the config key. The setting goes to every place that gets ssl_verify_strict today: the shared session, the SDK, the update check, the Maven tools downloads and the SAML refresh.
  • core/certificate_errors.py: the E_TLS_UNTRUSTED_CA hint names the new setting before REQUESTS_CA_BUNDLE. E_TLS_STRICT does not change, because the strict checks are a different failure.
  • pyproject.toml, uv.lock: truststore>=0.10.4 is now a direct dependency. It was already in the lock through httpx2/httpcore2, so the locked version does not change.
  • packaging/pyinstaller/entry.py: the selftest makes a truststore context. The CLI imports truststore only when the setting is on, so the import sweep does not load it.

How it composes with the other TLS options

Options Result
Default No change: certifi or REQUESTS_CA_BUNDLE, with strict X.509 checks on Python 3.13+
--ssl-system-trust-store Operating system trust store and certifi or REQUESTS_CA_BUNDLE
--ssl-system-trust-store --without-ssl-verify-strict As above, without VERIFY_X509_STRICT. This has an effect on Linux only, see the risks
--ssl-system-trust-store --without-ssl-verify No verification. The truststore context is not used

Credential helper domain lookups

A credential helper initialises the SDK again for an uncached custom domain lookup (credential_helpers/domains.py). That reset --ssl-verify, --ssl-verify-strict and --ssl-system-trust-store to their defaults, so the lookup failed behind a TLS-intercepting proxy even with the setting on. --without-ssl-verify-strict had the same problem before this PR. initialise_session now sets the TLS settings as process defaults, as it does for the proxy, and initialise_api uses them when a caller leaves a setting out.

Frozen binary

truststore is pure Python. On macOS and Windows it calls the Security framework and crypt32.dll through ctypes, and these are part of the operating system. PyInstaller finds the function-level import truststore in core/session.py, and httpcore2 imports it too. I built the onedir binary on macOS arm64 with the binaries workflow command. truststore, truststore._macos, truststore._windows and truststore._openssl are in the PYZ, and CLOUDSMITH_SELFTEST=1 reports SELFTEST: OK.

Risks

  • macOS and Windows use the platform verifier. It applies its own rules. For example, macOS rejects a server certificate with no serverAuth extended key usage, or with a validity longer than 825 days. A proxy certificate that fails these rules fails with the setting on, and --without-ssl-verify-strict does not change that. The user can turn the setting off and use REQUESTS_CA_BUNDLE.
  • Linux hosts without a system CA bundle. truststore finds no system bundle, but the CLI still trusts certifi, so the CLI works as it does today. I checked this in a python:3.13-slim container with /etc/ssl/certs removed.
  • Frozen Linux binaries. OpenSSL in the binary has the compiled-in paths of the build image. If those paths do not exist on the host, truststore tries the well-known distribution paths (/etc/ssl/cert.pem, /etc/pki/..., /etc/ssl/certs/ca-certificates.crt, /etc/ssl/ca-bundle.pem). A distribution with its bundle in another place needs SSL_CERT_FILE. I did not build a Linux or Windows binary for this PR. The binaries workflow selftest covers the import on all targets.

Tests

TDD: the new tests failed before the implementation.

  • core/tests/test_rest.py: the session uses a truststore context, composes with --without-ssl-verify-strict and --without-ssl-verify, and falls back to the SDK configuration. Two tests do a real TLS handshake with a local server that has a certificate from a test CA. With the CA in the bundle, the request succeeds, which shows that the bundle is trusted in addition to the system store. With no bundle, the request fails.
  • core/tests/test_mcp_http_client.py: the MCP context is a truststore context, loads the CA file or directory, and composes with the strict and verify settings.
  • cli/tests/test_decorators.py: the flag, the environment variable and config.ini. Another test sets only the environment variable and checks the SDK configuration, the requests session and the MCP context.
  • test_update_check.py, test_webserver.py, test_credential_helper_maven_tools.py, test_certificate_errors.py: the setting reaches each caller, and the hint names it.

Results:

  • pytest -m "not integration": 2151 passed. Run with a clean HOME and no CLOUDSMITH_WORKSPACE. With my real HOME, five workspace tests fail because my ~/.cloudsmith/config.ini sets a workspace. This change does not touch them.
  • The touched tests also pass on Linux in python:3.13-slim.
  • pre-commit run --files <changed files>: passed.
  • Manual check on macOS, with REQUESTS_CA_BUNDLE set to a bundle that does not contain the CA of api.cloudsmith.io: cloudsmith check service fails with the new hint by default. It succeeds with CLOUDSMITH_SSL_SYSTEM_TRUST_STORE=1, from source and from the frozen binary. The MCP httpx2 client gives the same results.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactoring
  • Other (please describe)

Additional Notes

  • CHANGELOG.md has an entry under [Unreleased] for the option and one for the fix below. The option ships in v1.32.0 or later.
  • Docs: cloudsmith-io/cloudsmith-docs#727. It merges after the CLI release that ships this setting.
  • Follow-up from the ticket: document the setting in the troubleshooting sections of the Maven, Gradle and sbt credential helper pages.

🤖 Generated with Claude Code

Add --ssl-system-trust-store/--without-ssl-system-trust-store, the
CLOUDSMITH_SSL_SYSTEM_TRUST_STORE environment variable and the
ssl_system_trust_store config.ini key. The option is off by default.

When the option is on, the requests sessions, the cloudsmith_api SDK and
the MCP server verify TLS with a truststore context. That context trusts
the operating system trust store in addition to the certifi bundle or
REQUESTS_CA_BUNDLE. --without-ssl-verify-strict and --without-ssl-verify
apply as before.

The hint for a certificate from an untrusted CA now names the option.
The frozen binary selftest checks that truststore is bundled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 6, 2026 11:48
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

🟡 Changes recommended

Credential-helper custom-domain lookups reset the resolved TLS option to false during SDK initialization, causing discovery and runtime lookups to fail behind proxies whose CA is trusted only by the operating system.

1 open issue
What changed in this PR

Adds opt-in operating-system CA trust alongside the existing CA bundle, allowing the CLI to work behind TLS-intercepting proxies without changing default behavior.

Changes:

  • Adds flag, environment-variable, and configuration controls for system trust.
  • Integrates truststore contexts into requests and MCP clients and propagates the setting through shared connection setup.
  • Updates certificate-error guidance, TLS tests, and frozen-binary selftests.
File Description
uv.lock Records truststore as a direct dependency.
pyproject.toml Declares the truststore dependency.
packaging/​pyinstaller/​entry.py Adds a system-trust context selftest.
cloudsmith_cli/​data/​config.ini Documents the system-trust setting.
cloudsmith_cli/​core/​update_check.py Passes the setting to update-check sessions.
cloudsmith_cli/​core/​tests/​test_update_check.py Checks update-session propagation.
cloudsmith_cli/​core/​tests/​test_rest.py Tests context selection and local TLS handshakes.
cloudsmith_cli/​core/​tests/​test_mcp_http_client.py Tests MCP trust contexts and option combinations.
cloudsmith_cli/​core/​tests/​test_certificate_errors.py Checks the new troubleshooting hint.
cloudsmith_cli/​core/​session.py Creates and selects truststore contexts.
cloudsmith_cli/​core/​mcp/​server.py Combines system trust with the resolved CA bundle.
cloudsmith_cli/​core/​certificate_errors.py Suggests system trust for untrusted-CA failures.
cloudsmith_cli/​core/​api/​init.py Stores the setting in SDK configuration.
cloudsmith_cli/​cli/​webserver.py Preserves the setting after SAML authentication.
cloudsmith_cli/​cli/​tests/​test_webserver.py Checks authentication-refresh propagation.
cloudsmith_cli/​cli/​tests/​test_decorators.py Tests option resolution and transport propagation.
cloudsmith_cli/​cli/​tests/​test_credential_helper_maven_tools.py Checks helper-download session propagation.
cloudsmith_cli/​cli/​decorators.py Exposes CLI controls and forwards the setting.
cloudsmith_cli/​cli/​config.py Adds the configuration schema and accessor.
cloudsmith_cli/​cli/​commands/​credential_helper/​manage.py Passes the setting to helper-download sessions.

Review effort: Balanced

Comment thread cloudsmith_cli/cli/decorators.py Resolved
…kups

A credential helper initialises the SDK again for an uncached custom
domain lookup. That reset --ssl-verify, --ssl-verify-strict and
--ssl-system-trust-store to their defaults, so the lookup failed behind
a TLS-intercepting proxy. initialise_session now sets the TLS settings
as process defaults, as it does for the proxy, and initialise_api uses
them when a caller leaves a setting out.

Add the CHANGELOG entries for the option and the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
REQUESTS_CA_BUNDLE replaces the included bundle, so the CHANGELOG must
not say that the CLI trusts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI 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.

🟢 Approval recommended

No active review issues are identified for the opt-in system certificate trust changes.

1 open issue

Review effort: Balanced

Copilot AI 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.

🟢 Approval recommended

The opt-in system certificate trust change has no active review issues.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

cloudsmith-iduffy added a commit to cloudsmith-labs/cloudsmith-maven-tools that referenced this pull request Nov 6, 2026
# Description

A customer used the Gradle plugin behind a TLS-intercepting proxy.
`cloudsmith domains list` failed, the plugin added no credentials, and
the build got 401 responses with no clue to the cause. The CLI printed
the fix on stderr, but the plugin discarded stderr and logged the
failure at debug level.

This change:

- Logs a warning when the CLI is missing, when `CLOUDSMITH_CLI_PATH` is
not executable, and when a CLI command fails, times out, or prints
output that is not JSON. The warning includes the stderr of the CLI, for
example `Hint: ... set REQUESTS_CA_BUNDLE to a CA bundle that contains
the proxy CA.` The CLI never prints the credential on stderr, and the
warning never includes stdout.
- Adds `Warnings`, because sbt does not show SLF4J output. Maven and
Gradle log through SLF4J as before. The sbt plugin sends the warnings to
its task logger.
- Adds the TLS and proxy variables (`REQUESTS_CA_BUNDLE`,
`SSL_CERT_FILE`, `HTTPS_PROXY`, `NO_PROXY` and others) to the key of the
CLI cache in the Gradle daemon. Before, a fix to these variables applied
only after the five-minute TTL.
- Documents the init script in `~/.gradle/init.d`. Gradle resolves the
`plugins` block of `settings.gradle.kts` before it applies the plugin,
so the plugin cannot cover other settings plugins from a private
repository. `cloudsmith credential-helper install gradle` writes the
same init script.

A failed answer is still cached for the TTL, so a second build in the
same Gradle daemon or sbt session within five minutes does not repeat
the warning.

## Type of change

- [x] Bug fix
- [x] New feature
- [ ] Breaking change
- [x] Documentation update
- [ ] Refactoring
- [ ] Other (describe below)

## Checklist

- [x] The title is a [Conventional
Commit](https://www.conventionalcommits.org/), for example `fix: ...`
- [x] Tests cover the change in behavior
- [ ] `mise run lint` and `mise run verify` pass
- [x] No new runtime dependency (see CONTRIBUTING.md)

## Additional notes

Tests were written first and seen failing:

- Gradle integration tests: a failing CLI with a TLS hint, a missing
`CLOUDSMITH_CLI_PATH`, no CLI on `PATH`, and a second build after
`REQUESTS_CA_BUNDLE` is set. 11 of 11 pass.
- Maven: a new integration test for the failing CLI. 4 of 4 integration
tests and all unit tests pass.
- sbt 1: a new integration test for the failing CLI. 6 of 6 pass. The
sbt 2 tests could not run locally, because a mirror in the local
`~/.m2/settings.xml` returns 401. CI runs them.

The pre-commit hooks pass. `mise run verify` did not run as one command
locally, for the same mirror reason.

Related: cloudsmith-io/cloudsmith-docs#714 documents the settings plugin
limit. cloudsmith-io/cloudsmith-cli#603 adds
`CLOUDSMITH_SSL_SYSTEM_TRUST_STORE`, which is an alternative to
`REQUESTS_CA_BUNDLE`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants