Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Added `skills` to `cloudsmith upstream`. Use `cloudsmith upstream skills list`, `create`, `update`, and `delete` to manage AI Skills upstreams. An AI Skills upstream caches skills and plugins from a GitHub repository.
- Added `--changelog` to `cloudsmith push skills`. Use it to add release notes to an AI Skills package. The option is available only for the `skills` format.
- Added `--compress` to `cloudsmith push skills`. With this option, you can pass a skill folder instead of a `.zip` archive. The CLI compresses the contents of the folder into a `.zip` archive, then uploads it. If you pass a folder without `--compress`, the command fails with a usage error. The option is available only for the `skills` format.
- Added `--ssl-system-trust-store`, the `CLOUDSMITH_SSL_SYSTEM_TRUST_STORE` environment variable, and the `ssl_system_trust_store` key in `config.ini`. With this setting, the CLI also trusts the trust store of the operating system, for example the macOS Keychain or the Windows certificate store. It still trusts the CA bundle that it trusts without the setting: `REQUESTS_CA_BUNDLE` when it is set, else the CA bundle that the CLI includes. Use it behind a TLS-intercepting proxy whose CA is in the trust store of the operating system. The setting is off by default. (#603)

### Changed

Expand Down Expand Up @@ -93,6 +94,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- `cloudsmith mcp start` now stops without an error when the MCP client closes the connection while the server writes a reply. Previously, the command stopped with `BrokenPipeError` and exit status 1.
- The CLI now exits with status 1 when you interrupt it with Ctrl+C after the reader of its stderr has closed the pipe. Before, the CLI sent an error report and exited with status 120.
- `cloudsmith check rates` no longer stops with `TypeError` when the API does not give a value for an interval, limit, remaining count, reset time or throttled status. The command now shows `None` for that value.
- The credential helpers now use `--ssl-verify`, `--ssl-verify-strict`, and their environment variables and `config.ini` keys when they look up the custom domains of a Workspace. Previously, the lookup always verified certificates with the strict X.509 checks, so it failed behind a TLS-intercepting proxy even with `--without-ssl-verify-strict`. (#603)

## [1.30.0] - 2026-10-22

Expand Down
1 change: 1 addition & 0 deletions cloudsmith_cli/cli/commands/credential_helper/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
proxy=opts.proxy,
ssl_verify=opts.ssl_verify,
ssl_verify_strict=opts.ssl_verify_strict,
ssl_system_trust_store=opts.ssl_system_trust_store,
user_agent=opts.api_user_agent,
)

Expand Down Expand Up @@ -209,7 +210,7 @@
contexts = []
while ctx is not None:
contexts.insert(0, ctx)
ctx = ctx.parent

Check failure on line 213 in cloudsmith_cli/cli/commands/credential_helper/manage.py

View workflow job for this annotation

GitHub Actions / ty

ty (invalid-assignment)

cloudsmith_cli/cli/commands/credential_helper/manage.py:213:15: invalid-assignment: Object of type `Context | None` is not assignable to `Context` cloudsmith_cli/cli/commands/credential_helper/manage.py:200:38: Declared type info: element `None` of union `Context | None` is not assignable to `Context`

supplied = (ParameterSource.COMMANDLINE, ParameterSource.ENVIRONMENT)
settings = {}
Expand Down
11 changes: 11 additions & 0 deletions cloudsmith_cli/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class Default(SectionSchema):
ssl_verify_strict = ConfigParam(
name="ssl_verify_strict", type=bool, default=True
)
ssl_system_trust_store = ConfigParam(name="ssl_system_trust_store", type=bool)
api_user_agent = ConfigParam(name="api_user_agent", type=str)
mcp_allowed_tools = ConfigParam(name="mcp_allowed_tools", type=str)
mcp_allowed_tool_groups = ConfigParam(name="mcp_allowed_tool_groups", type=str)
Expand Down Expand Up @@ -576,6 +577,16 @@ def ssl_verify_strict(self, value):
"""Set value for strict X.509 SSL verification."""
self._set_option("ssl_verify_strict", value, allow_clear=False)

@property
def ssl_system_trust_store(self):
"""Get value for SSL verification against the system trust store."""
return self._get_option("ssl_system_trust_store", default=False)

@ssl_system_trust_store.setter
def ssl_system_trust_store(self, value):
"""Set value for SSL verification against the system trust store."""
self._set_option("ssl_system_trust_store", value, allow_clear=False)

@property
def api_user_agent(self):
"""Get value for API user agent."""
Expand Down
20 changes: 19 additions & 1 deletion cloudsmith_cli/cli/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,16 @@ def initialise_session(f):
"by default, which rejects some certificates issued by TLS-intercepting "
"corporate proxies. Certificates are still verified when disabled.",
)
@click.option(
"--ssl-system-trust-store/--without-ssl-system-trust-store",
default=None,
is_flag=True,
envvar="CLOUDSMITH_SSL_SYSTEM_TRUST_STORE",
help="Also trust the CA certificates in the operating system trust store "
"for HTTPS connections (disabled by default). Use this if a "
"TLS-intercepting proxy uses a CA that the operating system trusts. "
"The certifi bundle and REQUESTS_CA_BUNDLE stay trusted.",
)
@click.option(
"--api-user-agent",
envvar="CLOUDSMITH_API_USER_AGENT",
Expand Down Expand Up @@ -587,7 +597,7 @@ def initialise_session(f):
def wrapper(ctx, *args, **kwargs):
# pylint: disable=missing-docstring
from ..core.session import create_requests_session as _create_session
from ..core.session import set_default_proxy
from ..core.session import set_default_proxy, set_default_tls

opts = config.get_or_create_options(ctx)
_warn_deprecated_config_keys(ctx, opts)
Expand Down Expand Up @@ -632,6 +642,7 @@ def wrapper(ctx, *args, **kwargs):
ctx.params["ssl_verify"] = ssl_verify
opts.ssl_verify = ssl_verify
opts.ssl_verify_strict = kwargs.pop("ssl_verify_strict")
opts.ssl_system_trust_store = kwargs.pop("ssl_system_trust_store")
Comment thread
Copilot marked this conversation as resolved.
Resolved
opts.api_user_agent = kwargs.pop("api_user_agent")
opts.api_headers = kwargs.pop("api_headers")

Expand All @@ -640,10 +651,16 @@ def wrapper(ctx, *args, **kwargs):
)

set_default_proxy(opts.proxy)
set_default_tls(
ssl_verify=opts.ssl_verify,
ssl_verify_strict=opts.ssl_verify_strict,
ssl_system_trust_store=opts.ssl_system_trust_store,
)
opts.session = _create_session(
proxy=opts.proxy,
ssl_verify=opts.ssl_verify,
ssl_verify_strict=opts.ssl_verify_strict,
ssl_system_trust_store=opts.ssl_system_trust_store,
user_agent=opts.api_user_agent,
headers=opts.api_headers,
)
Expand Down Expand Up @@ -888,6 +905,7 @@ def call_print_rate_limit_info_with_opts(rate_info):
proxy=opts.proxy,
ssl_verify=opts.ssl_verify,
ssl_verify_strict=opts.ssl_verify_strict,
ssl_system_trust_store=opts.ssl_system_trust_store,
user_agent=opts.api_user_agent,
headers=opts.api_headers,
rate_limit=opts.rate_limit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1335,11 +1335,18 @@ def record_download(artifact_path, destination, session=None):
) as create_session,
):
result = _invoke(
"install", [helper, "--without-ssl-verify-strict", "--no-discover"]
"install",
[
helper,
"--without-ssl-verify-strict",
"--ssl-system-trust-store",
"--no-discover",
],
)

assert result.exit_code == 0, result.output
assert create_session.call_args.kwargs["ssl_verify_strict"] is False
assert create_session.call_args.kwargs["ssl_system_trust_store"] is True
# The session for the public repository gets no --api-headers.
assert "headers" not in create_session.call_args.kwargs
assert sessions
Expand Down
94 changes: 94 additions & 0 deletions cloudsmith_cli/cli/tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

import click
import click.testing
import cloudsmith_api
import pytest
import truststore

from ...core import keyring as core_keyring
from ...core.api.exceptions import ApiException
from ...core.mcp.server import create_ssl_context
from ...core.session import create_requests_session
from ...core.tests.test_certificate_errors import certificate_error
from ...credential_helpers.domains import get_workspace_domains
from ...core.utils import get_data_path, read_file
from ..commands.main import main
from ..config import ConfigParam, ConfigSchema, Options
from ..decorators import (
common_api_auth_options,
common_cli_output_options,
common_package_action_options,
initialise_api,
Expand Down Expand Up @@ -157,6 +161,37 @@ def command(opts):
assert create_session.call_args.kwargs["ssl_verify_strict"] is expected


@pytest.mark.parametrize(
"args,env,config_value,expected",
[
([], {}, None, False),
(["--ssl-system-trust-store"], {}, None, True),
([], {"CLOUDSMITH_SSL_SYSTEM_TRUST_STORE": "1"}, None, True),
([], {}, "true", True),
(["--without-ssl-system-trust-store"], {}, "true", False),
],
)
def test_ssl_system_trust_store(
args, env, config_value, expected, telemetry_config_dir
):
@click.command()
@initialise_session
def command(opts):
pass

opts = Options()
if config_value is not None:
config_file = telemetry_config_dir / "config.ini"
config_file.write_text(f"[default]\nssl_system_trust_store={config_value}\n")
opts.load_config_file(str(config_file))

with patch("cloudsmith_cli.core.session.create_requests_session") as create_session:
result = click.testing.CliRunner().invoke(command, args, env=env, obj=opts)

assert result.exit_code == 0, result.output
assert create_session.call_args.kwargs["ssl_system_trust_store"] is expected


@pytest.mark.parametrize(
"args,env,configured,expected",
[
Expand Down Expand Up @@ -288,6 +323,65 @@ def command(opts):
assert result.exit_code == 0, result.output


def test_ssl_system_trust_store_env_var_reaches_all_transports():
@click.command()
@initialise_api
def command(opts):
assert opts.api_config.ssl_system_trust_store is True
with create_requests_session() as session:
adapter = session.get_adapter("https://api.example.com/")
assert isinstance(adapter.ssl_context_factory(), truststore.SSLContext)
assert isinstance(create_ssl_context(opts.api_config), truststore.SSLContext)

with patch(
"cloudsmith_cli.cli.decorators.CredentialProviderChain.resolve",
return_value=None,
):
result = click.testing.CliRunner().invoke(
command, env={"CLOUDSMITH_SSL_SYSTEM_TRUST_STORE": "1"}
)
assert result.exit_code == 0, result.output


@pytest.mark.parametrize(
"env,attribute,expected",
[
({"CLOUDSMITH_SSL_SYSTEM_TRUST_STORE": "1"}, "ssl_system_trust_store", True),
({"CLOUDSMITH_SSL_VERIFY_STRICT": "0"}, "ssl_verify_strict", False),
({"CLOUDSMITH_SSL_VERIFY": "0"}, "verify_ssl", False),
],
)
def test_tls_settings_reach_an_uncached_credential_helper_domain_lookup(
env, attribute, expected, tmp_path, monkeypatch
):
seen = {}

def list_domains(workspace, credential=None):
seen[attribute] = getattr(cloudsmith_api.Configuration(), attribute)
return []

monkeypatch.setattr("cloudsmith_cli.core.api.workspaces.list_domains", list_domains)
monkeypatch.setattr(
"cloudsmith_cli.credential_helpers.domains.get_default_config_path",
lambda: str(tmp_path),
)

@click.command()
@common_api_auth_options
@resolve_credentials
def command(opts):
get_workspace_domains("acme", credential=opts.credential, refresh=True)

with patch(
"cloudsmith_cli.cli.decorators.CredentialProviderChain.resolve",
return_value=None,
):
result = click.testing.CliRunner().invoke(command, env=env)

assert result.exit_code == 0, result.output
assert seen == {attribute: expected}


PROXY = "http://proxy.example.com:3128"


Expand Down
12 changes: 12 additions & 0 deletions cloudsmith_cli/cli/tests/test_webserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ def test_refresh_api_config_keeps_ssl_verify(self):

assert mock_init_api.call_args.kwargs["ssl_verify"] is False

def test_refresh_api_config_keeps_ssl_system_trust_store(self):
with patch.object(AuthenticationWebServer, "__init__", lambda *a, **kw: None):
server = AuthenticationWebServer.__new__(AuthenticationWebServer)
server.sso_access_token = None
server.api_opts = cloudsmith_api.Configuration()
server.api_opts.ssl_system_trust_store = True

with patch("cloudsmith_cli.cli.webserver.initialise_api") as mock_init_api:
server.refresh_api_config_after_auth()

assert mock_init_api.call_args.kwargs["ssl_system_trust_store"] is True


class TestAuthenticationWebRequestHandlerKeyring:
"""Tests for AuthenticationWebRequestHandler keyring behavior."""
Expand Down
3 changes: 3 additions & 0 deletions cloudsmith_cli/cli/webserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ def refresh_api_config_after_auth(self):
proxy=getattr(self.api_opts, "proxy", None),
ssl_verify=getattr(self.api_opts, "verify_ssl", True),
ssl_verify_strict=getattr(self.api_opts, "ssl_verify_strict", True),
ssl_system_trust_store=getattr(
self.api_opts, "ssl_system_trust_store", False
),
user_agent=getattr(self.api_opts, "user_agent", None),
headers=getattr(self.api_opts, "headers", None),
rate_limit=getattr(self.api_opts, "rate_limit", True),
Expand Down
3 changes: 2 additions & 1 deletion cloudsmith_cli/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def restore_api_configuration_default():

@pytest.fixture(autouse=True)
def restore_default_proxy():
"""Clear the process-wide proxy that ``initialise_session`` sets."""
"""Clear the process-wide proxy and TLS settings that ``initialise_session`` sets."""
yield
session.set_default_proxy(None)
session.set_default_tls()
20 changes: 16 additions & 4 deletions cloudsmith_cli/core/api/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import cloudsmith_api

from ..rest import RestClient
from ..session import get_default_tls


def _basic_auth_credentials(encoded: str) -> tuple[str, str]:
Expand All @@ -27,8 +28,9 @@ def initialise_api(
host=None,
credential=None,
proxy=None,
ssl_verify=True,
ssl_verify_strict=True,
ssl_verify=None,
ssl_verify_strict=None,
ssl_system_trust_store=None,
user_agent=None,
headers=None,
rate_limit=True,
Expand All @@ -52,8 +54,18 @@ def initialise_api(
config.error_retry_backoff = error_retry_backoff
config.error_retry_codes = error_retry_codes
config.error_retry_cb = error_retry_cb
config.verify_ssl = ssl_verify
config.ssl_verify_strict = ssl_verify_strict
# A caller that leaves out a TLS setting, such as a domain lookup, keeps the
# setting of the session.
tls = get_default_tls()
config.verify_ssl = tls["ssl_verify"] if ssl_verify is None else ssl_verify
config.ssl_verify_strict = (
tls["ssl_verify_strict"] if ssl_verify_strict is None else ssl_verify_strict
)
config.ssl_system_trust_store = (
tls["ssl_system_trust_store"]
if ssl_system_trust_store is None
else ssl_system_trust_store
)
config.client_side_validation = False

if credential:
Expand Down
4 changes: 3 additions & 1 deletion cloudsmith_cli/core/certificate_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ def classify_certificate_error(exc):
return CertificateFailure(
"E_TLS_UNTRUSTED_CA",
f"The TLS certificate is not from a trusted CA: {reason}.",
"If your network uses a TLS-intercepting proxy, set REQUESTS_CA_BUNDLE "
"If your network uses a TLS-intercepting proxy, use "
"--ssl-system-trust-store or set CLOUDSMITH_SSL_SYSTEM_TRUST_STORE=1 to "
"trust the CAs of the operating system. Or set REQUESTS_CA_BUNDLE "
"to a CA bundle that contains the proxy CA.",
)
return CertificateFailure(
Expand Down
16 changes: 14 additions & 2 deletions cloudsmith_cli/core/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from mcp.shared._httpx_utils import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT

from ..api.exceptions import ApiException
from ..session import disable_x509_strict
from ..session import create_system_trust_store_context, disable_x509_strict
from ..version import get_version as get_cli_version
from .data import OpenAPITool

Expand Down Expand Up @@ -111,6 +111,16 @@ def _resolve_ca_location(api_config: cloudsmith_api.Configuration) -> str:
return certifi.where()


def _create_system_trust_store_context(ca_location: str) -> ssl.SSLContext:
"""Return a context that trusts the operating system trust store and ``ca_location``."""
ctx = create_system_trust_store_context()
if os.path.isdir(ca_location):
ctx.load_verify_locations(capath=ca_location)
else:
ctx.load_verify_locations(cafile=ca_location)
return ctx


def create_ssl_context(
api_config: cloudsmith_api.Configuration,
) -> ssl.SSLContext | bool:
Expand All @@ -124,7 +134,9 @@ def create_ssl_context(
return False

ca_location = _resolve_ca_location(api_config)
if os.path.isdir(ca_location):
if getattr(api_config, "ssl_system_trust_store", False):
ctx = _create_system_trust_store_context(ca_location)
elif os.path.isdir(ca_location):
ctx = ssl.create_default_context(capath=ca_location)
else:
ctx = ssl.create_default_context(cafile=ca_location)
Expand Down
Loading
Loading