From 1137ddd6f8ceb9423749b4259229d4a1076e5842 Mon Sep 17 00:00:00 2001 From: fangkangmi Date: Fri, 3 Jul 2026 12:42:06 +0100 Subject: [PATCH 01/27] feat(tls): verify against the OS trust store by default (#2004) APM verified HTTPS against the bundled certifi CA set, which omits internal/corporate root CAs and TLS-proxy certs. Since APM also shells out to git (which reads the OS trust store), `git clone` of an internal host succeeded while APM's requests-based Contents API calls failed against the same chain -- a confusing enterprise first-run failure. Route requests' TLS verification through the OS trust store via truststore, injected once at CLI startup. Best-effort and non-regressive: - An explicit REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE / SSL_CERT_FILE still wins (we skip injection so a pinned bundle is honoured verbatim). - APM_DISABLE_TRUSTSTORE=1 restores the legacy certifi-only behaviour. - Falls back to certifi if truststore is missing or injection raises; configure_tls_trust() never raises. Adds truststore to dependencies and the PyInstaller hiddenimports so the frozen binary ships it, documents the new default in the SSL troubleshooting guide, and covers every branch with unit tests. --- CHANGELOG.md | 7 ++ build/apm.spec | 1 + .../docs/troubleshooting/ssl-issues.md | 9 ++ pyproject.toml | 1 + src/apm_cli/cli.py | 6 ++ src/apm_cli/core/tls_trust.py | 82 ++++++++++++++++++ tests/unit/core/test_tls_trust.py | 84 +++++++++++++++++++ uv.lock | 11 +++ 8 files changed, 201 insertions(+) create mode 100644 src/apm_cli/core/tls_trust.py create mode 100644 tests/unit/core/test_tls_trust.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d08545c..4175ae550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Collapsed repeated `git config` subprocess calls from 3 to 1 per repository during cache operations, cutting process-spawn overhead on `apm install` and `apm update`. (#1974) +- APM now verifies HTTPS against the OS trust store by default (via + `truststore`), so it works out-of-the-box behind a corporate CA or a + TLS-inspecting proxy, matching the behaviour of `git`/`curl` on the same + host instead of failing against the bundled `certifi` set. An explicitly set + `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` still wins, and + `APM_DISABLE_TRUSTSTORE=1` restores the previous certifi-only behaviour. + Falls back to `certifi` if `truststore` is unavailable. (#2004) ### Fixed diff --git a/build/apm.spec b/build/apm.spec index 10624330e..1fcb73f02 100644 --- a/build/apm.spec +++ b/build/apm.spec @@ -188,6 +188,7 @@ hiddenimports = [ 'frontmatter', 'requests', 'certifi', # CA certificate bundle for SSL verification in frozen binary + 'truststore', # OS trust-store verification (corporate CA / TLS proxy support) # Rich modules (lazily imported, must be explicitly included) 'rich', 'rich.console', diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index 706c80aaf..5b0f51ca8 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -52,6 +52,15 @@ Re-run the failing command with `--verbose` to see the underlying exception and apm install --verbose ``` +## Default behaviour: the OS trust store + +APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. So if your corporate CA or TLS-proxy root is installed in the OS trust store (Keychain on macOS, `update-ca-certificates`/`update-ca-trust` on Linux, the Trusted Root store on Windows), APM picks it up with **no configuration** — importing the CA at the OS level is the recommended fix. + +You only need the steps below when the CA is *not* in the OS store, or you want to pin a specific bundle: + +- Setting `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, or `SSL_CERT_FILE` makes APM honour that bundle instead of the OS store. +- `APM_DISABLE_TRUSTSTORE=1` restores the legacy behaviour (verify against APM's bundled `certifi` set only). + ## Configure trust APM uses `requests` for HTTP and shells out to `git` for repository operations. Both honour standard environment variables. Set them at the shell or in your profile (`~/.zshrc`, `~/.bashrc`, or the Windows user environment). diff --git a/pyproject.toml b/pyproject.toml index 87f17e639..03c2e23c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "colorama>=0.4.6", "pyyaml>=6.0.0", "requests>=2.31.0", + "truststore>=0.9.1", "python-frontmatter>=1.0.0", "llm>=0.28", "llm-github-models>=0.18.0", diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index e2553cf51..111ded11e 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -333,6 +333,12 @@ def main(): """Main entry point for the CLI.""" _configure_logging() # honours APM_LOG_LEVEL env var; --verbose upgrades in cli() _configure_encoding() + # Verify HTTPS against the OS trust store by default so APM behaves like + # git/curl in corporate-CA / TLS-proxy environments. Best-effort: falls + # back to certifi and never raises. Must run before the first HTTPS call. + from apm_cli.core.tls_trust import configure_tls_trust + + configure_tls_trust() try: cli(obj={}) except Exception as e: diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py new file mode 100644 index 000000000..2e077b099 --- /dev/null +++ b/src/apm_cli/core/tls_trust.py @@ -0,0 +1,82 @@ +"""Default TLS trust configuration for APM's HTTP layer. + +By default ``requests`` verifies HTTPS against the bundled ``certifi`` CA set, +which does **not** contain internal/corporate root CAs or the certificates +injected by a TLS-inspecting proxy (Zscaler, Netskope, Palo Alto, ...). APM +also shells out to ``git``, which reads the OS trust store, so ``git clone`` +of an internal host succeeds while APM's ``requests``-based Contents API calls +fail against the *same* certificate chain -- a confusing, inconsistent +failure for enterprise users. + +This module opts APM into the OS trust store via `truststore +`_ so both paths verify against the +same source with zero per-shell configuration. It is deliberately +best-effort: + +* If the user has pinned an explicit CA bundle (``REQUESTS_CA_BUNDLE`` / + ``CURL_CA_BUNDLE`` / ``SSL_CERT_FILE``), that choice wins and we do not + override it. +* If ``truststore`` is unavailable or injection raises for any reason, APM + silently falls back to the previous ``certifi`` behaviour. +* ``APM_DISABLE_TRUSTSTORE`` forces the old behaviour as an escape hatch. + +``configure_tls_trust`` never raises: TLS setup must not be able to crash CLI +startup. +""" + +from __future__ import annotations + +import logging +import os + +logger = logging.getLogger(__name__) + +# Env vars through which a user pins an explicit CA bundle. When any is set we +# respect that choice rather than silently redirecting verification to the OS +# trust store (which could ignore a deliberately narrow, air-gapped bundle). +_EXPLICIT_CA_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE") + +# Escape hatch: set truthy to force the legacy certifi-only behaviour. +_DISABLE_ENV_VAR = "APM_DISABLE_TRUSTSTORE" + +_TRUTHY = {"1", "true", "yes", "on"} + + +def _env_flag(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in _TRUTHY + + +def configure_tls_trust() -> bool: + """Route HTTPS verification through the OS trust store when possible. + + Call once at process startup, before the first HTTPS request. Returns + ``True`` when ``truststore`` was injected, ``False`` when the default + ``certifi`` behaviour was left in place (explicit override, opt-out, + ``truststore`` missing, or injection failure). Never raises. + """ + if _env_flag(_DISABLE_ENV_VAR): + logger.debug("OS trust-store injection disabled via %s", _DISABLE_ENV_VAR) + return False + + explicit = next((var for var in _EXPLICIT_CA_ENV_VARS if os.environ.get(var)), None) + if explicit: + # The user asked for a specific bundle; honour it verbatim. + logger.debug("Explicit CA bundle set via %s; leaving certifi/verify path intact", explicit) + return False + + try: + import truststore + except ImportError: + logger.debug("truststore not installed; verifying TLS against bundled certifi") + return False + + try: + # Broad by design: trust setup must never crash CLI startup, so any + # failure degrades to the certifi default rather than propagating. + truststore.inject_into_ssl() + except Exception as exc: + logger.debug("truststore.inject_into_ssl() failed (%s); falling back to certifi", exc) + return False + + logger.debug("Verifying TLS against the OS trust store via truststore") + return True diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py new file mode 100644 index 000000000..ce0e9ccf7 --- /dev/null +++ b/tests/unit/core/test_tls_trust.py @@ -0,0 +1,84 @@ +"""Unit tests for apm_cli.core.tls_trust.configure_tls_trust. + +Covers every branch: +- opt-out via APM_DISABLE_TRUSTSTORE +- explicit CA bundle env vars win (REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE / SSL_CERT_FILE) +- truststore missing -> graceful certifi fallback +- injection failure -> graceful certifi fallback +- happy path -> inject_into_ssl called exactly once +""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from apm_cli.core.tls_trust import ( + _DISABLE_ENV_VAR, + _EXPLICIT_CA_ENV_VARS, + configure_tls_trust, +) + +_ALL_TRUST_ENV = (_DISABLE_ENV_VAR, *_EXPLICIT_CA_ENV_VARS) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """Start each test from a pristine env (no override / opt-out set).""" + for var in _ALL_TRUST_ENV: + monkeypatch.delenv(var, raising=False) + + +def _install_fake_truststore(monkeypatch, inject=None): + """Put a fake ``truststore`` module in sys.modules and return its inject mock.""" + calls = {"n": 0} + + def _default_inject(): + calls["n"] += 1 + + module = types.ModuleType("truststore") + module.inject_into_ssl = inject or _default_inject # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "truststore", module) + return calls + + +def test_opt_out_disables_injection(monkeypatch): + calls = _install_fake_truststore(monkeypatch) + monkeypatch.setenv(_DISABLE_ENV_VAR, "1") + + assert configure_tls_trust() is False + assert calls["n"] == 0 + + +@pytest.mark.parametrize("var", _EXPLICIT_CA_ENV_VARS) +def test_explicit_ca_bundle_wins(monkeypatch, var): + calls = _install_fake_truststore(monkeypatch) + monkeypatch.setenv(var, "/etc/ssl/certs/custom-ca.pem") + + assert configure_tls_trust() is False + assert calls["n"] == 0 + + +def test_missing_truststore_falls_back(monkeypatch): + # A None entry in sys.modules makes ``import truststore`` raise ImportError. + monkeypatch.setitem(sys.modules, "truststore", None) + + assert configure_tls_trust() is False + + +def test_injection_failure_falls_back(monkeypatch): + def _boom(): + raise RuntimeError("platform trust API unavailable") + + _install_fake_truststore(monkeypatch, inject=_boom) + + assert configure_tls_trust() is False + + +def test_happy_path_injects_once(monkeypatch): + calls = _install_fake_truststore(monkeypatch) + + assert configure_tls_trust() is True + assert calls["n"] == 1 diff --git a/uv.lock b/uv.lock index 1a3612936..9fc54bba2 100644 --- a/uv.lock +++ b/uv.lock @@ -218,6 +218,7 @@ dependencies = [ { name = "ruamel-yaml" }, { name = "toml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "truststore" }, { name = "watchdog" }, { name = "websockets" }, ] @@ -262,6 +263,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, { name = "toml", specifier = ">=0.10.2" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.2.0" }, + { name = "truststore", specifier = ">=0.9.1" }, { name = "watchdog", specifier = ">=3.0.0" }, { name = "websockets", specifier = ">=12,<17" }, ] @@ -1963,6 +1965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 13ffe8793034fe072ce232666aa922999bb329fc Mon Sep 17 00:00:00 2001 From: fangkangmi Date: Fri, 3 Jul 2026 14:14:02 +0100 Subject: [PATCH 02/27] test(tls): integration test for verification against a custom CA (#2004) Completes the triage panel's checklist item: exercise the real requests -> urllib3 -> ssl stack against a loopback HTTPS server whose leaf is signed by a freshly minted private CA (in no trust store). Covers: untrusted CA is rejected (verification is genuinely on); an explicit REQUESTS_CA_BUNDLE is honoured end-to-end while configure_tls_trust() declines to override it; and truststore injection keeps verification on (a CA absent from the OS store is still rejected, proving injection redirects trust rather than disabling it). Skips where the openssl CLI is unavailable; isolates the global ssl / truststore mutation per test. --- tests/integration/test_tls_custom_ca.py | 169 ++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 tests/integration/test_tls_custom_ca.py diff --git a/tests/integration/test_tls_custom_ca.py b/tests/integration/test_tls_custom_ca.py new file mode 100644 index 000000000..5c6935023 --- /dev/null +++ b/tests/integration/test_tls_custom_ca.py @@ -0,0 +1,169 @@ +"""Integration tests for TLS verification against a custom CA. + +Spins up a loopback HTTPS server whose leaf certificate is signed by a +freshly generated private CA (never present in any trust store), then +exercises the real ``requests`` -> ``urllib3`` -> ``ssl`` stack that APM uses +for the Contents API. This is the end-to-end counterpart to the unit tests in +``tests/unit/core/test_tls_trust.py`` and covers the behaviour the triage +panel asked for on #2004: + +- an untrusted custom CA is genuinely rejected (verification is on), +- an explicit ``REQUESTS_CA_BUNDLE`` is honoured and makes the request pass + (and ``configure_tls_trust`` correctly declines to override it), +- injecting the OS trust store via truststore does NOT weaken verification -- + a CA that is not in the OS store is still rejected. + +Requires the ``openssl`` CLI to mint the certificates; skipped where absent. +""" + +from __future__ import annotations + +import http.server +import shutil +import ssl +import subprocess +import threading +from types import SimpleNamespace + +import pytest +import requests + +from apm_cli.core.tls_trust import configure_tls_trust + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif(shutil.which("openssl") is None, reason="openssl CLI not available"), +] + +_TRUST_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "APM_DISABLE_TRUSTSTORE") + +_CA_CNF = """\ +[req] +distinguished_name = dn +x509_extensions = v3_ca +prompt = no +[dn] +CN = APM Test Root CA +[v3_ca] +basicConstraints = critical, CA:TRUE +keyUsage = critical, keyCertSign, cRLSign +""" + +_SERVER_CNF = """\ +[req] +distinguished_name = dn +req_extensions = v3_req +prompt = no +[dn] +CN = localhost +[v3_req] +basicConstraints = CA:FALSE +keyUsage = digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = DNS:localhost, IP:127.0.0.1 +""" + + +def _openssl(*args) -> None: + subprocess.run(["openssl", *[str(a) for a in args]], check=True, capture_output=True) + + +def _mint_ca_and_leaf(dirpath): + """Generate a private CA and a localhost leaf cert signed by it.""" + ca_key, ca_pem = dirpath / "ca.key", dirpath / "ca.pem" + srv_key, srv_csr, srv_pem = dirpath / "server.key", dirpath / "server.csr", dirpath / "server.pem" + ca_cnf, srv_cnf = dirpath / "ca.cnf", dirpath / "server.cnf" + ca_cnf.write_text(_CA_CNF) + srv_cnf.write_text(_SERVER_CNF) + + _openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", ca_key, "-out", ca_pem, "-days", "2", "-config", ca_cnf) + _openssl("req", "-newkey", "rsa:2048", "-nodes", + "-keyout", srv_key, "-out", srv_csr, "-config", srv_cnf) + _openssl("x509", "-req", "-in", srv_csr, "-CA", ca_pem, "-CAkey", ca_key, + "-CAcreateserial", "-out", srv_pem, "-days", "2", + "-extfile", srv_cnf, "-extensions", "v3_req") + return ca_pem, srv_pem, srv_key + + +class _OkHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): # stdlib handler contract + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *_args): # silence per-request stderr logging + pass + + +@pytest.fixture(scope="module") +def custom_ca_server(tmp_path_factory): + """A loopback HTTPS server presenting a leaf signed by a private CA.""" + dirpath = tmp_path_factory.mktemp("tls_custom_ca") + ca_pem, srv_pem, srv_key = _mint_ca_and_leaf(dirpath) + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=str(srv_pem), keyfile=str(srv_key)) + + httpd = http.server.HTTPServer(("127.0.0.1", 0), _OkHandler) + httpd.socket = context.wrap_socket(httpd.socket, server_side=True) + port = httpd.server_address[1] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield SimpleNamespace(url=f"https://localhost:{port}/", ca_path=str(ca_pem)) + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + + +@pytest.fixture(autouse=True) +def _isolate_trust(monkeypatch): + """Pristine trust env per test, and undo any global ssl/truststore mutation.""" + for var in _TRUST_ENV_VARS: + monkeypatch.delenv(var, raising=False) + original_ssl_context = ssl.SSLContext + try: + yield + finally: + try: + import truststore + + truststore.extract_from_ssl() + except Exception: + pass + ssl.SSLContext = original_ssl_context + + +def test_untrusted_custom_ca_is_rejected(custom_ca_server): + # Default trust (certifi) must reject a cert signed by an unknown CA. + with pytest.raises(requests.exceptions.SSLError): + requests.get(custom_ca_server.url, timeout=5) + + +def test_verify_with_ca_path_succeeds(custom_ca_server): + # Sanity: the minted chain is valid when the CA is explicitly trusted. + resp = requests.get(custom_ca_server.url, verify=custom_ca_server.ca_path, timeout=5) + assert resp.status_code == 200 + assert resp.text == "ok" + + +def test_explicit_ca_bundle_env_is_honored(custom_ca_server, monkeypatch): + monkeypatch.setenv("REQUESTS_CA_BUNDLE", custom_ca_server.ca_path) + + # An explicit bundle must win: we skip truststore injection... + assert configure_tls_trust() is False + # ...and requests verifies against it, so the request succeeds. + resp = requests.get(custom_ca_server.url, timeout=5) + assert resp.status_code == 200 + + +def test_truststore_injection_keeps_verification_on(custom_ca_server): + # With no explicit bundle, we inject the OS trust store. + assert configure_tls_trust() is True + # The private CA is in no OS store, so verification must still fail -- + # injection routes trust to the OS store, it does not disable it. + with pytest.raises(requests.exceptions.SSLError): + requests.get(custom_ca_server.url, timeout=5) From b5826a0670a917b54de567cd47fcc838b6fc2a7b Mon Sep 17 00:00:00 2001 From: fangkangmi Date: Fri, 3 Jul 2026 14:24:25 +0100 Subject: [PATCH 03/27] fix(tls): don't let SSL_CERT_FILE suppress OS-trust injection (#2004) Review follow-up. The frozen binary's runtime hook (build/hooks/runtime_hook_ssl_certs.py) sets SSL_CERT_FILE to the bundled certifi before app code runs. configure_tls_trust() treated any SSL_CERT_FILE as an explicit override and skipped truststore -- so OS-trust injection was a no-op in exactly the shipped artifact this feature targets. requests only consults REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE for its verify bundle (not SSL_CERT_FILE), so: - Drop SSL_CERT_FILE from the override set; injection now runs in the frozen binary, and a lone SSL_CERT_FILE no longer misleadingly "wins". - Broaden the truststore import guard from ImportError to Exception so a broken/incompatible install degrades to certifi instead of crashing startup. - Correct the docs and changelog (REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE are the HTTP-layer overrides; SSL_CERT_FILE is not read by requests). - Add a unit regression guard (SSL_CERT_FILE set still injects) and cover CURL_CA_BUNDLE in the integration precedence test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/troubleshooting/ssl-issues.md | 2 +- src/apm_cli/core/tls_trust.py | 22 ++++++++++++++----- tests/integration/test_tls_custom_ca.py | 6 +++-- tests/unit/core/test_tls_trust.py | 13 ++++++++++- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index 5b0f51ca8..7ca95a7ba 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -58,7 +58,7 @@ APM verifies HTTPS against the **operating-system trust store** by default (via You only need the steps below when the CA is *not* in the OS store, or you want to pin a specific bundle: -- Setting `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, or `SSL_CERT_FILE` makes APM honour that bundle instead of the OS store. +- Setting `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` makes APM's HTTP layer verify against that bundle instead of the OS store. (`SSL_CERT_FILE` configures the stdlib `ssl` layer but is *not* read by `requests`, so on its own it does not override the HTTP path — use `REQUESTS_CA_BUNDLE` for that.) - `APM_DISABLE_TRUSTSTORE=1` restores the legacy behaviour (verify against APM's bundled `certifi` set only). ## Configure trust diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index 2e077b099..0be06aa36 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -31,10 +31,17 @@ logger = logging.getLogger(__name__) -# Env vars through which a user pins an explicit CA bundle. When any is set we -# respect that choice rather than silently redirecting verification to the OS -# trust store (which could ignore a deliberately narrow, air-gapped bundle). -_EXPLICIT_CA_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE") +# Env vars ``requests`` actually consults for its CA bundle (via +# ``Session.merge_environment_settings``). When one is set the user has pinned a +# specific bundle for the HTTP path, so we honour it verbatim instead of +# redirecting to the OS store. +# +# ``SSL_CERT_FILE`` is deliberately NOT in this set: ``requests`` does not read +# it, and the PyInstaller runtime hook (build/hooks/runtime_hook_ssl_certs.py) +# sets it to the bundled certifi in the frozen binary. Treating it as an +# override would silently disable OS-trust injection in exactly the shipped +# artifact this feature targets. +_EXPLICIT_CA_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE") # Escape hatch: set truthy to force the legacy certifi-only behaviour. _DISABLE_ENV_VAR = "APM_DISABLE_TRUSTSTORE" @@ -66,8 +73,11 @@ def configure_tls_trust() -> bool: try: import truststore - except ImportError: - logger.debug("truststore not installed; verifying TLS against bundled certifi") + except Exception as exc: + # Usually ImportError (not bundled), but a broken or platform-incompatible + # install can raise other errors at import time. Degrade rather than let + # TLS setup crash startup. + logger.debug("truststore unavailable (%s); verifying TLS against bundled certifi", exc) return False try: diff --git a/tests/integration/test_tls_custom_ca.py b/tests/integration/test_tls_custom_ca.py index 5c6935023..e829137a3 100644 --- a/tests/integration/test_tls_custom_ca.py +++ b/tests/integration/test_tls_custom_ca.py @@ -150,8 +150,10 @@ def test_verify_with_ca_path_succeeds(custom_ca_server): assert resp.text == "ok" -def test_explicit_ca_bundle_env_is_honored(custom_ca_server, monkeypatch): - monkeypatch.setenv("REQUESTS_CA_BUNDLE", custom_ca_server.ca_path) +@pytest.mark.parametrize("env_var", ["REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"]) +def test_explicit_ca_bundle_env_is_honored(custom_ca_server, monkeypatch, env_var): + # Both env vars requests consults must win end-to-end. + monkeypatch.setenv(env_var, custom_ca_server.ca_path) # An explicit bundle must win: we skip truststore injection... assert configure_tls_trust() is False diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index ce0e9ccf7..1fa86e6a0 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -21,7 +21,7 @@ configure_tls_trust, ) -_ALL_TRUST_ENV = (_DISABLE_ENV_VAR, *_EXPLICIT_CA_ENV_VARS) +_ALL_TRUST_ENV = (_DISABLE_ENV_VAR, "SSL_CERT_FILE", *_EXPLICIT_CA_ENV_VARS) @pytest.fixture(autouse=True) @@ -61,6 +61,17 @@ def test_explicit_ca_bundle_wins(monkeypatch, var): assert calls["n"] == 0 +def test_ssl_cert_file_does_not_suppress_injection(monkeypatch): + # SSL_CERT_FILE is not a requests CA override and IS set by the frozen-binary + # runtime hook (to bundled certifi). It must NOT disable OS-trust injection, + # or the feature becomes a no-op in the shipped artifact. + calls = _install_fake_truststore(monkeypatch) + monkeypatch.setenv("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt") + + assert configure_tls_trust() is True + assert calls["n"] == 1 + + def test_missing_truststore_falls_back(monkeypatch): # A None entry in sys.modules makes ``import truststore`` raise ImportError. monkeypatch.setitem(sys.modules, "truststore", None) From 188b2ccedd880c7eab892c845cedff95d43629c3 Mon Sep 17 00:00:00 2001 From: fangkangmi Date: Fri, 3 Jul 2026 14:28:50 +0100 Subject: [PATCH 04/27] test(tls): drive the real frozen SSL runtime hook (#2004) Regression guard for the no-op found in review: execute the actual build/hooks/runtime_hook_ssl_certs.py under a simulated frozen process and assert configure_tls_trust() still injects truststore when the hook has pinned SSL_CERT_FILE to the bundled certifi -- and that a user-set REQUESTS_CA_BUNDLE still wins (no injection) in the same frozen state. Uses a manual env/ssl/sys.frozen snapshot fixture because the hook mutates os.environ directly, which monkeypatch would not track. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration/test_tls_frozen_hook.py | 95 +++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/integration/test_tls_frozen_hook.py diff --git a/tests/integration/test_tls_frozen_hook.py b/tests/integration/test_tls_frozen_hook.py new file mode 100644 index 000000000..93cfee373 --- /dev/null +++ b/tests/integration/test_tls_frozen_hook.py @@ -0,0 +1,95 @@ +"""Regression test: the frozen-binary SSL runtime hook must not disable OS-trust injection. + +The PyInstaller build wires ``build/hooks/runtime_hook_ssl_certs.py``, which runs +before application code in the frozen binary and sets ``SSL_CERT_FILE`` to the +bundled certifi bundle (to fix the compiled-in OpenSSL cert path, see #428). + +``configure_tls_trust()`` must still inject ``truststore`` in that state. +Otherwise, because the hook sets ``SSL_CERT_FILE``, treating that var as an +explicit override would make OS-trust verification a silent no-op in exactly the +shipped artifact this feature exists for. This test drives the *real* hook under +a simulated frozen process so the two cannot drift apart. +""" + +from __future__ import annotations + +import importlib.util +import os +import ssl +import sys +from pathlib import Path + +import certifi +import pytest + +from apm_cli.core.tls_trust import configure_tls_trust + +pytestmark = pytest.mark.integration + +_TRUST_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "APM_DISABLE_TRUSTSTORE") +_HOOK_PATH = Path(__file__).resolve().parents[2] / "build" / "hooks" / "runtime_hook_ssl_certs.py" + + +@pytest.fixture(autouse=True) +def _isolate_trust(): + """Snapshot/restore trust env, sys.frozen, and the global ssl.SSLContext. + + Manual snapshot (not monkeypatch) because the runtime hook mutates + ``os.environ`` directly, which monkeypatch would not track or undo. + """ + saved_env = {var: os.environ.get(var) for var in _TRUST_ENV_VARS} + for var in _TRUST_ENV_VARS: + os.environ.pop(var, None) + saved_ctx = ssl.SSLContext + had_frozen = hasattr(sys, "frozen") + saved_frozen = getattr(sys, "frozen", None) + try: + yield + finally: + try: + import truststore + + truststore.extract_from_ssl() + except Exception: + pass + ssl.SSLContext = saved_ctx + if had_frozen: + sys.frozen = saved_frozen + elif hasattr(sys, "frozen"): + del sys.frozen + for var, value in saved_env.items(): + if value is None: + os.environ.pop(var, None) + else: + os.environ[var] = value + + +def _run_runtime_hook(): + """Execute the real PyInstaller SSL runtime hook (runs _configure_ssl_certs).""" + spec = importlib.util.spec_from_file_location("runtime_hook_ssl_certs", _HOOK_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_frozen_hook_ssl_cert_file_does_not_disable_injection(): + sys.frozen = True + _run_runtime_hook() + + # The hook pinned SSL_CERT_FILE to the bundled certifi... + assert os.environ.get("SSL_CERT_FILE") == certifi.where() + # ...and that must NOT suppress OS-trust injection in the frozen binary. + assert configure_tls_trust() is True + assert "truststore" in ssl.SSLContext.__module__ + + +def test_user_override_still_wins_under_frozen_hook(): + # A user-pinned REQUESTS_CA_BUNDLE makes the hook leave SSL_CERT_FILE unset, + # and we must honour that bundle rather than inject the OS store. + os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() + sys.frozen = True + _run_runtime_hook() + + assert os.environ.get("SSL_CERT_FILE") is None + assert configure_tls_trust() is False + assert "truststore" not in ssl.SSLContext.__module__ From bbb413413ca66e88a43fef7d326275a3fa2b7b6f Mon Sep 17 00:00:00 2001 From: fangkangmi Date: Fri, 3 Jul 2026 14:35:43 +0100 Subject: [PATCH 05/27] docs(tls): tighten comments to repo style, fix stale SSL_CERT_FILE docstring Trim the module docstring and inline comments to match the density of comparable core modules; drop two comments that restated the code. Also correct the docstring, which still listed SSL_CERT_FILE among the overrides that "win" after it was removed from the skip set. Keep the frozen-hook and never-raises rationale (load-bearing, matches validation.py's TLS comments). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apm_cli/core/tls_trust.py | 61 ++++++++++++----------------------- 1 file changed, 21 insertions(+), 40 deletions(-) diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index 0be06aa36..ec83a74ca 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -1,27 +1,17 @@ -"""Default TLS trust configuration for APM's HTTP layer. - -By default ``requests`` verifies HTTPS against the bundled ``certifi`` CA set, -which does **not** contain internal/corporate root CAs or the certificates -injected by a TLS-inspecting proxy (Zscaler, Netskope, Palo Alto, ...). APM -also shells out to ``git``, which reads the OS trust store, so ``git clone`` -of an internal host succeeds while APM's ``requests``-based Contents API calls -fail against the *same* certificate chain -- a confusing, inconsistent -failure for enterprise users. - -This module opts APM into the OS trust store via `truststore -`_ so both paths verify against the -same source with zero per-shell configuration. It is deliberately -best-effort: - -* If the user has pinned an explicit CA bundle (``REQUESTS_CA_BUNDLE`` / - ``CURL_CA_BUNDLE`` / ``SSL_CERT_FILE``), that choice wins and we do not - override it. -* If ``truststore`` is unavailable or injection raises for any reason, APM - silently falls back to the previous ``certifi`` behaviour. -* ``APM_DISABLE_TRUSTSTORE`` forces the old behaviour as an escape hatch. - -``configure_tls_trust`` never raises: TLS setup must not be able to crash CLI -startup. +"""Verify HTTPS against the OS trust store by default. + +``requests`` verifies against the bundled ``certifi`` set, which lacks +internal/corporate root CAs and TLS-proxy certs. Because APM also shells out to +``git`` (which reads the OS trust store), ``git clone`` of an internal host +succeeds while APM's ``requests`` calls fail on the same chain. This routes +``requests`` through the OS store via ``truststore`` so the two agree, with no +per-shell config. + +Best-effort -- ``configure_tls_trust`` never raises: + +* An explicit ``REQUESTS_CA_BUNDLE`` / ``CURL_CA_BUNDLE`` wins (no injection). +* Missing ``truststore`` or a failed injection falls back to ``certifi``. +* ``APM_DISABLE_TRUSTSTORE`` forces the legacy ``certifi``-only behaviour. """ from __future__ import annotations @@ -31,16 +21,11 @@ logger = logging.getLogger(__name__) -# Env vars ``requests`` actually consults for its CA bundle (via -# ``Session.merge_environment_settings``). When one is set the user has pinned a -# specific bundle for the HTTP path, so we honour it verbatim instead of -# redirecting to the OS store. -# -# ``SSL_CERT_FILE`` is deliberately NOT in this set: ``requests`` does not read -# it, and the PyInstaller runtime hook (build/hooks/runtime_hook_ssl_certs.py) -# sets it to the bundled certifi in the frozen binary. Treating it as an -# override would silently disable OS-trust injection in exactly the shipped -# artifact this feature targets. +# The CA-bundle env vars ``requests`` honours (via merge_environment_settings); +# when one is set, respect that pinned bundle and skip injection. SSL_CERT_FILE +# is excluded on purpose: requests ignores it, and the frozen binary's runtime +# hook sets it to the bundled certifi -- treating it as an override would make +# injection a no-op in the shipped artifact. _EXPLICIT_CA_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE") # Escape hatch: set truthy to force the legacy certifi-only behaviour. @@ -67,22 +52,18 @@ def configure_tls_trust() -> bool: explicit = next((var for var in _EXPLICIT_CA_ENV_VARS if os.environ.get(var)), None) if explicit: - # The user asked for a specific bundle; honour it verbatim. logger.debug("Explicit CA bundle set via %s; leaving certifi/verify path intact", explicit) return False try: + # Broad except: a broken/incompatible install can fail at import, not + # only with ImportError -- degrade instead of crashing startup. import truststore except Exception as exc: - # Usually ImportError (not bundled), but a broken or platform-incompatible - # install can raise other errors at import time. Degrade rather than let - # TLS setup crash startup. logger.debug("truststore unavailable (%s); verifying TLS against bundled certifi", exc) return False try: - # Broad by design: trust setup must never crash CLI startup, so any - # failure degrades to the certifi default rather than propagating. truststore.inject_into_ssl() except Exception as exc: logger.debug("truststore.inject_into_ssl() failed (%s); falling back to certifi", exc) From 1756d1a6aa8709ccab90bcc835331bf72828cec4 Mon Sep 17 00:00:00 2001 From: fangkangmi Date: Fri, 3 Jul 2026 14:55:01 +0100 Subject: [PATCH 06/27] docs(tls): address PR review feedback - test_tls_trust docstring: drop the stale SSL_CERT_FILE-suppresses claim and note it explicitly does NOT suppress injection (matches the code + its test). - ssl-issues.md / CHANGELOG: normalise em dashes to ASCII "--" to match the surrounding docs. - CHANGELOG: reference the PR, "(closes #2004) (#2005)", per repo convention. --- CHANGELOG.md | 4 ++-- docs/src/content/docs/troubleshooting/ssl-issues.md | 4 ++-- tests/unit/core/test_tls_trust.py | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4175ae550..5e85f1aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,11 +37,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `apm update`. (#1974) - APM now verifies HTTPS against the OS trust store by default (via `truststore`), so it works out-of-the-box behind a corporate CA or a - TLS-inspecting proxy, matching the behaviour of `git`/`curl` on the same + TLS-inspecting proxy -- matching the behaviour of `git`/`curl` on the same host instead of failing against the bundled `certifi` set. An explicitly set `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` still wins, and `APM_DISABLE_TRUSTSTORE=1` restores the previous certifi-only behaviour. - Falls back to `certifi` if `truststore` is unavailable. (#2004) + Falls back to `certifi` if `truststore` is unavailable. (closes #2004) (#2005) ### Fixed diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index 7ca95a7ba..f3201a260 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -54,11 +54,11 @@ apm install --verbose ## Default behaviour: the OS trust store -APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. So if your corporate CA or TLS-proxy root is installed in the OS trust store (Keychain on macOS, `update-ca-certificates`/`update-ca-trust` on Linux, the Trusted Root store on Windows), APM picks it up with **no configuration** — importing the CA at the OS level is the recommended fix. +APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. So if your corporate CA or TLS-proxy root is installed in the OS trust store (Keychain on macOS, `update-ca-certificates`/`update-ca-trust` on Linux, the Trusted Root store on Windows), APM picks it up with **no configuration** -- importing the CA at the OS level is the recommended fix. You only need the steps below when the CA is *not* in the OS store, or you want to pin a specific bundle: -- Setting `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` makes APM's HTTP layer verify against that bundle instead of the OS store. (`SSL_CERT_FILE` configures the stdlib `ssl` layer but is *not* read by `requests`, so on its own it does not override the HTTP path — use `REQUESTS_CA_BUNDLE` for that.) +- Setting `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` makes APM's HTTP layer verify against that bundle instead of the OS store. (`SSL_CERT_FILE` configures the stdlib `ssl` layer but is *not* read by `requests`, so on its own it does not override the HTTP path -- use `REQUESTS_CA_BUNDLE` for that.) - `APM_DISABLE_TRUSTSTORE=1` restores the legacy behaviour (verify against APM's bundled `certifi` set only). ## Configure trust diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index 1fa86e6a0..cc3223f70 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -2,7 +2,8 @@ Covers every branch: - opt-out via APM_DISABLE_TRUSTSTORE -- explicit CA bundle env vars win (REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE / SSL_CERT_FILE) +- explicit CA bundle env vars win (REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE) +- SSL_CERT_FILE does NOT suppress injection (it is set by the frozen runtime hook) - truststore missing -> graceful certifi fallback - injection failure -> graceful certifi fallback - happy path -> inject_into_ssl called exactly once From bc27114af265b04b7a5555651c5923a6ca98a2c1 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 10:05:06 +0200 Subject: [PATCH 07/27] fix(tls): fold OS trust follow-ups Updates the TLS failure guidance, truststore floor, override detection seam, docs, and import-timing regression coverage for the OS trust-store feature. Addresses the shepherd-driver fold set from the comparative review of #2005 and #2022. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Ching Wei Kang --- .../docs/troubleshooting/common-errors.md | 9 +- .../docs/troubleshooting/ssl-issues.md | 11 ++- .../.apm/skills/apm-usage/troubleshooting.md | 1 + pyproject.toml | 2 +- src/apm_cli/cli.py | 25 ++++-- src/apm_cli/core/tls_trust.py | 28 ++++--- src/apm_cli/install/validation.py | 7 +- tests/integration/test_tls_custom_ca.py | 66 +++++++++++++-- tests/integration/test_tls_frozen_hook.py | 7 +- tests/unit/core/test_tls_trust.py | 83 ++++++++++++++++--- uv.lock | 2 +- 11 files changed, 188 insertions(+), 53 deletions(-) diff --git a/docs/src/content/docs/troubleshooting/common-errors.md b/docs/src/content/docs/troubleshooting/common-errors.md index f43335b74..e2d3ecfcc 100644 --- a/docs/src/content/docs/troubleshooting/common-errors.md +++ b/docs/src/content/docs/troubleshooting/common-errors.md @@ -241,14 +241,15 @@ See also: [./install-failures/](./install-failures/) ### `TLS verification failed` ``` -TLS verification failed -- if you're behind a corporate proxy or -firewall, set the REQUESTS_CA_BUNDLE environment variable to the -path of your organisation's CA bundle (a PEM file) and retry. +TLS verification failed -- APM uses the system trust store by default. +If you're behind a corporate proxy or firewall, make sure your +organisation's CA is installed in the OS trust store, or set +REQUESTS_CA_BUNDLE to a readable PEM bundle and retry. ``` Cause: Python's TLS stack rejected the server certificate. Almost always a corporate proxy doing TLS interception with a CA that is not in the system trust store. -Fix: export `REQUESTS_CA_BUNDLE=/path/to/corporate-ca.pem` and retry. Do not disable TLS verification. +Fix: install the corporate CA into the OS trust store and retry. For a per-shell override, export `REQUESTS_CA_BUNDLE=/path/to/corporate-ca.pem`; `SSL_CERT_FILE` alone is not a reliable requests override. Do not disable TLS verification. See also: [./ssl-issues/](./ssl-issues/) diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index f3201a260..c06368b02 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -14,8 +14,10 @@ Related: [environment variables](../reference/environment-variables/), [install Typical errors APM surfaces or passes through from the underlying HTTP/git stack: ```text -[!] TLS verification failed -- if you're behind a corporate proxy or - firewall, set REQUESTS_CA_BUNDLE to your organisation's CA bundle. +[!] TLS verification failed -- APM uses the system trust store by default. + If you're behind a corporate proxy or firewall, make sure your + organisation's CA is installed in the OS trust store, or set + REQUESTS_CA_BUNDLE to a readable PEM bundle and retry. ``` ```text @@ -69,12 +71,9 @@ APM uses `requests` for HTTP and shells out to `git` for repository operations. ```bash export REQUESTS_CA_BUNDLE=/path/to/ca-bundle.pem -# or, more general: -export SSL_CERT_FILE=/path/to/ca-bundle.pem -export SSL_CERT_DIR=/etc/ssl/certs ``` -`REQUESTS_CA_BUNDLE` wins for `requests`. `SSL_CERT_FILE` / `SSL_CERT_DIR` cover the rest of the Python TLS stack. +`REQUESTS_CA_BUNDLE` wins for `requests`. `SSL_CERT_FILE` / `SSL_CERT_DIR` cover parts of the stdlib TLS stack, but on their own they are not reliable overrides for the `requests` HTTP path APM uses. ### Git operations diff --git a/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md b/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md index 2411cfce6..2b6e0e802 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md +++ b/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md @@ -6,6 +6,7 @@ | Authentication errors (401/403) | Set the correct token. Run `apm install --verbose` to see which token source is used. See [Authentication](./authentication.md). | | File collision on install | A local file conflicts with a dependency file. Use `--force` to overwrite, or rename the local file. | | Stale dependencies | Run `apm install --update` to refresh to latest refs. | +| TLS verification failed | Install your corporate CA into the OS trust store. For a per-shell override, set `REQUESTS_CA_BUNDLE=/path/to/ca-bundle.pem`; `SSL_CERT_FILE` alone is not a reliable requests override. | | Orphaned packages in lockfile | Run `apm prune` to remove packages no longer in apm.yml. | | Security findings block install | Run `apm audit` to review findings, then `apm install --force` if acceptable. | | Compilation not picking up changes | Run `apm compile --clean` to remove orphaned output, or `apm compile --watch` for auto-regeneration. | diff --git a/pyproject.toml b/pyproject.toml index 03c2e23c3..fdc3f0be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "colorama>=0.4.6", "pyyaml>=6.0.0", "requests>=2.31.0", - "truststore>=0.9.1", + "truststore>=0.10.0", "python-frontmatter>=1.0.0", "llm>=0.28", "llm-github-models>=0.18.0", diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 111ded11e..2af487a21 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -3,6 +3,8 @@ Thin wiring layer -- all command logic lives in ``apm_cli.commands.*`` modules. """ +# ruff: noqa: E402 + import ctypes import logging import os @@ -11,6 +13,22 @@ import click +from apm_cli.core.tls_trust import configure_tls_trust + +_TLS_TRUST_CONFIGURED = False + + +def _configure_process_tls_trust() -> None: + """Configure process-wide TLS trust before network clients are imported.""" + global _TLS_TRUST_CONFIGURED + if _TLS_TRUST_CONFIGURED: + return + configure_tls_trust() + _TLS_TRUST_CONFIGURED = True + + +_configure_process_tls_trust() + from apm_cli.commands._helpers import ( ERROR, RESET, @@ -333,12 +351,7 @@ def main(): """Main entry point for the CLI.""" _configure_logging() # honours APM_LOG_LEVEL env var; --verbose upgrades in cli() _configure_encoding() - # Verify HTTPS against the OS trust store by default so APM behaves like - # git/curl in corporate-CA / TLS-proxy environments. Best-effort: falls - # back to certifi and never raises. Must run before the first HTTPS call. - from apm_cli.core.tls_trust import configure_tls_trust - - configure_tls_trust() + _configure_process_tls_trust() try: cli(obj={}) except Exception as e: diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index ec83a74ca..01983af5d 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -18,14 +18,16 @@ import logging import os +from collections.abc import Mapping logger = logging.getLogger(__name__) # The CA-bundle env vars ``requests`` honours (via merge_environment_settings); # when one is set, respect that pinned bundle and skip injection. SSL_CERT_FILE -# is excluded on purpose: requests ignores it, and the frozen binary's runtime -# hook sets it to the bundled certifi -- treating it as an override would make -# injection a no-op in the shipped artifact. +# and SSL_CERT_DIR are excluded on purpose: requests ignores those standalone +# variables, and the frozen binary's runtime hook sets SSL_CERT_FILE to bundled +# certifi -- treating either as an override would make injection a no-op in the +# shipped artifact. _EXPLICIT_CA_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE") # Escape hatch: set truthy to force the legacy certifi-only behaviour. @@ -34,11 +36,18 @@ _TRUTHY = {"1", "true", "yes", "on"} -def _env_flag(name: str) -> bool: - return os.environ.get(name, "").strip().lower() in _TRUTHY +def _env_flag(name: str, env: Mapping[str, str] | None = None) -> bool: + environ = os.environ if env is None else env + return environ.get(name, "").strip().lower() in _TRUTHY -def configure_tls_trust() -> bool: +def has_explicit_ca_override(env: Mapping[str, str] | None = None) -> bool: + """Return True when requests has an explicit CA bundle override.""" + environ = os.environ if env is None else env + return any((environ.get(var) or "").strip() for var in _EXPLICIT_CA_ENV_VARS) + + +def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: """Route HTTPS verification through the OS trust store when possible. Call once at process startup, before the first HTTPS request. Returns @@ -46,13 +55,12 @@ def configure_tls_trust() -> bool: ``certifi`` behaviour was left in place (explicit override, opt-out, ``truststore`` missing, or injection failure). Never raises. """ - if _env_flag(_DISABLE_ENV_VAR): + if _env_flag(_DISABLE_ENV_VAR, env): logger.debug("OS trust-store injection disabled via %s", _DISABLE_ENV_VAR) return False - explicit = next((var for var in _EXPLICIT_CA_ENV_VARS if os.environ.get(var)), None) - if explicit: - logger.debug("Explicit CA bundle set via %s; leaving certifi/verify path intact", explicit) + if has_explicit_ca_override(env): + logger.debug("Explicit CA bundle set; leaving certifi/verify path intact") return False try: diff --git a/src/apm_cli/install/validation.py b/src/apm_cli/install/validation.py index 3546f2bd6..1d198c1e8 100644 --- a/src/apm_cli/install/validation.py +++ b/src/apm_cli/install/validation.py @@ -66,9 +66,10 @@ def _log_tls_failure(host_display: str, exc: BaseException, verbose_log, logger) Verbose: also include the host name and the underlying exception text. """ logger.warning( - "TLS verification failed -- if you're behind a corporate proxy or " - "firewall, set the REQUESTS_CA_BUNDLE environment variable to the " - "path of your organisation's CA bundle (a PEM file) and retry. " + "TLS verification failed -- APM uses the system trust store by default. " + "If you're behind a corporate proxy or firewall, make sure your " + "organisation's CA is installed in the OS trust store, or set " + "REQUESTS_CA_BUNDLE to a readable PEM bundle and retry. " "See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" ) if verbose_log: diff --git a/tests/integration/test_tls_custom_ca.py b/tests/integration/test_tls_custom_ca.py index e829137a3..afe0a7e0e 100644 --- a/tests/integration/test_tls_custom_ca.py +++ b/tests/integration/test_tls_custom_ca.py @@ -35,7 +35,12 @@ pytest.mark.skipif(shutil.which("openssl") is None, reason="openssl CLI not available"), ] -_TRUST_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "APM_DISABLE_TRUSTSTORE") +_TRUST_ENV_VARS = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "APM_DISABLE_TRUSTSTORE", +) _CA_CNF = """\ [req] @@ -71,18 +76,61 @@ def _openssl(*args) -> None: def _mint_ca_and_leaf(dirpath): """Generate a private CA and a localhost leaf cert signed by it.""" ca_key, ca_pem = dirpath / "ca.key", dirpath / "ca.pem" - srv_key, srv_csr, srv_pem = dirpath / "server.key", dirpath / "server.csr", dirpath / "server.pem" + srv_key, srv_csr, srv_pem = ( + dirpath / "server.key", + dirpath / "server.csr", + dirpath / "server.pem", + ) ca_cnf, srv_cnf = dirpath / "ca.cnf", dirpath / "server.cnf" ca_cnf.write_text(_CA_CNF) srv_cnf.write_text(_SERVER_CNF) - _openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-keyout", ca_key, "-out", ca_pem, "-days", "2", "-config", ca_cnf) - _openssl("req", "-newkey", "rsa:2048", "-nodes", - "-keyout", srv_key, "-out", srv_csr, "-config", srv_cnf) - _openssl("x509", "-req", "-in", srv_csr, "-CA", ca_pem, "-CAkey", ca_key, - "-CAcreateserial", "-out", srv_pem, "-days", "2", - "-extfile", srv_cnf, "-extensions", "v3_req") + _openssl( + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + ca_key, + "-out", + ca_pem, + "-days", + "2", + "-config", + ca_cnf, + ) + _openssl( + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + srv_key, + "-out", + srv_csr, + "-config", + srv_cnf, + ) + _openssl( + "x509", + "-req", + "-in", + srv_csr, + "-CA", + ca_pem, + "-CAkey", + ca_key, + "-CAcreateserial", + "-out", + srv_pem, + "-days", + "2", + "-extfile", + srv_cnf, + "-extensions", + "v3_req", + ) return ca_pem, srv_pem, srv_key diff --git a/tests/integration/test_tls_frozen_hook.py b/tests/integration/test_tls_frozen_hook.py index 93cfee373..7b412eb14 100644 --- a/tests/integration/test_tls_frozen_hook.py +++ b/tests/integration/test_tls_frozen_hook.py @@ -26,7 +26,12 @@ pytestmark = pytest.mark.integration -_TRUST_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "APM_DISABLE_TRUSTSTORE") +_TRUST_ENV_VARS = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "APM_DISABLE_TRUSTSTORE", +) _HOOK_PATH = Path(__file__).resolve().parents[2] / "build" / "hooks" / "runtime_hook_ssl_certs.py" diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index cc3223f70..c22846fef 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -3,7 +3,7 @@ Covers every branch: - opt-out via APM_DISABLE_TRUSTSTORE - explicit CA bundle env vars win (REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE) -- SSL_CERT_FILE does NOT suppress injection (it is set by the frozen runtime hook) +- SSL_CERT_FILE / SSL_CERT_DIR do NOT suppress injection - truststore missing -> graceful certifi fallback - injection failure -> graceful certifi fallback - happy path -> inject_into_ssl called exactly once @@ -11,8 +11,11 @@ from __future__ import annotations +import os +import subprocess import sys import types +from pathlib import Path import pytest @@ -20,9 +23,11 @@ _DISABLE_ENV_VAR, _EXPLICIT_CA_ENV_VARS, configure_tls_trust, + has_explicit_ca_override, ) -_ALL_TRUST_ENV = (_DISABLE_ENV_VAR, "SSL_CERT_FILE", *_EXPLICIT_CA_ENV_VARS) +_NON_REQUESTS_CA_ENV_VARS = ("SSL_CERT_FILE", "SSL_CERT_DIR") +_ALL_TRUST_ENV = (_DISABLE_ENV_VAR, *_NON_REQUESTS_CA_ENV_VARS, *_EXPLICIT_CA_ENV_VARS) @pytest.fixture(autouse=True) @@ -47,29 +52,30 @@ def _default_inject(): def test_opt_out_disables_injection(monkeypatch): calls = _install_fake_truststore(monkeypatch) - monkeypatch.setenv(_DISABLE_ENV_VAR, "1") - assert configure_tls_trust() is False + assert configure_tls_trust(env={_DISABLE_ENV_VAR: "1"}) is False assert calls["n"] == 0 @pytest.mark.parametrize("var", _EXPLICIT_CA_ENV_VARS) def test_explicit_ca_bundle_wins(monkeypatch, var): calls = _install_fake_truststore(monkeypatch) - monkeypatch.setenv(var, "/etc/ssl/certs/custom-ca.pem") - assert configure_tls_trust() is False + assert has_explicit_ca_override(env={var: "/etc/ssl/certs/custom-ca.pem"}) is True + assert configure_tls_trust(env={var: "/etc/ssl/certs/custom-ca.pem"}) is False assert calls["n"] == 0 -def test_ssl_cert_file_does_not_suppress_injection(monkeypatch): - # SSL_CERT_FILE is not a requests CA override and IS set by the frozen-binary - # runtime hook (to bundled certifi). It must NOT disable OS-trust injection, - # or the feature becomes a no-op in the shipped artifact. +@pytest.mark.parametrize("var", _NON_REQUESTS_CA_ENV_VARS) +def test_non_requests_ca_env_does_not_suppress_injection(monkeypatch, var): + # SSL_CERT_FILE and SSL_CERT_DIR are not requests CA overrides. The frozen + # runtime hook sets SSL_CERT_FILE to bundled certifi, so these vars must not + # disable OS-trust injection in the shipped artifact. calls = _install_fake_truststore(monkeypatch) - monkeypatch.setenv("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt") + env = {var: "/etc/ssl/certs/ca-certificates.crt"} - assert configure_tls_trust() is True + assert has_explicit_ca_override(env=env) is False + assert configure_tls_trust(env=env) is True assert calls["n"] == 1 @@ -94,3 +100,56 @@ def test_happy_path_injects_once(monkeypatch): assert configure_tls_trust() is True assert calls["n"] == 1 + + +def _repo_root() -> Path: + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("Cannot locate repository root") + + +def test_cli_bootstrap_injects_before_requests_import(tmp_path): + sentinel = tmp_path / "sentinel.txt" + fake_truststore = tmp_path / "truststore.py" + fake_truststore.write_text( + "\n".join( + [ + "import os", + "import pathlib", + "import sys", + "", + "def inject_into_ssl():", + " pathlib.Path(os.environ['TRUSTSTORE_SENTINEL']).write_text(", + " 'requests_imported=' + str('requests' in sys.modules),", + " encoding='utf-8',", + " )", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + for name in ( + _DISABLE_ENV_VAR, + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + ): + env.pop(name, None) + env["PYTHONPATH"] = f"{tmp_path}{os.pathsep}{_repo_root() / 'src'}" + env["TRUSTSTORE_SENTINEL"] = str(sentinel) + + result = subprocess.run( + [sys.executable, "-c", "import apm_cli.cli"], + cwd=_repo_root(), + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert sentinel.read_text(encoding="utf-8") == "requests_imported=False" diff --git a/uv.lock b/uv.lock index 9fc54bba2..26b9ac3fd 100644 --- a/uv.lock +++ b/uv.lock @@ -263,7 +263,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, { name = "toml", specifier = ">=0.10.2" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.2.0" }, - { name = "truststore", specifier = ">=0.9.1" }, + { name = "truststore", specifier = ">=0.10.0" }, { name = "watchdog", specifier = ">=3.0.0" }, { name = "websockets", specifier = ">=12,<17" }, ] From d652ee5405653b9e7bfc1b99e960bb33e45a9e01 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 10:11:36 +0200 Subject: [PATCH 08/27] docs(tls): complete trust environment guidance Adds the TLS trust environment variables to the canonical reference, aligns the install-failures page with the OS trust-store default, and locks the CLI TLS bootstrap idempotency guard with a unit regression test. Addresses apm-review-panel doc, DevX, growth, and coverage follow-ups. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../docs/reference/environment-variables.md | 10 ++++ .../docs/troubleshooting/install-failures.md | 2 +- tests/unit/core/test_tls_trust.py | 51 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/reference/environment-variables.md b/docs/src/content/docs/reference/environment-variables.md index a3d609719..a07093255 100644 --- a/docs/src/content/docs/reference/environment-variables.md +++ b/docs/src/content/docs/reference/environment-variables.md @@ -40,6 +40,16 @@ Controls how APM clones packages from Git hosts. These settings can also be pers | `APM_GIT_PROTOCOL` | Preferred clone protocol for shorthand (`owner/repo`) dependencies. Accepted values: `ssh`, `https`. | unset | Equivalent to `--ssh` / `--https` flag. Resolution: CLI flag → env var → `prefer-ssh` key in `~/.apm/config.json` → git `insteadOf` rules → HTTPS. | | `APM_ALLOW_PROTOCOL_FALLBACK` | Set to `1` (or `true`/`yes`/`on`) to enable the legacy cross-protocol fallback chain. When enabled, a failed clone is retried with the opposite protocol. | unset | Equivalent to `--allow-protocol-fallback`. Resolution: CLI flag → env var → `allow-protocol-fallback` key in `~/.apm/config.json` → `false`. | +## TLS trust + +APM verifies HTTPS against the operating-system trust store by default. For the full troubleshooting flow, see [SSL / TLS issues](../troubleshooting/ssl-issues/). + +| Variable | Purpose | Default | Notes | +|---|---|---|---| +| `REQUESTS_CA_BUNDLE` | PEM bundle for APM's Python HTTP requests. | unset | Explicit override; wins over OS trust-store injection. Use for a per-shell corporate CA bundle. | +| `CURL_CA_BUNDLE` | PEM bundle fallback honoured by `requests`. | unset | Explicit override; wins over OS trust-store injection when `REQUESTS_CA_BUNDLE` is unset. | +| `APM_DISABLE_TRUSTSTORE` | Set to `1` (or `true`/`yes`/`on`) to disable OS trust-store injection. | unset | Escape hatch that restores the legacy bundled-`certifi` verification path. | + ## Registry (MCP and proxy) | Variable | Purpose | Default | Notes | diff --git a/docs/src/content/docs/troubleshooting/install-failures.md b/docs/src/content/docs/troubleshooting/install-failures.md index c14f9aeaa..af95c97dc 100644 --- a/docs/src/content/docs/troubleshooting/install-failures.md +++ b/docs/src/content/docs/troubleshooting/install-failures.md @@ -110,7 +110,7 @@ For end-to-end auth setup see [Authentication](../getting-started/authentication [!] TLS verification failed ``` -Behind a corporate proxy, set `REQUESTS_CA_BUNDLE` to your org's CA bundle (PEM file). Full walkthrough: [SSL / TLS issues](./ssl-issues/). +APM verifies HTTPS against the OS trust store by default. Behind a corporate proxy, install your org's CA into the OS trust store; for a per-shell override, set `REQUESTS_CA_BUNDLE` to a readable PEM bundle. Full walkthrough: [SSL / TLS issues](./ssl-issues/). ### Timeouts and proxies diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index c22846fef..93a085041 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -153,3 +153,54 @@ def test_cli_bootstrap_injects_before_requests_import(tmp_path): assert result.returncode == 0, result.stderr assert sentinel.read_text(encoding="utf-8") == "requests_imported=False" + + +def test_cli_bootstrap_is_idempotent_across_import_and_main(tmp_path): + sentinel = tmp_path / "sentinel.txt" + fake_truststore = tmp_path / "truststore.py" + fake_truststore.write_text( + "\n".join( + [ + "import os", + "import pathlib", + "", + "def inject_into_ssl():", + " path = pathlib.Path(os.environ['TRUSTSTORE_SENTINEL'])", + " count = int(path.read_text(encoding='utf-8') or '0') if path.exists() else 0", + " path.write_text(str(count + 1), encoding='utf-8')", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + for name in ( + _DISABLE_ENV_VAR, + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + ): + env.pop(name, None) + env["PYTHONPATH"] = f"{tmp_path}{os.pathsep}{_repo_root() / 'src'}" + env["TRUSTSTORE_SENTINEL"] = str(sentinel) + + result = subprocess.run( + [ + sys.executable, + "-c", + "import apm_cli.cli as c\n" + "def fake_cli(*, obj):\n" + " return None\n" + "c.cli = fake_cli\n" + "c.main()\n", + ], + cwd=_repo_root(), + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert sentinel.read_text(encoding="utf-8") == "1" From 93a3692f512aab8cf76e89f1d0ab76b4bf420c30 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 10:14:21 +0200 Subject: [PATCH 09/27] docs(tls): link enterprise proxy TLS guidance Adds the OS trust-store TLS troubleshooting entry to the enterprise registry-proxy page so corporate proxy users reach the same CA guidance from the page they are likely to read first. Addresses the final growth-panel nit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/enterprise/registry-proxy.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/content/docs/enterprise/registry-proxy.md b/docs/src/content/docs/enterprise/registry-proxy.md index 164ac178f..f495179f3 100644 --- a/docs/src/content/docs/enterprise/registry-proxy.md +++ b/docs/src/content/docs/enterprise/registry-proxy.md @@ -278,6 +278,7 @@ and `apm cache clean`. | `ERROR: ... locked to direct VCS hosts` | Lockfile predates the proxy | `apm install --update` | | HTTP 401/403 from the proxy | Missing or invalid `PROXY_REGISTRY_TOKEN` | Verify the token has read on the upstream repo path | | `git clone` hangs through the proxy | `HTTPS_PROXY` not set in the env that runs `git` | Export it in the shell that invokes `apm install`; CI secrets often miss this | +| `TLS verification failed` | Corporate proxy CA is not trusted by the OS store | Install the CA into the OS trust store, or set `REQUESTS_CA_BUNDLE`; see [SSL / TLS issues](../troubleshooting/ssl-issues/) | | `DeprecationWarning: ARTIFACTORY_BASE_URL is deprecated` | Legacy env names | Rename to `PROXY_REGISTRY_*` | | Plaintext-token warning on proxy startup | Token sent over `http://` | Use `https://`, or set `PROXY_REGISTRY_ALLOW_HTTP=1` if the link is internal-only | | `Invalid zip archive` with a body that starts `` and is ~17KB | Upstream returned a sign-in page; proxy cached the HTML | Configure upstream credentials on the registry remote, purge the cache, then refetch | @@ -293,5 +294,6 @@ connected host with `apm pack` and restore offline. See - [Authentication](../consumer/authentication/) -- token resolution order - [Private and org packages](../consumer/private-and-org-packages/) -- per-host PAT scoping +- [SSL / TLS issues](../troubleshooting/ssl-issues/) -- corporate CA and TLS proxy trust - [Pack and distribute](../producer/pack-a-bundle/) -- air-gapped bundle delivery - [Governance deep-dive](./governance-guide/) -- policy-cache offline story From 503e698fa8ab6d799bbecade3f3f3110c2148b21 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 10:16:01 +0200 Subject: [PATCH 10/27] test(tls): isolate custom CA server SSL context Ensures the private-CA loopback server creates its server-side SSL context from the stdlib context even when broader integration collection has already imported the CLI and installed truststore globally. This keeps the end-to-end TLS test runnable alongside install and marketplace integration coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/integration/test_tls_custom_ca.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integration/test_tls_custom_ca.py b/tests/integration/test_tls_custom_ca.py index afe0a7e0e..2f222e221 100644 --- a/tests/integration/test_tls_custom_ca.py +++ b/tests/integration/test_tls_custom_ca.py @@ -148,6 +148,12 @@ def log_message(self, *_args): # silence per-request stderr logging @pytest.fixture(scope="module") def custom_ca_server(tmp_path_factory): """A loopback HTTPS server presenting a leaf signed by a private CA.""" + try: + import truststore + + truststore.extract_from_ssl() + except Exception: + pass dirpath = tmp_path_factory.mktemp("tls_custom_ca") ca_pem, srv_pem, srv_key = _mint_ca_and_leaf(dirpath) From dc28260426b1366cd3d5b7219953c11f9ba2267a Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 11:39:28 +0200 Subject: [PATCH 11/27] fix(tls): use OS store in frozen binary, add trust-source diagnostics, add child-env shim B2: the frozen runtime hook set SSL_CERT_FILE=certifi.where() before app code, and truststore's Linux backend honors SSL_CERT_FILE via set_default_verify_paths, so the injected context loaded certifi instead of the OS store. The hook now also sets APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT=1 to mark that WE set the default. configure_tls_trust pops that bundled SSL_CERT_FILE before inject_into_ssl so the system default is read, restores it on injection failure (never zero trust), and clears the marker so it does not leak into children. A genuine user SSL_CERT_FILE (no marker) is left untouched. H2: each branch now emits one ASCII-only, greppable trust-source line via the module logger (debug): OS trust store, certifi fallback, explicit CA bundle, or disabled. B1 (core): add build_child_tls_env() and a silent, never-raise sitecustomize shim under apm_cli/core/_child_tls/. build_child_tls_env prepends the shim dir to a child PYTHONPATH so each Python child re-runs configure_tls_trust at its own interpreter startup (single source of truth; no logic duplicated). The shim ships in the frozen binary via apm.spec datas. Docs + CHANGELOG updated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 12 +- build/apm.spec | 5 + build/hooks/runtime_hook_ssl_certs.py | 5 + .../docs/troubleshooting/ssl-issues.md | 4 +- src/apm_cli/core/_child_tls/__init__.py | 9 ++ src/apm_cli/core/_child_tls/sitecustomize.py | 16 +++ src/apm_cli/core/tls_trust.py | 118 +++++++++++++++++- 7 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 src/apm_cli/core/_child_tls/__init__.py create mode 100644 src/apm_cli/core/_child_tls/sitecustomize.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e85f1aa2..f1970626c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,12 +36,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 during cache operations, cutting process-spawn overhead on `apm install` and `apm update`. (#1974) - APM now verifies HTTPS against the OS trust store by default (via - `truststore`), so it works out-of-the-box behind a corporate CA or a - TLS-inspecting proxy -- matching the behaviour of `git`/`curl` on the same - host instead of failing against the bundled `certifi` set. An explicitly set + `truststore`) for both `apm install` and `apm run` (child runtimes), so it + works out-of-the-box behind a corporate CA or a TLS-inspecting proxy -- + matching the behaviour of `git`/`curl` on the same host instead of failing + against the bundled `certifi` set. The frozen binary now honours the system + store as well, with `certifi` as a genuine fallback. An explicitly set `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` still wins, and `APM_DISABLE_TRUSTSTORE=1` restores the previous certifi-only behaviour. - Falls back to `certifi` if `truststore` is unavailable. (closes #2004) (#2005) + Known limitations / fast-follow: an additive `APM_EXTRA_CA_BUNDLE` is not yet + supported, and Windows `schannel` trust has caveats; Node-based runtimes are + not covered by the child-shim propagation. (closes #2004) (#2005) ### Fixed diff --git a/build/apm.spec b/build/apm.spec index 1fcb73f02..ecbdeed71 100644 --- a/build/apm.spec +++ b/build/apm.spec @@ -88,6 +88,11 @@ entry_point = repo_root / 'src' / 'apm_cli' / 'cli.py' datas = [ (str(repo_root / 'scripts' / 'runtime'), 'scripts/runtime'), # Bundle runtime setup scripts (str(repo_root / 'pyproject.toml'), '.'), # Bundle pyproject.toml for version reading + # Child-runtime TLS trust shim: ships at apm_cli/core/_child_tls/ so + # build_child_tls_env() can prepend it to a child's PYTHONPATH in the + # frozen binary, letting Python child runtimes re-run the OS-trust bootstrap. + (str(repo_root / 'src' / 'apm_cli' / 'core' / '_child_tls' / 'sitecustomize.py'), + 'apm_cli/core/_child_tls'), ] # Bundle platform-appropriate token helper diff --git a/build/hooks/runtime_hook_ssl_certs.py b/build/hooks/runtime_hook_ssl_certs.py index 4b6462d2f..afc505549 100644 --- a/build/hooks/runtime_hook_ssl_certs.py +++ b/build/hooks/runtime_hook_ssl_certs.py @@ -32,6 +32,11 @@ def _configure_ssl_certs() -> None: ca_bundle = certifi.where() if os.path.isfile(ca_bundle): os.environ["SSL_CERT_FILE"] = ca_bundle + # Marker: record that WE set this default (vs a genuine user + # SSL_CERT_FILE). apm_cli.core.tls_trust reads this to know it may + # pop SSL_CERT_FILE before truststore injection so the OS store is + # used, restoring it only if injection fails (certifi fallback). + os.environ["APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT"] = "1" except Exception: # certifi unavailable or broken -- fall through to system defaults. pass diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index c06368b02..5ed540006 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -56,13 +56,15 @@ apm install --verbose ## Default behaviour: the OS trust store -APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. So if your corporate CA or TLS-proxy root is installed in the OS trust store (Keychain on macOS, `update-ca-certificates`/`update-ca-trust` on Linux, the Trusted Root store on Windows), APM picks it up with **no configuration** -- importing the CA at the OS level is the recommended fix. +APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. This applies to both `apm install` and `apm run` -- the child runtimes APM spawns (for example the `llm` and `codex` CLIs) re-run the same OS-trust bootstrap at their own startup. So if your corporate CA or TLS-proxy root is installed in the OS trust store (Keychain on macOS, `update-ca-certificates`/`update-ca-trust` on Linux, the Trusted Root store on Windows), APM picks it up with **no configuration** -- importing the CA at the OS level is the recommended fix. The standalone frozen binary honours the system store as well, falling back to its bundled `certifi` set only when the OS store is unavailable. You only need the steps below when the CA is *not* in the OS store, or you want to pin a specific bundle: - Setting `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` makes APM's HTTP layer verify against that bundle instead of the OS store. (`SSL_CERT_FILE` configures the stdlib `ssl` layer but is *not* read by `requests`, so on its own it does not override the HTTP path -- use `REQUESTS_CA_BUNDLE` for that.) - `APM_DISABLE_TRUSTSTORE=1` restores the legacy behaviour (verify against APM's bundled `certifi` set only). +Known limitations (fast-follow): an additive `APM_EXTRA_CA_BUNDLE` (trust the OS store *and* an extra bundle) is not yet available -- use `REQUESTS_CA_BUNDLE` to pin a single bundle for now. On Windows, the `schannel` backend has trust caveats. Node-based child runtimes are not covered by the OS-trust propagation and continue to use their own default trust. + ## Configure trust APM uses `requests` for HTTP and shells out to `git` for repository operations. Both honour standard environment variables. Set them at the shell or in your profile (`~/.zshrc`, `~/.bashrc`, or the Windows user environment). diff --git a/src/apm_cli/core/_child_tls/__init__.py b/src/apm_cli/core/_child_tls/__init__.py new file mode 100644 index 000000000..2c22d48a1 --- /dev/null +++ b/src/apm_cli/core/_child_tls/__init__.py @@ -0,0 +1,9 @@ +"""Child-process TLS trust shim package. + +Holds ``sitecustomize.py``, which Python auto-imports at interpreter startup +from any directory on ``sys.path`` / ``PYTHONPATH``. ``build_child_tls_env`` +in ``apm_cli.core.tls_trust`` prepends this directory to a child's +``PYTHONPATH`` so each Python runtime re-runs the OS-trust bootstrap in its +own process. The ``__init__`` exists only so the directory ships as package +data; the shim itself is imported by path, not as a submodule. +""" diff --git a/src/apm_cli/core/_child_tls/sitecustomize.py b/src/apm_cli/core/_child_tls/sitecustomize.py new file mode 100644 index 000000000..ff4c80502 --- /dev/null +++ b/src/apm_cli/core/_child_tls/sitecustomize.py @@ -0,0 +1,16 @@ +# APM child-runtime TLS trust shim. +# +# Python auto-imports ``sitecustomize`` at interpreter startup from any +# directory on sys.path / PYTHONPATH. apm_cli.core.tls_trust.build_child_tls_env +# prepends this directory to a child runtime's PYTHONPATH so the child re-runs +# the OS-trust bootstrap in its own process (the parent cannot monkeypatch a +# child's ssl module across exec()). +# +# Must stay SILENT (write nothing to stdout/stderr) and never raise -- a broken +# bootstrap must not disturb the child runtime's own output or startup. +try: + from apm_cli.core.tls_trust import configure_tls_trust + + configure_tls_trust() +except Exception: + pass diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index 01983af5d..bb1ca83a5 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -12,13 +12,21 @@ * An explicit ``REQUESTS_CA_BUNDLE`` / ``CURL_CA_BUNDLE`` wins (no injection). * Missing ``truststore`` or a failed injection falls back to ``certifi``. * ``APM_DISABLE_TRUSTSTORE`` forces the legacy ``certifi``-only behaviour. + +Child runtimes (``llm``, ``codex``, ``copilot``, ...) are Python/CLI +subprocesses spawned after ``exec``; the parent cannot monkeypatch their +``ssl`` module. ``build_child_tls_env`` propagates trust by prepending a +``sitecustomize`` shim directory to the child ``PYTHONPATH`` so each Python +child re-runs :func:`configure_tls_trust` at its own interpreter startup. """ from __future__ import annotations import logging import os -from collections.abc import Mapping +import sys +from collections.abc import Mapping, MutableMapping +from pathlib import Path logger = logging.getLogger(__name__) @@ -33,6 +41,18 @@ # Escape hatch: set truthy to force the legacy certifi-only behaviour. _DISABLE_ENV_VAR = "APM_DISABLE_TRUSTSTORE" +# stdlib ``ssl`` CA-file variable. truststore's Linux backend calls +# ``ctx.set_default_verify_paths()`` which honours SSL_CERT_FILE, so a bundled +# certifi value would shadow the OS store -- we pop it before injecting. +_SSL_CERT_FILE_VAR = "SSL_CERT_FILE" + +# Marker set by build/hooks/runtime_hook_ssl_certs.py: records that the frozen +# binary set SSL_CERT_FILE to bundled certifi (vs a genuine user value). +_BUNDLED_CERT_MARKER = "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT" + +# Directory (relative to the installed package) that holds the child shim. +_CHILD_SHIM_DIRNAME = "_child_tls" + _TRUTHY = {"1", "true", "yes", "on"} @@ -47,6 +67,31 @@ def has_explicit_ca_override(env: Mapping[str, str] | None = None) -> bool: return any((environ.get(var) or "").strip() for var in _EXPLICIT_CA_ENV_VARS) +def _explicit_ca_path(env: Mapping[str, str] | None = None) -> str: + """Return the first explicit CA-bundle path set, or an empty string.""" + environ = os.environ if env is None else env + for var in _EXPLICIT_CA_ENV_VARS: + value = (environ.get(var) or "").strip() + if value: + return value + return "" + + +def _mutable_environ(env: Mapping[str, str] | None) -> MutableMapping[str, str]: + """Return the environment truststore/OpenSSL will actually read. + + ``env is None`` is the real runtime path -- operate on ``os.environ`` so the + pop/restore of SSL_CERT_FILE takes effect before OpenSSL reads it. When an + explicit mapping is passed (tests), operate on it if mutable, else fall back + to ``os.environ`` to match the read semantics of the other helpers. + """ + if env is None: + return os.environ + if isinstance(env, MutableMapping): + return env + return os.environ + + def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: """Route HTTPS verification through the OS trust store when possible. @@ -56,11 +101,11 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: ``truststore`` missing, or injection failure). Never raises. """ if _env_flag(_DISABLE_ENV_VAR, env): - logger.debug("OS trust-store injection disabled via %s", _DISABLE_ENV_VAR) + logger.debug("[i] TLS: OS trust-store injection disabled (%s)", _DISABLE_ENV_VAR) return False if has_explicit_ca_override(env): - logger.debug("Explicit CA bundle set; leaving certifi/verify path intact") + logger.debug("[i] TLS: explicit CA bundle in use: %s", _explicit_ca_path(env)) return False try: @@ -68,14 +113,75 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: # only with ImportError -- degrade instead of crashing startup. import truststore except Exception as exc: - logger.debug("truststore unavailable (%s); verifying TLS against bundled certifi", exc) + logger.debug("[i] TLS: verifying against bundled CA (certifi fallback) [%s]", exc) return False + environ = _mutable_environ(env) + + # If the frozen hook pinned SSL_CERT_FILE to bundled certifi, pop it so + # truststore's set_default_verify_paths() reads the genuine system default. + # A user-set SSL_CERT_FILE (no marker) is left untouched. + bundled_cert: str | None = None + if environ.get(_SSL_CERT_FILE_VAR) and _env_flag(_BUNDLED_CERT_MARKER, env): + bundled_cert = environ.get(_SSL_CERT_FILE_VAR) + environ.pop(_SSL_CERT_FILE_VAR, None) + try: truststore.inject_into_ssl() except Exception as exc: - logger.debug("truststore.inject_into_ssl() failed (%s); falling back to certifi", exc) + # Never end with zero trust: restore the bundled certifi path so + # musl/minimal-container hosts still verify against certifi. + if bundled_cert is not None: + environ[_SSL_CERT_FILE_VAR] = bundled_cert + environ.pop(_BUNDLED_CERT_MARKER, None) + logger.debug("[i] TLS: verifying against bundled CA (certifi fallback) [%s]", exc) return False - logger.debug("Verifying TLS against the OS trust store via truststore") + # Clear the marker so it does not leak into child processes. + environ.pop(_BUNDLED_CERT_MARKER, None) + logger.debug("[i] TLS: verifying against OS trust store (truststore)") return True + + +def _child_shim_dir() -> str | None: + """Absolute path to the directory containing the child ``sitecustomize``. + + Resolves for both source-installed and frozen (PyInstaller) layouts. Returns + ``None`` if the path cannot be determined -- callers degrade gracefully. + """ + try: + if getattr(sys, "frozen", False): + base = getattr(sys, "_MEIPASS", None) + if not base: + return None + candidate = Path(base) / "apm_cli" / "core" / _CHILD_SHIM_DIRNAME + else: + candidate = Path(__file__).resolve().parent / _CHILD_SHIM_DIRNAME + return str(candidate) + except Exception: + return None + + +def build_child_tls_env(base_env: Mapping[str, str]) -> dict[str, str]: + """Return a child env that re-runs the trust bootstrap at its startup. + + Prepends the ``sitecustomize`` shim directory to ``PYTHONPATH`` (preserving + any existing value) so each Python child imports the shim and re-invokes + :func:`configure_tls_trust` -- single source of truth, no logic duplicated. + + * ``APM_DISABLE_TRUSTSTORE`` truthy: return the env unchanged (no shim). + * An explicit CA override still gets the shim; the shim's own + ``configure_tls_trust`` declines to inject and leaves the bundle intact. + """ + child = dict(base_env) + + if _env_flag(_DISABLE_ENV_VAR, base_env): + return child + + shim_dir = _child_shim_dir() + if not shim_dir: + return child + + existing = child.get("PYTHONPATH", "") + child["PYTHONPATH"] = shim_dir + os.pathsep + existing if existing else shim_dir + return child From 134c989aaa57b78dd25934d9a1ea02243544613e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 11:39:35 +0200 Subject: [PATCH 12/27] fix(tls): propagate OS-store trust into runtime child processes Wire build_child_tls_env into every runtime child-spawn surface so each Python child runtime re-runs the OS-trust bootstrap: - base._stream_subprocess_output gains an env param; defaults to build_child_tls_env(os.environ) and passes env= to Popen (covers copilot and llm prompt-execution paths). - codex_runtime prompt-execution Popen passes the child TLS env. - llm_runtime models-list subprocess passes the child TLS env (the --version availability probes make no HTTPS call and are left as-is to avoid regressing their pinned unit-test kwargs). - manager wraps the runtime-setup child env as build_child_tls_env(setup_runtime_environment(env)). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/runtime/base.py | 14 +++++++++++++- src/apm_cli/runtime/codex_runtime.py | 3 +++ src/apm_cli/runtime/llm_runtime.py | 15 +++++++++++++-- src/apm_cli/runtime/manager.py | 5 +++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/apm_cli/runtime/base.py b/src/apm_cli/runtime/base.py index ed8d6433e..225b5ebca 100644 --- a/src/apm_cli/runtime/base.py +++ b/src/apm_cli/runtime/base.py @@ -1,23 +1,34 @@ """Base runtime adapter interface for APM.""" +import os import subprocess from abc import ABC, abstractmethod from typing import Any +from ..core.tls_trust import build_child_tls_env -def _stream_subprocess_output(cmd: list, timeout: int | None = None) -> tuple[list, int]: + +def _stream_subprocess_output( + cmd: list, timeout: int | None = None, env: dict | None = None +) -> tuple[list, int]: """Run *cmd* as a subprocess, stream stdout in real-time, and return output. Args: cmd: Command and arguments list passed to :class:`subprocess.Popen`. timeout: Optional wait timeout in seconds passed to :meth:`subprocess.Popen.wait`. ``None`` waits indefinitely. + env: Optional child environment. When ``None``, the current process + environment is used with the OS-trust child shim wired in so the + child runtime verifies HTTPS against the OS trust store too. Returns: ``(output_lines, return_code)`` where *output_lines* is the list of streamed stdout lines (including newlines) and *return_code* is the process exit code. """ + if env is None: + env = build_child_tls_env(os.environ) + process = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -25,6 +36,7 @@ def _stream_subprocess_output(cmd: list, timeout: int | None = None) -> tuple[li text=True, encoding="utf-8", bufsize=1, # Line buffered + env=env, ) output_lines = [] diff --git a/src/apm_cli/runtime/codex_runtime.py b/src/apm_cli/runtime/codex_runtime.py index 44fbb6c99..3022b1ced 100644 --- a/src/apm_cli/runtime/codex_runtime.py +++ b/src/apm_cli/runtime/codex_runtime.py @@ -1,8 +1,10 @@ """Codex runtime adapter for APM.""" +import os import subprocess from typing import Any +from ..core.tls_trust import build_child_tls_env from .base import RuntimeAdapter from .utils import find_runtime_binary @@ -45,6 +47,7 @@ def execute_prompt(self, prompt_content: str, **kwargs) -> str: text=True, encoding="utf-8", bufsize=1, # Line buffered + env=build_child_tls_env(os.environ), ) output_lines = [] diff --git a/src/apm_cli/runtime/llm_runtime.py b/src/apm_cli/runtime/llm_runtime.py index 69336e25f..2f82aa2de 100644 --- a/src/apm_cli/runtime/llm_runtime.py +++ b/src/apm_cli/runtime/llm_runtime.py @@ -1,8 +1,10 @@ """LLM runtime adapter for APM.""" +import os import subprocess from typing import Any +from ..core.tls_trust import build_child_tls_env from .base import RuntimeAdapter, _stream_subprocess_output @@ -20,7 +22,11 @@ def __init__(self, model_name: str | None = None): # Verify llm CLI is available try: result = subprocess.run( # noqa: F841 - ["llm", "--version"], capture_output=True, text=True, encoding="utf-8", check=True + ["llm", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, ) except (subprocess.CalledProcessError, FileNotFoundError): raise RuntimeError("llm CLI not found. Please install: pip install llm") # noqa: B904 @@ -74,6 +80,7 @@ def list_available_models(self) -> dict[str, Any]: text=True, encoding="utf-8", check=True, + env=build_child_tls_env(os.environ), ) models = {} for line in result.stdout.strip().split("\n"): @@ -121,7 +128,11 @@ def is_available() -> bool: """ try: subprocess.run( - ["llm", "--version"], capture_output=True, text=True, encoding="utf-8", check=True + ["llm", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, ) return True except (subprocess.CalledProcessError, FileNotFoundError): diff --git a/src/apm_cli/runtime/manager.py b/src/apm_cli/runtime/manager.py index 4ad505bdc..d66155234 100644 --- a/src/apm_cli/runtime/manager.py +++ b/src/apm_cli/runtime/manager.py @@ -13,6 +13,7 @@ import click from colorama import Fore, Style +from ..core.tls_trust import build_child_tls_env from ..core.token_manager import setup_runtime_environment @@ -184,6 +185,10 @@ def run_embedded_script( # Setup GitHub tokens using centralized manager env = setup_runtime_environment(env) # Pass env to preserve CI tokens + # Wire OS-trust child shim so the setup subprocess (and any + # Python runtime it spawns) verifies HTTPS against the OS store. + env = build_child_tls_env(env) + result = subprocess.run( cmd, cwd=temp_dir, From 8323444ce6bfbe400325ed931d0e0d6d6c9892ac Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 12:02:53 +0200 Subject: [PATCH 13/27] test(tls): independent e2e verification of B1/B2/H2 OS-trust remediation Adversarial end-to-end tests authored from the PR #2005 acceptance specs, proving the fixes empirically rather than asserting a function was called. B1 (tests/integration/test_tls_child_runtime.py): OS-store trust crosses the process boundary into a real child via build_child_tls_env -- the shim runs at child startup (ssl.SSLContext becomes truststore-backed vs stdlib "ssl"), a real requests.get against a private-CA server succeeds only through the env-delivered trust (control fails SSLERROR), and APM_DISABLE_TRUSTSTORE suppresses the shim. No shim stdout/stderr contamination. B2 (tests/integration/test_tls_frozen_hook.py): the bundled-default SSL_CERT_FILE is popped before injection (marker-gated), a genuine user override is preserved and honored end-to-end, and an inject failure restores the certifi path. H2 (tests/unit/core/test_tls_trust.py): each trust-source branch emits its exact ASCII diagnostic line at DEBUG. Shared private-CA server harness (tests/integration/_tls_ca_server.py) reuses the proven cert factory and guards against global truststore injection bleed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/integration/_tls_ca_server.py | 62 ++++++ tests/integration/test_tls_child_runtime.py | 211 ++++++++++++++++++++ tests/integration/test_tls_frozen_hook.py | 185 +++++++++++++++++ tests/unit/core/test_tls_trust.py | 65 ++++++ 4 files changed, 523 insertions(+) create mode 100644 tests/integration/_tls_ca_server.py create mode 100644 tests/integration/test_tls_child_runtime.py diff --git a/tests/integration/_tls_ca_server.py b/tests/integration/_tls_ca_server.py new file mode 100644 index 000000000..154fb9bf0 --- /dev/null +++ b/tests/integration/_tls_ca_server.py @@ -0,0 +1,62 @@ +"""Shared private-CA HTTPS server harness for the child-runtime/frozen TLS tests. + +Reuses the certificate factory (``_mint_ca_and_leaf``) and request handler +(``_OkHandler``) already proven in ``test_tls_custom_ca`` -- single source of +truth for minting a private CA that is present in neither ``certifi`` nor the OS +trust store. Exposes a context manager that boots a loopback HTTPS server whose +leaf is signed by that private CA, so the child-runtime (B1) and frozen-binary +(B2) verifiers can drive real ``requests`` traffic across a genuine trust +boundary. +""" + +from __future__ import annotations + +import contextlib +import http.server +import ssl +import threading +from pathlib import Path +from types import SimpleNamespace + +from .test_tls_custom_ca import _mint_ca_and_leaf, _OkHandler + + +@contextlib.contextmanager +def private_ca_https_server(dirpath: Path): + """Yield a running loopback HTTPS server backed by a fresh private CA. + + Yields a namespace with ``url`` (https://localhost:/), ``ca_path`` + (PEM of the private CA), and ``ca_pem``/``srv_pem``/``srv_key`` paths. + """ + ca_pem, srv_pem, srv_key = _mint_ca_and_leaf(dirpath) + + # A prior test may have left truststore globally injected; a truststore-backed + # server-side SSLContext raises on wrap_socket. Extract first (best-effort) so + # the server always presents its chain via the stdlib ssl backend. + try: + import truststore + + truststore.extract_from_ssl() + except Exception: + pass + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=str(srv_pem), keyfile=str(srv_key)) + + httpd = http.server.HTTPServer(("127.0.0.1", 0), _OkHandler) + httpd.socket = context.wrap_socket(httpd.socket, server_side=True) + port = httpd.server_address[1] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield SimpleNamespace( + url=f"https://localhost:{port}/", + ca_path=str(ca_pem), + ca_pem=ca_pem, + srv_pem=srv_pem, + srv_key=srv_key, + ) + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) diff --git a/tests/integration/test_tls_child_runtime.py b/tests/integration/test_tls_child_runtime.py new file mode 100644 index 000000000..f4326669e --- /dev/null +++ b/tests/integration/test_tls_child_runtime.py @@ -0,0 +1,211 @@ +"""B1 verifier: OS-store trust must propagate into a REAL child process. + +The parent cannot monkeypatch a child runtime's ``ssl`` module across ``exec``; +``build_child_tls_env`` must carry trust across the process boundary by +prepending the ``sitecustomize`` shim dir to the child ``PYTHONPATH`` so the +child re-runs :func:`configure_tls_trust` at its own interpreter startup. + +These are independent end-to-end tests written from the acceptance spec for +PR #2005 -- they spawn genuine child interpreters and prove: + +* the shim actually executes in the child (``ssl.SSLContext`` becomes + truststore-backed) and does so only via the env, not inheritance; +* a real ``requests.get`` against a private-CA HTTPS server succeeds only when + trust is delivered through the ``build_child_tls_env``-produced env, and fails + with the identical child under a plain env (the asymmetry is the proof); +* ``APM_DISABLE_TRUSTSTORE`` suppresses the shim entirely. + +Platform note: on macOS ``truststore`` verifies against the system keychain and +does not consult ``SSL_CERT_FILE``, so the file-based system default cannot be +honored through truststore there. On macOS the env-delivered trust is therefore +proven via ``REQUESTS_CA_BUNDLE`` carried through the same +``build_child_tls_env``-produced env; on Linux ``SSL_CERT_FILE`` is honored by +truststore's ``set_default_verify_paths()``. Either way the trust reaches the +child solely through the env this feature builds. +""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import subprocess +import sys + +import pytest + +from apm_cli.core.tls_trust import build_child_tls_env + +pytestmark = pytest.mark.integration + +_TRUST_ENV_VARS = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "APM_DISABLE_TRUSTSTORE", + "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT", +) + +_truststore_missing = importlib.util.find_spec("truststore") is None +_requires_truststore = pytest.mark.skipif( + _truststore_missing, reason="truststore not importable in this environment" +) +_requires_openssl = pytest.mark.skipif( + shutil.which("openssl") is None, reason="openssl CLI not available" +) + +# Child that reports which module owns ssl.SSLContext -- truststore-backed after +# the shim runs, plain "ssl" otherwise. +_SSL_MODULE_PROBE = "import ssl; print(ssl.SSLContext.__module__)" + +# Child that performs a real HTTPS GET and prints exactly one RESULT token. +_REQUEST_PROBE = ( + "import sys\n" + "import ssl\n" + "import requests\n" + "try:\n" + " r = requests.get(sys.argv[1], timeout=5)\n" + " print('RESULT:OK' if r.status_code == 200 else 'RESULT:BAD')\n" + "except (ssl.SSLError, requests.exceptions.SSLError):\n" + " print('RESULT:SSLERROR')\n" +) + + +@pytest.fixture(autouse=True) +def _isolate_trust(): + """Undo any global truststore/ssl injection so parent state cannot bleed in or out. + + These tests spawn isolated child interpreters, but the in-parent private-CA + server must be built with the stdlib ssl backend -- a prior test that left + truststore injected would otherwise break the server's wrap_socket. + """ + import ssl as _ssl + + saved_ctx = _ssl.SSLContext + try: + import truststore + + truststore.extract_from_ssl() + except Exception: + pass + try: + yield + finally: + try: + import truststore + + truststore.extract_from_ssl() + except Exception: + pass + _ssl.SSLContext = saved_ctx + + +def _clean_base_env() -> dict[str, str]: + """os.environ copy with every trust-related var stripped (pristine start).""" + return {k: v for k, v in os.environ.items() if k not in _TRUST_ENV_VARS} + + +def _ca_delivery_env(base: dict[str, str], ca_path: str) -> dict[str, str]: + """Return *base* augmented so an honest child would trust *ca_path*. + + Uses the delivery channel the platform's truststore backend actually + honors: ``REQUESTS_CA_BUNDLE`` on macOS (keychain backend ignores + ``SSL_CERT_FILE``), ``SSL_CERT_FILE`` on Linux/other. + """ + env = dict(base) + if sys.platform == "darwin": + env["REQUESTS_CA_BUNDLE"] = ca_path + else: + env["SSL_CERT_FILE"] = ca_path + return env + + +@_requires_truststore +def test_shim_runs_in_child_via_env_not_inheritance(): + """Test 1: build_child_tls_env makes ssl.SSLContext truststore-backed in the child.""" + base = _clean_base_env() + + with_shim = subprocess.run( + [sys.executable, "-c", _SSL_MODULE_PROBE], + env=build_child_tls_env(base), + capture_output=True, + text=True, + ) + assert with_shim.returncode == 0, with_shim.stderr + assert with_shim.stdout.strip().startswith("truststore"), ( + f"child ssl module should be truststore-backed, got {with_shim.stdout!r}" + ) + + # Control: a plain env (no shim dir on PYTHONPATH) must stay on stdlib ssl, + # proving the trust crosses the boundary via the env, not process inheritance. + control = subprocess.run( + [sys.executable, "-c", _SSL_MODULE_PROBE], + env=base, + capture_output=True, + text=True, + ) + assert control.returncode == 0, control.stderr + assert control.stdout.strip() == "ssl", ( + f"control child should use stdlib ssl, got {control.stdout!r}" + ) + + +@_requires_truststore +@_requires_openssl +def test_real_child_gains_trust_only_through_env(tmp_path): + """Test 2: a real requests.get in a child succeeds only via the env-delivered trust.""" + from ._tls_ca_server import private_ca_https_server + + with private_ca_https_server(tmp_path) as server: + base = _clean_base_env() + + # Trust delivered ONLY through the build_child_tls_env-produced env. + trusted_env = build_child_tls_env(_ca_delivery_env(base, server.ca_path)) + trusted = subprocess.run( + [sys.executable, "-c", _REQUEST_PROBE, server.url], + env=trusted_env, + capture_output=True, + text=True, + ) + assert trusted.returncode == 0, trusted.stderr + # No shim contamination: stdout is exactly the RESULT token, stderr empty. + assert trusted.stdout.strip() == "RESULT:OK", ( + f"trusted child stdout should be only RESULT:OK, got {trusted.stdout!r}" + ) + assert trusted.stderr == "", f"shim must not write to child stderr, got {trusted.stderr!r}" + + # Control: identical child, plain env (private CA absent) -> SSL failure. + control = subprocess.run( + [sys.executable, "-c", _REQUEST_PROBE, server.url], + env=base, + capture_output=True, + text=True, + ) + assert control.returncode == 0, control.stderr + assert control.stdout.strip() == "RESULT:SSLERROR", ( + f"control child should fail verification, got {control.stdout!r}" + ) + + +@_requires_truststore +def test_disable_flag_suppresses_child_shim(): + """Test 3: APM_DISABLE_TRUSTSTORE keeps the shim out and the child on stdlib ssl.""" + base = _clean_base_env() + base["APM_DISABLE_TRUSTSTORE"] = "1" + + child_env = build_child_tls_env(base) + shim_dir = os.path.dirname(importlib.util.find_spec("apm_cli.core.tls_trust").origin) + # The shim dir must NOT have been prepended to PYTHONPATH. + assert os.path.join(shim_dir, "_child_tls") not in child_env.get("PYTHONPATH", "") + + result = subprocess.run( + [sys.executable, "-c", _SSL_MODULE_PROBE], + env=child_env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "ssl", ( + f"disabled child should use stdlib ssl, got {result.stdout!r}" + ) diff --git a/tests/integration/test_tls_frozen_hook.py b/tests/integration/test_tls_frozen_hook.py index 7b412eb14..317a51ac0 100644 --- a/tests/integration/test_tls_frozen_hook.py +++ b/tests/integration/test_tls_frozen_hook.py @@ -15,8 +15,11 @@ import importlib.util import os +import shutil import ssl +import subprocess import sys +import types from pathlib import Path import certifi @@ -31,9 +34,69 @@ "CURL_CA_BUNDLE", "SSL_CERT_FILE", "APM_DISABLE_TRUSTSTORE", + "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT", ) _HOOK_PATH = Path(__file__).resolve().parents[2] / "build" / "hooks" / "runtime_hook_ssl_certs.py" +_BUNDLED_CERT_MARKER = "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT" + +_truststore_missing = importlib.util.find_spec("truststore") is None +_requires_truststore = pytest.mark.skipif( + _truststore_missing, reason="truststore not importable in this environment" +) +_requires_openssl = pytest.mark.skipif( + shutil.which("openssl") is None, reason="openssl CLI not available" +) + +# Child that bootstraps trust in its own process then does one real HTTPS GET. +# Reports the return value, whether SSL_CERT_FILE survived the bootstrap, and a +# single RESULT token -- so the parent can assert both mechanism and outcome +# without any global ssl mutation bleeding across tests. +_B2_CHILD = ( + "import os\n" + "import sys\n" + "import ssl\n" + "import requests\n" + "from apm_cli.core.tls_trust import configure_tls_trust\n" + "ret = configure_tls_trust()\n" + "print('RET:' + str(ret))\n" + "print('SSL_CERT_FILE_PRESENT:' + str('SSL_CERT_FILE' in os.environ))\n" + "try:\n" + " r = requests.get(sys.argv[1], timeout=5)\n" + " print('RESULT:OK' if r.status_code == 200 else 'RESULT:BAD')\n" + "except (ssl.SSLError, requests.exceptions.SSLError):\n" + " print('RESULT:SSLERROR')\n" +) + + +def _install_fake_truststore(monkeypatch, inject=None): + """Put a no-op (or raising) fake ``truststore`` in sys.modules for mechanism tests.""" + module = types.ModuleType("truststore") + module.inject_into_ssl = inject or (lambda: None) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "truststore", module) + + +def _clean_child_env() -> dict[str, str]: + """os.environ copy with every trust-related var stripped.""" + return {k: v for k, v in os.environ.items() if k not in _TRUST_ENV_VARS} + + +def _parse_tokens(stdout: str) -> dict[str, str]: + """Split the B2 child's ``KEY:VALUE`` lines into a dict.""" + tokens: dict[str, str] = {} + for line in stdout.splitlines(): + if ":" in line: + key, _, value = line.partition(":") + tokens[key.strip()] = value.strip() + return tokens + + +def _bundle_with_private_ca(tmp_path, ca_pem: Path) -> Path: + """A CA bundle that is certifi + the private CA (a superset 'bundled default').""" + bundle = tmp_path / "bundle_with_private_ca.pem" + bundle.write_bytes(Path(certifi.where()).read_bytes() + b"\n" + ca_pem.read_bytes()) + return bundle + @pytest.fixture(autouse=True) def _isolate_trust(): @@ -98,3 +161,125 @@ def test_user_override_still_wins_under_frozen_hook(): assert os.environ.get("SSL_CERT_FILE") is None assert configure_tls_trust() is False assert "truststore" not in ssl.SSLContext.__module__ + + +# --------------------------------------------------------------------------- +# B2 -- frozen binary uses the OS store; certifi only as a genuine fallback. +# These SIMULATE the frozen state (no binary is built). The bundled-default +# SSL_CERT_FILE (marked by the runtime hook) must be neutralized before +# injection so the true system store is consulted; a genuine user override +# must be left intact; and an injection failure must restore certifi so a +# minimal container never ends up with zero trust. +# --------------------------------------------------------------------------- + + +def test_bundled_default_marker_gates_ssl_cert_file_pop(monkeypatch): + """B2 mechanism: the bundled-default SSL_CERT_FILE is popped, a user value is kept. + + Platform-independent proof (fake truststore -> no global ssl mutation) that + the pop is gated strictly on the runtime-hook marker: OUR bundled default is + removed before injection, a real user SSL_CERT_FILE is preserved. + """ + _install_fake_truststore(monkeypatch) + + marked = {"SSL_CERT_FILE": "/bundled/certifi.pem", _BUNDLED_CERT_MARKER: "1"} + assert configure_tls_trust(env=marked) is True + assert "SSL_CERT_FILE" not in marked, "bundled default must be popped before injection" + assert _BUNDLED_CERT_MARKER not in marked, "marker must not leak to children" + + user = {"SSL_CERT_FILE": "/etc/ssl/user-ca.pem"} + assert configure_tls_trust(env=user) is True + assert user["SSL_CERT_FILE"] == "/etc/ssl/user-ca.pem", "user value must be preserved" + + +@_requires_truststore +@_requires_openssl +def test_bundled_default_neutralized_system_store_wins(tmp_path): + """B2 Test A: with the bundled-default marker, the private CA is NOT trusted. + + SSL_CERT_FILE points at a bundle that CONTAINS the private CA and the marker + is set. Because configure_tls_trust must pop that bundled default before + injecting, verification falls to the real system trust (which lacks the + private CA), so the request fails. If the B2 bug persisted and the bundled + SSL_CERT_FILE were honored, the request would SUCCEED -- so SSLERROR proves + the bundled certifi is no longer the trust source. Run in a child process to + avoid global-state bleed. + """ + from ._tls_ca_server import private_ca_https_server + + with private_ca_https_server(tmp_path) as server: + bundle = _bundle_with_private_ca(tmp_path, server.ca_pem) + env = _clean_child_env() + env["SSL_CERT_FILE"] = str(bundle) + env[_BUNDLED_CERT_MARKER] = "1" + + result = subprocess.run( + [sys.executable, "-c", _B2_CHILD, server.url], + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + tokens = _parse_tokens(result.stdout) + assert tokens.get("RET") == "True", result.stdout + assert tokens.get("SSL_CERT_FILE_PRESENT") == "False", ( + f"bundled default must be popped, got {result.stdout!r}" + ) + assert tokens.get("RESULT") == "SSLERROR", ( + f"private CA must not be trusted after neutralization, got {result.stdout!r}" + ) + + +@_requires_truststore +@_requires_openssl +def test_genuine_user_override_still_honored(tmp_path): + """B2 Test B: a genuine user CA override (no marker) is honored -> request succeeds. + + Same private-CA bundle, delivered as a real user value WITHOUT the + bundled-default marker. Only OUR bundled default is ever neutralized; user + intent must survive. Delivered via the channel the platform's truststore + backend honors (REQUESTS_CA_BUNDLE on macOS, SSL_CERT_FILE on Linux). Run in + a child process to avoid global-state bleed. + """ + from ._tls_ca_server import private_ca_https_server + + with private_ca_https_server(tmp_path) as server: + bundle = _bundle_with_private_ca(tmp_path, server.ca_pem) + env = _clean_child_env() + if sys.platform == "darwin": + env["REQUESTS_CA_BUNDLE"] = str(bundle) + else: + env["SSL_CERT_FILE"] = str(bundle) + + result = subprocess.run( + [sys.executable, "-c", _B2_CHILD, server.url], + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + tokens = _parse_tokens(result.stdout) + assert tokens.get("RESULT") == "OK", ( + f"genuine user override must be honored, got {result.stdout!r}" + ) + + +def test_injection_failure_restores_bundled_certifi(monkeypatch): + """B2 Test C: an inject failure returns False and restores the bundled certifi path. + + Minimal-container safety: never end with zero trust. With the bundled-default + marker set and inject_into_ssl raising, configure_tls_trust must restore + SSL_CERT_FILE to the captured bundled value and clear the marker. + """ + + def _boom(): + raise RuntimeError("platform trust API unavailable") + + _install_fake_truststore(monkeypatch, inject=_boom) + bundled = certifi.where() + os.environ["SSL_CERT_FILE"] = bundled + os.environ[_BUNDLED_CERT_MARKER] = "1" + + assert configure_tls_trust() is False + assert os.environ.get("SSL_CERT_FILE") == bundled, "certifi fallback must be restored" + assert _BUNDLED_CERT_MARKER not in os.environ, "marker must be cleared" diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index 93a085041..8cc94e749 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -11,6 +11,7 @@ from __future__ import annotations +import logging import os import subprocess import sys @@ -204,3 +205,67 @@ def test_cli_bootstrap_is_idempotent_across_import_and_main(tmp_path): assert result.returncode == 0, result.stderr assert sentinel.read_text(encoding="utf-8") == "1" + + +# --------------------------------------------------------------------------- +# H2 -- visible trust-source diagnostic. Each branch of configure_tls_trust must +# emit an ASCII "[i] TLS: ..." line at DEBUG naming which trust source is in +# effect, so an operator can tell OS-store vs certifi-fallback vs explicit +# bundle vs opt-out from the logs alone. +# --------------------------------------------------------------------------- + + +def _trust_source_messages(caplog): + """Rendered log messages emitted by configure_tls_trust that name a trust source.""" + return [ + record.getMessage() + for record in caplog.records + if record.name == "apm_cli.core.tls_trust" and "TLS:" in record.getMessage() + ] + + +def test_diag_default_inject_names_os_trust_store(monkeypatch, caplog): + _install_fake_truststore(monkeypatch) + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + assert configure_tls_trust() is True + + messages = _trust_source_messages(caplog) + assert "[i] TLS: verifying against OS trust store (truststore)" in messages + for message in messages: + message.encode("ascii") # must not raise + + +def test_diag_disabled_names_opt_out(caplog): + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + assert configure_tls_trust(env={_DISABLE_ENV_VAR: "1"}) is False + + messages = _trust_source_messages(caplog) + assert "[i] TLS: OS trust-store injection disabled (APM_DISABLE_TRUSTSTORE)" in messages + for message in messages: + message.encode("ascii") + + +def test_diag_explicit_bundle_names_the_path(caplog): + ca_path = "/etc/ssl/certs/corp-root.pem" + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + assert configure_tls_trust(env={"REQUESTS_CA_BUNDLE": ca_path}) is False + + messages = _trust_source_messages(caplog) + assert f"[i] TLS: explicit CA bundle in use: {ca_path}" in messages + for message in messages: + message.encode("ascii") + + +def test_diag_import_failure_names_certifi_fallback(monkeypatch, caplog): + # A None entry makes ``import truststore`` raise -> certifi-fallback branch. + monkeypatch.setitem(sys.modules, "truststore", None) + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + assert configure_tls_trust() is False + + messages = _trust_source_messages(caplog) + # The branch appends the captured exception in brackets; match the stable core. + assert any( + m.startswith("[i] TLS: verifying against bundled CA (certifi fallback)") for m in messages + ), messages + for message in messages: + message.encode("ascii") From a4e4f5ea8c3e29ecbfea380d2ced28df3dd7ead8 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 12:37:46 +0200 Subject: [PATCH 14/27] fix(tls): deliver OS-trust to child venvs via install-time .pth bootstrap The round-1 child-trust design prepended a sitecustomize shim dir to each child's PYTHONPATH so it re-ran apm_cli.core.tls_trust.configure_tls_trust. That was a silent no-op for the flagship `llm` runtime, which runs in its own venv (~/.apm/runtimes/llm-venv) that has neither apm_cli nor truststore: the shim's `from apm_cli...` import failed, was swallowed, and the child fell back to certifi -- so `apm run` still failed behind a corporate proxy. Prepending the shim dir also shadowed any user/corporate sitecustomize.py. Switch to CEO-approved Mechanism 2a: deliver trust at venv-setup time. - New self-contained bootstrap (_apm_tls_bootstrap.py) with ZERO apm_cli dependency (only truststore) plus a one-line .pth (_apm_tls.pth) that Python executes at interpreter startup. Both ship as package data / apm.spec datas. - setup-llm.sh / setup-llm.ps1 now `pip install truststore` into the llm venv; RuntimeManager copies the two bootstrap files into that venv's site-packages via a new Python helper ensure_child_tls_bootstrap() (Python-driven so it resolves identically for source and frozen apm; no fragile shell globbing). - build_child_tls_env() no longer mutates PYTHONPATH (kills the sitecustomize hijack); it is now an env-hygiene pass that only strips the internal bundled-cert marker. - configure_tls_trust() clears the bundled-default marker unconditionally, up-front, so it can no longer leak on the opt-out / explicit-override / truststore-import-fail early returns. - Delete the old sitecustomize.py shim; update apm.spec datas. - Honest scope-down in CHANGELOG + ssl-issues.md: OS-trust covers `apm install` and the Python `llm` runtime; Node (Copilot) / Rust (Codex) runtimes are not yet covered (tracked in #2034), with a Known limitations note. - Tests: foreign-venv regression gate (C1, offline via copied truststore), additive-to-user-sitecustomize (T2), marker no-leak across all 5 configure_tls_trust branches (T4), build_child_tls_env hygiene, delivery helper (T7), and a docs-scope drift guard (T3). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 21 +- build/apm.spec | 10 +- .../docs/troubleshooting/ssl-issues.md | 8 +- scripts/runtime/setup-llm.ps1 | 7 + scripts/runtime/setup-llm.sh | 7 + src/apm_cli/core/_child_tls/__init__.py | 20 +- src/apm_cli/core/_child_tls/_apm_tls.pth | 1 + .../core/_child_tls/_apm_tls_bootstrap.py | 48 ++++ src/apm_cli/core/_child_tls/sitecustomize.py | 16 -- src/apm_cli/core/tls_trust.py | 110 +++++-- src/apm_cli/runtime/manager.py | 28 +- tests/integration/test_tls_child_runtime.py | 272 ++++++++---------- tests/unit/core/test_tls_trust.py | 126 ++++++++ tests/unit/test_tls_docs_scope.py | 53 ++++ 14 files changed, 500 insertions(+), 227 deletions(-) create mode 100644 src/apm_cli/core/_child_tls/_apm_tls.pth create mode 100644 src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py delete mode 100644 src/apm_cli/core/_child_tls/sitecustomize.py create mode 100644 tests/unit/test_tls_docs_scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b19adb14..925dd39a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,16 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - APM now verifies HTTPS against the OS trust store by default (via - `truststore`) for both `apm install` and `apm run` (child runtimes), so it - works out-of-the-box behind a corporate CA or a TLS-inspecting proxy -- - matching the behaviour of `git`/`curl` on the same host instead of failing - against the bundled `certifi` set. The frozen binary now honours the system - store as well, with `certifi` as a genuine fallback. An explicitly set - `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` still wins, and - `APM_DISABLE_TRUSTSTORE=1` restores the previous certifi-only behaviour. - Known limitations / fast-follow: an additive `APM_EXTRA_CA_BUNDLE` is not yet - supported, and Windows `schannel` trust has caveats; Node-based runtimes are - not covered by the child-shim propagation. (closes #2004) (#2005) + `truststore`), so it works out-of-the-box behind a corporate CA or a + TLS-inspecting proxy -- matching the behaviour of `git`/`curl` on the same + host instead of failing against the bundled `certifi` set. This covers `apm + install` (in-process) and the Python-based `llm` child runtime spawned by `apm + run`, into whose venv APM installs `truststore` and a self-contained `.pth` + bootstrap at setup time. The frozen binary honours the system store as well, + with `certifi` as a genuine fallback. An explicitly set `REQUESTS_CA_BUNDLE` / + `CURL_CA_BUNDLE` still wins, and `APM_DISABLE_TRUSTSTORE=1` restores the + previous certifi-only behaviour. Node-based (Copilot) and Rust-based (Codex) + child runtimes are not yet covered and continue to use their own default + trust; tracked in #2034. (closes #2004) (#2005) ## [0.24.0] - 2026-07-05 diff --git a/build/apm.spec b/build/apm.spec index ecbdeed71..82c0e582e 100644 --- a/build/apm.spec +++ b/build/apm.spec @@ -88,10 +88,12 @@ entry_point = repo_root / 'src' / 'apm_cli' / 'cli.py' datas = [ (str(repo_root / 'scripts' / 'runtime'), 'scripts/runtime'), # Bundle runtime setup scripts (str(repo_root / 'pyproject.toml'), '.'), # Bundle pyproject.toml for version reading - # Child-runtime TLS trust shim: ships at apm_cli/core/_child_tls/ so - # build_child_tls_env() can prepend it to a child's PYTHONPATH in the - # frozen binary, letting Python child runtimes re-run the OS-trust bootstrap. - (str(repo_root / 'src' / 'apm_cli' / 'core' / '_child_tls' / 'sitecustomize.py'), + # Child-runtime TLS trust bootstrap: ships at apm_cli/core/_child_tls/ so + # ensure_child_tls_bootstrap() can copy the self-contained .pth bootstrap + # into a child runtime venv's site-packages from the frozen binary. + (str(repo_root / 'src' / 'apm_cli' / 'core' / '_child_tls' / '_apm_tls_bootstrap.py'), + 'apm_cli/core/_child_tls'), + (str(repo_root / 'src' / 'apm_cli' / 'core' / '_child_tls' / '_apm_tls.pth'), 'apm_cli/core/_child_tls'), ] diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index 5ed540006..df5e33ee1 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -56,14 +56,18 @@ apm install --verbose ## Default behaviour: the OS trust store -APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. This applies to both `apm install` and `apm run` -- the child runtimes APM spawns (for example the `llm` and `codex` CLIs) re-run the same OS-trust bootstrap at their own startup. So if your corporate CA or TLS-proxy root is installed in the OS trust store (Keychain on macOS, `update-ca-certificates`/`update-ca-trust` on Linux, the Trusted Root store on Windows), APM picks it up with **no configuration** -- importing the CA at the OS level is the recommended fix. The standalone frozen binary honours the system store as well, falling back to its bundled `certifi` set only when the OS store is unavailable. +APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. This applies to `apm install` (in-process) and to the Python-based `llm` child runtime that `apm run` spawns: `apm runtime setup llm` installs `truststore` into the runtime's virtual environment and drops a self-contained bootstrap into it, so that interpreter also verifies against the OS store. So if your corporate CA or TLS-proxy root is installed in the OS trust store (Keychain on macOS, `update-ca-certificates`/`update-ca-trust` on Linux, the Trusted Root store on Windows), APM picks it up with **no configuration** -- importing the CA at the OS level is the recommended fix. The standalone frozen binary honours the system store as well, falling back to its bundled `certifi` set only when the OS store is unavailable. You only need the steps below when the CA is *not* in the OS store, or you want to pin a specific bundle: - Setting `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` makes APM's HTTP layer verify against that bundle instead of the OS store. (`SSL_CERT_FILE` configures the stdlib `ssl` layer but is *not* read by `requests`, so on its own it does not override the HTTP path -- use `REQUESTS_CA_BUNDLE` for that.) - `APM_DISABLE_TRUSTSTORE=1` restores the legacy behaviour (verify against APM's bundled `certifi` set only). -Known limitations (fast-follow): an additive `APM_EXTRA_CA_BUNDLE` (trust the OS store *and* an extra bundle) is not yet available -- use `REQUESTS_CA_BUNDLE` to pin a single bundle for now. On Windows, the `schannel` backend has trust caveats. Node-based child runtimes are not covered by the OS-trust propagation and continue to use their own default trust. +### Known limitations + +- The Node-based (Copilot) and Rust-based (Codex) child runtimes are **not yet covered** by OS-trust propagation and continue to use their own default trust; behind a TLS-inspecting proxy, configure those runtimes' own trust or export `REQUESTS_CA_BUNDLE`/`NODE_EXTRA_CA_CERTS` for them. Tracked in #2034. +- An additive `APM_EXTRA_CA_BUNDLE` (trust the OS store *and* an extra bundle) is not yet available -- use `REQUESTS_CA_BUNDLE` to pin a single bundle for now. +- On Windows, the `schannel` backend has trust caveats. ## Configure trust diff --git a/scripts/runtime/setup-llm.ps1 b/scripts/runtime/setup-llm.ps1 index 69af3f1f6..44e6ac9d4 100644 --- a/scripts/runtime/setup-llm.ps1 +++ b/scripts/runtime/setup-llm.ps1 @@ -38,6 +38,13 @@ function Install-Llm { & $pipExe install --upgrade pip & $pipExe install llm + # Install truststore so the llm venv verifies HTTPS against the OS trust + # store (corporate CA / TLS-proxy support). APM drops a self-contained .pth + # bootstrap into this venv's site-packages after setup; that bootstrap + # depends only on truststore, which must be present here. + Write-Info "Installing truststore for OS-trust HTTPS verification..." + & $pipExe install truststore + # Install GitHub Models plugin in non-vanilla mode if (-not $Vanilla) { Write-Info "Installing GitHub Models plugin for APM defaults..." diff --git a/scripts/runtime/setup-llm.sh b/scripts/runtime/setup-llm.sh index 24d0d43d3..9a3b3782b 100755 --- a/scripts/runtime/setup-llm.sh +++ b/scripts/runtime/setup-llm.sh @@ -49,6 +49,13 @@ setup_llm() { log_info "Installing LLM library..." "$llm_venv/bin/pip" install --upgrade pip "$llm_venv/bin/pip" install llm + + # Install truststore so the llm venv verifies HTTPS against the OS trust + # store (corporate CA / TLS-proxy support). APM drops a self-contained .pth + # bootstrap into this venv's site-packages after setup; that bootstrap + # depends only on truststore, which must be present here. + log_info "Installing truststore for OS-trust HTTPS verification..." + "$llm_venv/bin/pip" install truststore # Install GitHub Models plugin in non-vanilla mode if [[ "$VANILLA_MODE" == "false" ]]; then diff --git a/src/apm_cli/core/_child_tls/__init__.py b/src/apm_cli/core/_child_tls/__init__.py index 2c22d48a1..5e3c9dbeb 100644 --- a/src/apm_cli/core/_child_tls/__init__.py +++ b/src/apm_cli/core/_child_tls/__init__.py @@ -1,9 +1,15 @@ -"""Child-process TLS trust shim package. +"""Child-process TLS trust bootstrap package. -Holds ``sitecustomize.py``, which Python auto-imports at interpreter startup -from any directory on ``sys.path`` / ``PYTHONPATH``. ``build_child_tls_env`` -in ``apm_cli.core.tls_trust`` prepends this directory to a child's -``PYTHONPATH`` so each Python runtime re-runs the OS-trust bootstrap in its -own process. The ``__init__`` exists only so the directory ships as package -data; the shim itself is imported by path, not as a submodule. +Holds the two artifacts copied into a child runtime venv's site-packages by +``ensure_child_tls_bootstrap`` in ``apm_cli.core.tls_trust``: + +* ``_apm_tls_bootstrap.py`` -- a self-contained OS-trust bootstrap with NO + ``apm_cli`` dependency (it only needs ``truststore``). +* ``_apm_tls.pth`` -- a one-line path-config file (``import _apm_tls_bootstrap``) + that Python executes at interpreter startup, so trust is delivered at + venv-setup time instead of by mutating the child's ``PYTHONPATH`` at spawn + time (which would shadow a user/corporate ``sitecustomize.py``). + +The ``__init__`` exists only so the directory ships as package data; the +bootstrap files are copied by path, not imported as submodules of this package. """ diff --git a/src/apm_cli/core/_child_tls/_apm_tls.pth b/src/apm_cli/core/_child_tls/_apm_tls.pth new file mode 100644 index 000000000..b24d5d8e7 --- /dev/null +++ b/src/apm_cli/core/_child_tls/_apm_tls.pth @@ -0,0 +1 @@ +import _apm_tls_bootstrap diff --git a/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py b/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py new file mode 100644 index 000000000..0d83c254b --- /dev/null +++ b/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py @@ -0,0 +1,48 @@ +"""Self-contained OS-trust bootstrap for child runtimes. NO apm_cli dependency. + +Depends only on ``truststore`` (installed into the child venv). Executed at +interpreter startup via a ``.pth`` file dropped into the child venv's +site-packages, so trust is delivered at venv-setup time rather than by +mutating the child's ``PYTHONPATH`` at spawn time (which would shadow a +user/corporate ``sitecustomize.py``). + +Must stay SILENT (write nothing to stdout/stderr) and never raise -- a broken +bootstrap must not disturb the child runtime's own output or startup. +""" + +import logging as _logging +import os as _os + +_logger = _logging.getLogger("apm.tls") + + +def _truthy(val): + return (val or "").strip().lower() in ("1", "true", "yes", "on") + + +def _bootstrap(): + if _truthy(_os.environ.get("APM_DISABLE_TRUSTSTORE")): + return + for var in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"): + if (_os.environ.get(var) or "").strip(): + return + try: + import truststore + except Exception: + return + marker = "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT" + bundled = None + if _os.environ.get("SSL_CERT_FILE") and _truthy(_os.environ.get(marker)): + bundled = _os.environ.pop("SSL_CERT_FILE", None) + _os.environ.pop(marker, None) + try: + truststore.inject_into_ssl() + _logger.debug("[i] TLS: child verifying against OS trust store") + except Exception: + if bundled is not None: + _os.environ["SSL_CERT_FILE"] = bundled + _logger.debug("[i] TLS: child falling back to certifi") + + +_bootstrap() +del _bootstrap diff --git a/src/apm_cli/core/_child_tls/sitecustomize.py b/src/apm_cli/core/_child_tls/sitecustomize.py deleted file mode 100644 index ff4c80502..000000000 --- a/src/apm_cli/core/_child_tls/sitecustomize.py +++ /dev/null @@ -1,16 +0,0 @@ -# APM child-runtime TLS trust shim. -# -# Python auto-imports ``sitecustomize`` at interpreter startup from any -# directory on sys.path / PYTHONPATH. apm_cli.core.tls_trust.build_child_tls_env -# prepends this directory to a child runtime's PYTHONPATH so the child re-runs -# the OS-trust bootstrap in its own process (the parent cannot monkeypatch a -# child's ssl module across exec()). -# -# Must stay SILENT (write nothing to stdout/stderr) and never raise -- a broken -# bootstrap must not disturb the child runtime's own output or startup. -try: - from apm_cli.core.tls_trust import configure_tls_trust - - configure_tls_trust() -except Exception: - pass diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index bb1ca83a5..e7a06827f 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -13,17 +13,21 @@ * Missing ``truststore`` or a failed injection falls back to ``certifi``. * ``APM_DISABLE_TRUSTSTORE`` forces the legacy ``certifi``-only behaviour. -Child runtimes (``llm``, ``codex``, ``copilot``, ...) are Python/CLI -subprocesses spawned after ``exec``; the parent cannot monkeypatch their -``ssl`` module. ``build_child_tls_env`` propagates trust by prepending a -``sitecustomize`` shim directory to the child ``PYTHONPATH`` so each Python -child re-runs :func:`configure_tls_trust` at its own interpreter startup. +Child runtimes are Python/CLI subprocesses spawned after ``exec``; the parent +cannot monkeypatch their ``ssl`` module. Trust is delivered to the Python +``llm`` runtime at venv-setup time: :func:`ensure_child_tls_bootstrap` drops a +self-contained ``.pth`` bootstrap into the runtime venv's site-packages so its +interpreter injects ``truststore`` at startup with no ``apm_cli`` dependency and +no ``PYTHONPATH`` mutation (which would shadow a user ``sitecustomize.py``). +:func:`build_child_tls_env` is now an env-hygiene pass only. Node (Copilot) and +Rust (Codex) runtimes verify against their own default trust for now (#2034). """ from __future__ import annotations import logging import os +import shutil import sys from collections.abc import Mapping, MutableMapping from pathlib import Path @@ -50,9 +54,14 @@ # binary set SSL_CERT_FILE to bundled certifi (vs a genuine user value). _BUNDLED_CERT_MARKER = "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT" -# Directory (relative to the installed package) that holds the child shim. +# Directory (relative to the installed package) that holds the child bootstrap. _CHILD_SHIM_DIRNAME = "_child_tls" +# The two artifacts copied into a child venv's site-packages to deliver +# OS-trust at interpreter startup (see ensure_child_tls_bootstrap). +_BOOTSTRAP_MODULE_FILE = "_apm_tls_bootstrap.py" +_BOOTSTRAP_PTH_FILE = "_apm_tls.pth" + _TRUTHY = {"1", "true", "yes", "on"} @@ -100,6 +109,16 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: ``certifi`` behaviour was left in place (explicit override, opt-out, ``truststore`` missing, or injection failure). Never raises. """ + environ = _mutable_environ(env) + + # Clear the bundled-default marker unconditionally, up-front, so it can + # never leak into child processes on ANY return path (opt-out, explicit + # override, truststore-import failure, inject success, or inject failure). + # Capture its truthiness first -- the pop-before-inject logic below needs + # to know whether the current SSL_CERT_FILE was OUR bundled default. + had_bundled_marker = _env_flag(_BUNDLED_CERT_MARKER, env) + environ.pop(_BUNDLED_CERT_MARKER, None) + if _env_flag(_DISABLE_ENV_VAR, env): logger.debug("[i] TLS: OS trust-store injection disabled (%s)", _DISABLE_ENV_VAR) return False @@ -116,13 +135,11 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: logger.debug("[i] TLS: verifying against bundled CA (certifi fallback) [%s]", exc) return False - environ = _mutable_environ(env) - # If the frozen hook pinned SSL_CERT_FILE to bundled certifi, pop it so # truststore's set_default_verify_paths() reads the genuine system default. # A user-set SSL_CERT_FILE (no marker) is left untouched. bundled_cert: str | None = None - if environ.get(_SSL_CERT_FILE_VAR) and _env_flag(_BUNDLED_CERT_MARKER, env): + if environ.get(_SSL_CERT_FILE_VAR) and had_bundled_marker: bundled_cert = environ.get(_SSL_CERT_FILE_VAR) environ.pop(_SSL_CERT_FILE_VAR, None) @@ -133,18 +150,15 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: # musl/minimal-container hosts still verify against certifi. if bundled_cert is not None: environ[_SSL_CERT_FILE_VAR] = bundled_cert - environ.pop(_BUNDLED_CERT_MARKER, None) logger.debug("[i] TLS: verifying against bundled CA (certifi fallback) [%s]", exc) return False - # Clear the marker so it does not leak into child processes. - environ.pop(_BUNDLED_CERT_MARKER, None) logger.debug("[i] TLS: verifying against OS trust store (truststore)") return True -def _child_shim_dir() -> str | None: - """Absolute path to the directory containing the child ``sitecustomize``. +def _child_bootstrap_dir() -> str | None: + """Absolute path to the directory holding the child TLS bootstrap files. Resolves for both source-installed and frozen (PyInstaller) layouts. Returns ``None`` if the path cannot be determined -- callers degrade gracefully. @@ -162,26 +176,62 @@ def _child_shim_dir() -> str | None: return None -def build_child_tls_env(base_env: Mapping[str, str]) -> dict[str, str]: - """Return a child env that re-runs the trust bootstrap at its startup. +def _venv_site_packages(venv_path: Path) -> Path | None: + """Return the site-packages dir of *venv_path*, or ``None`` if not found. + + Handles the POSIX ``lib/pythonX.Y/site-packages`` and the Windows + ``Lib/site-packages`` layouts. Picks the first existing match. + """ + candidates = list(venv_path.glob("lib/python*/site-packages")) + candidates.extend(venv_path.glob("Lib/site-packages")) + for candidate in candidates: + if candidate.is_dir(): + return candidate + return None - Prepends the ``sitecustomize`` shim directory to ``PYTHONPATH`` (preserving - any existing value) so each Python child imports the shim and re-invokes - :func:`configure_tls_trust` -- single source of truth, no logic duplicated. - * ``APM_DISABLE_TRUSTSTORE`` truthy: return the env unchanged (no shim). - * An explicit CA override still gets the shim; the shim's own - ``configure_tls_trust`` declines to inject and leaves the bundle intact. +def ensure_child_tls_bootstrap(venv_path: str | os.PathLike[str]) -> bool: + """Install the self-contained OS-trust bootstrap into a child venv. + + Copies ``_apm_tls_bootstrap.py`` and ``_apm_tls.pth`` into the venv's + site-packages so its interpreter injects ``truststore`` at startup -- no + ``apm_cli`` dependency, no ``PYTHONPATH`` mutation. Python-driven (rather + than shell-globbed) so it resolves the shipped source identically for a + source install (package dir) and a frozen binary (``sys._MEIPASS``). + + Idempotent and best-effort: returns ``True`` when both files are present + after the call, ``False`` on any failure. Never raises. """ - child = dict(base_env) + try: + site_packages = _venv_site_packages(Path(venv_path)) + if site_packages is None: + return False + source_dir = _child_bootstrap_dir() + if not source_dir: + return False + source = Path(source_dir) + for name in (_BOOTSTRAP_MODULE_FILE, _BOOTSTRAP_PTH_FILE): + src_file = source / name + if not src_file.is_file(): + return False + shutil.copyfile(src_file, site_packages / name) + return True + except Exception: + return False + - if _env_flag(_DISABLE_ENV_VAR, base_env): - return child +def build_child_tls_env(base_env: Mapping[str, str]) -> dict[str, str]: + """Return a copy of *base_env* scrubbed for a spawned child runtime. - shim_dir = _child_shim_dir() - if not shim_dir: - return child + Trust is now delivered at venv-setup time via the ``.pth`` bootstrap + (:func:`ensure_child_tls_bootstrap`), NOT at spawn time via ``PYTHONPATH`` + -- prepending a shim dir would shadow a user/corporate ``sitecustomize.py`` + and only reached children that shared this process's ``sys.path``. - existing = child.get("PYTHONPATH", "") - child["PYTHONPATH"] = shim_dir + os.pathsep + existing if existing else shim_dir + This function is now an env-hygiene pass: it strips the internal + bundled-default marker so the frozen binary's ``SSL_CERT_FILE`` marker never + leaks into a child, then returns the env unchanged otherwise. + """ + child = dict(base_env) + child.pop(_BUNDLED_CERT_MARKER, None) return child diff --git a/src/apm_cli/runtime/manager.py b/src/apm_cli/runtime/manager.py index d66155234..329a70a7c 100644 --- a/src/apm_cli/runtime/manager.py +++ b/src/apm_cli/runtime/manager.py @@ -13,7 +13,7 @@ import click from colorama import Fore, Style -from ..core.tls_trust import build_child_tls_env +from ..core.tls_trust import build_child_tls_env, ensure_child_tls_bootstrap from ..core.token_manager import setup_runtime_environment @@ -254,6 +254,14 @@ def setup_runtime( success = self.run_embedded_script(script_content, common_content, script_args) if success: + # Deliver the self-contained OS-trust bootstrap into the llm + # runtime venv so its interpreter (which has neither apm_cli nor + # truststore on PYTHONPATH) verifies HTTPS against the OS store. + # Done in Python -- not the shell script -- so the shipped + # bootstrap files resolve identically for source and frozen apm. + if runtime_name == "llm": + self._install_llm_tls_bootstrap() + click.echo( f"{Fore.GREEN}[+] Successfully set up {runtime_name} runtime{Style.RESET_ALL}" ) @@ -271,6 +279,24 @@ def setup_runtime( ) return False + def _install_llm_tls_bootstrap(self) -> None: + """Drop the OS-trust bootstrap into the llm runtime venv (best-effort). + + The llm venv is created by ``setup-llm.sh`` with ``truststore`` + installed; this copies the ``.pth`` bootstrap into its site-packages so + the child interpreter injects the OS trust store at startup. Silent and + non-fatal -- a bootstrap failure must not fail runtime setup. + """ + venv_path = self.runtime_dir / "llm-venv" + try: + if not ensure_child_tls_bootstrap(venv_path): + click.echo( + f"{Fore.YELLOW}[!] Could not install OS-trust bootstrap into the llm venv; " + f"the llm runtime may fall back to bundled CAs behind a proxy.{Style.RESET_ALL}" + ) + except Exception: + pass + def list_runtimes(self) -> dict[str, dict[str, str]]: """List available and installed runtimes.""" runtimes = {} diff --git a/tests/integration/test_tls_child_runtime.py b/tests/integration/test_tls_child_runtime.py index f4326669e..e9d0f8038 100644 --- a/tests/integration/test_tls_child_runtime.py +++ b/tests/integration/test_tls_child_runtime.py @@ -1,27 +1,29 @@ -"""B1 verifier: OS-store trust must propagate into a REAL child process. - -The parent cannot monkeypatch a child runtime's ``ssl`` module across ``exec``; -``build_child_tls_env`` must carry trust across the process boundary by -prepending the ``sitecustomize`` shim dir to the child ``PYTHONPATH`` so the -child re-runs :func:`configure_tls_trust` at its own interpreter startup. - -These are independent end-to-end tests written from the acceptance spec for -PR #2005 -- they spawn genuine child interpreters and prove: - -* the shim actually executes in the child (``ssl.SSLContext`` becomes - truststore-backed) and does so only via the env, not inheritance; -* a real ``requests.get`` against a private-CA HTTPS server succeeds only when - trust is delivered through the ``build_child_tls_env``-produced env, and fails - with the identical child under a plain env (the asymmetry is the proof); -* ``APM_DISABLE_TRUSTSTORE`` suppresses the shim entirely. - -Platform note: on macOS ``truststore`` verifies against the system keychain and -does not consult ``SSL_CERT_FILE``, so the file-based system default cannot be -honored through truststore there. On macOS the env-delivered trust is therefore -proven via ``REQUESTS_CA_BUNDLE`` carried through the same -``build_child_tls_env``-produced env; on Linux ``SSL_CERT_FILE`` is honored by -truststore's ``set_default_verify_paths()``. Either way the trust reaches the -child solely through the env this feature builds. +"""C1 verifier: OS-trust must reach a FOREIGN child venv via the .pth bootstrap. + +The flagship ``llm`` runtime runs in its own venv (``~/.apm/runtimes/llm-venv``) +that has NEITHER ``apm_cli`` NOR (historically) ``truststore``. The round-1 +design re-ran ``configure_tls_trust`` in the child by prepending a +``sitecustomize`` shim dir to the child ``PYTHONPATH``; in the real ``llm`` venv +that import failed silently, so the child fell back to ``certifi`` and ``apm +run`` still failed behind a proxy. It also shadowed any user ``sitecustomize``. + +The round-2 mechanism delivers trust at venv-setup time instead: APM installs +``truststore`` into the runtime venv and copies a self-contained ``.pth`` +bootstrap into its site-packages, so the child interpreter injects the OS trust +store at startup with no ``apm_cli`` dependency and no ``PYTHONPATH`` mutation. + +These tests spawn a genuine FOREIGN venv (created with ``python -m venv``, WITH +NO ``apm_cli`` installed) and prove: + +* C1 -- with the shipped ``_apm_tls_bootstrap.py`` + ``_apm_tls.pth`` dropped in, + the child's ``ssl.SSLContext`` becomes truststore-backed; remove the ``.pth`` + and it reverts to stdlib ``ssl`` (the asymmetry is the proof). +* T2 -- the ``.pth`` is additive: a pre-existing user ``sitecustomize.py`` in the + same venv still runs AND truststore still injects. + +Offline-by-design: ``truststore`` is copied from the running dev environment +into the foreign venv rather than ``pip install``-ed, so the tests need no +network. The interpreter under test is still a foreign venv without ``apm_cli``. """ from __future__ import annotations @@ -31,181 +33,137 @@ import shutil import subprocess import sys +from pathlib import Path import pytest -from apm_cli.core.tls_trust import build_child_tls_env +from apm_cli.core.tls_trust import _child_bootstrap_dir, _venv_site_packages pytestmark = pytest.mark.integration -_TRUST_ENV_VARS = ( - "REQUESTS_CA_BUNDLE", - "CURL_CA_BUNDLE", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "APM_DISABLE_TRUSTSTORE", - "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT", -) - _truststore_missing = importlib.util.find_spec("truststore") is None _requires_truststore = pytest.mark.skipif( _truststore_missing, reason="truststore not importable in this environment" ) -_requires_openssl = pytest.mark.skipif( - shutil.which("openssl") is None, reason="openssl CLI not available" -) # Child that reports which module owns ssl.SSLContext -- truststore-backed after -# the shim runs, plain "ssl" otherwise. +# the bootstrap runs, plain "ssl" otherwise. _SSL_MODULE_PROBE = "import ssl; print(ssl.SSLContext.__module__)" -# Child that performs a real HTTPS GET and prints exactly one RESULT token. -_REQUEST_PROBE = ( - "import sys\n" - "import ssl\n" - "import requests\n" - "try:\n" - " r = requests.get(sys.argv[1], timeout=5)\n" - " print('RESULT:OK' if r.status_code == 200 else 'RESULT:BAD')\n" - "except (ssl.SSLError, requests.exceptions.SSLError):\n" - " print('RESULT:SSLERROR')\n" +_TRUST_ENV_VARS = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "APM_DISABLE_TRUSTSTORE", + "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT", + "PYTHONPATH", ) -@pytest.fixture(autouse=True) -def _isolate_trust(): - """Undo any global truststore/ssl injection so parent state cannot bleed in or out. - - These tests spawn isolated child interpreters, but the in-parent private-CA - server must be built with the stdlib ssl backend -- a prior test that left - truststore injected would otherwise break the server's wrap_socket. - """ - import ssl as _ssl +def _clean_env() -> dict[str, str]: + """os.environ copy with every trust-related var stripped (pristine start).""" + return {k: v for k, v in os.environ.items() if k not in _TRUST_ENV_VARS} - saved_ctx = _ssl.SSLContext - try: - import truststore - truststore.extract_from_ssl() - except Exception: - pass - try: - yield - finally: - try: - import truststore +def _venv_python(venv: Path) -> Path: + """Return the interpreter path inside *venv* for the current platform.""" + if sys.platform == "win32": + return venv / "Scripts" / "python.exe" + return venv / "bin" / "python" - truststore.extract_from_ssl() - except Exception: - pass - _ssl.SSLContext = saved_ctx +def _make_foreign_venv(root: Path) -> tuple[Path, Path]: + """Create a foreign venv (no apm_cli) and return (venv_python, site_packages). -def _clean_base_env() -> dict[str, str]: - """os.environ copy with every trust-related var stripped (pristine start).""" - return {k: v for k, v in os.environ.items() if k not in _TRUST_ENV_VARS} + ``truststore`` is copied in from the running dev environment so the test is + fully offline; ``apm_cli`` is deliberately NOT installed so the interpreter + matches the real ``llm`` runtime venv. + """ + venv = root / "foreign-venv" + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(venv)], + check=True, + capture_output=True, + ) + site_packages = _venv_site_packages(venv) + assert site_packages is not None, "could not locate foreign venv site-packages" + import truststore -def _ca_delivery_env(base: dict[str, str], ca_path: str) -> dict[str, str]: - """Return *base* augmented so an honest child would trust *ca_path*. + ts_src = Path(truststore.__file__).resolve().parent + shutil.copytree(ts_src, site_packages / "truststore") - Uses the delivery channel the platform's truststore backend actually - honors: ``REQUESTS_CA_BUNDLE`` on macOS (keychain backend ignores - ``SSL_CERT_FILE``), ``SSL_CERT_FILE`` on Linux/other. - """ - env = dict(base) - if sys.platform == "darwin": - env["REQUESTS_CA_BUNDLE"] = ca_path - else: - env["SSL_CERT_FILE"] = ca_path - return env + return _venv_python(venv), site_packages -@_requires_truststore -def test_shim_runs_in_child_via_env_not_inheritance(): - """Test 1: build_child_tls_env makes ssl.SSLContext truststore-backed in the child.""" - base = _clean_base_env() +def _drop_bootstrap(site_packages: Path) -> None: + """Copy the shipped bootstrap module + .pth into *site_packages*.""" + source = Path(_child_bootstrap_dir()) + shutil.copyfile(source / "_apm_tls_bootstrap.py", site_packages / "_apm_tls_bootstrap.py") + shutil.copyfile(source / "_apm_tls.pth", site_packages / "_apm_tls.pth") - with_shim = subprocess.run( - [sys.executable, "-c", _SSL_MODULE_PROBE], - env=build_child_tls_env(base), - capture_output=True, - text=True, - ) - assert with_shim.returncode == 0, with_shim.stderr - assert with_shim.stdout.strip().startswith("truststore"), ( - f"child ssl module should be truststore-backed, got {with_shim.stdout!r}" - ) - # Control: a plain env (no shim dir on PYTHONPATH) must stay on stdlib ssl, - # proving the trust crosses the boundary via the env, not process inheritance. - control = subprocess.run( - [sys.executable, "-c", _SSL_MODULE_PROBE], - env=base, +def _probe_ssl_module(venv_python: Path) -> str: + result = subprocess.run( + [str(venv_python), "-c", _SSL_MODULE_PROBE], + env=_clean_env(), capture_output=True, text=True, ) - assert control.returncode == 0, control.stderr - assert control.stdout.strip() == "ssl", ( - f"control child should use stdlib ssl, got {control.stdout!r}" - ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() @_requires_truststore -@_requires_openssl -def test_real_child_gains_trust_only_through_env(tmp_path): - """Test 2: a real requests.get in a child succeeds only via the env-delivered trust.""" - from ._tls_ca_server import private_ca_https_server - - with private_ca_https_server(tmp_path) as server: - base = _clean_base_env() - - # Trust delivered ONLY through the build_child_tls_env-produced env. - trusted_env = build_child_tls_env(_ca_delivery_env(base, server.ca_path)) - trusted = subprocess.run( - [sys.executable, "-c", _REQUEST_PROBE, server.url], - env=trusted_env, - capture_output=True, - text=True, - ) - assert trusted.returncode == 0, trusted.stderr - # No shim contamination: stdout is exactly the RESULT token, stderr empty. - assert trusted.stdout.strip() == "RESULT:OK", ( - f"trusted child stdout should be only RESULT:OK, got {trusted.stdout!r}" - ) - assert trusted.stderr == "", f"shim must not write to child stderr, got {trusted.stderr!r}" - - # Control: identical child, plain env (private CA absent) -> SSL failure. - control = subprocess.run( - [sys.executable, "-c", _REQUEST_PROBE, server.url], - env=base, - capture_output=True, - text=True, - ) - assert control.returncode == 0, control.stderr - assert control.stdout.strip() == "RESULT:SSLERROR", ( - f"control child should fail verification, got {control.stdout!r}" - ) +def test_foreign_venv_bootstrap_injects_truststore(tmp_path): + """C1: the shipped .pth bootstrap makes a foreign venv verify via the OS store.""" + venv_python, site_packages = _make_foreign_venv(tmp_path) + + # Control first: no bootstrap -> stdlib ssl. Proves the venv is foreign and + # would otherwise verify against certifi (the field failure mode). + assert _probe_ssl_module(venv_python) == "ssl", "foreign venv should start on stdlib ssl" + + # Drop the bootstrap -> the child's ssl becomes truststore-backed. + _drop_bootstrap(site_packages) + module = _probe_ssl_module(venv_python) + assert module.startswith("truststore"), ( + f"child ssl module should be truststore-backed after bootstrap, got {module!r}" + ) @_requires_truststore -def test_disable_flag_suppresses_child_shim(): - """Test 3: APM_DISABLE_TRUSTSTORE keeps the shim out and the child on stdlib ssl.""" - base = _clean_base_env() - base["APM_DISABLE_TRUSTSTORE"] = "1" - - child_env = build_child_tls_env(base) - shim_dir = os.path.dirname(importlib.util.find_spec("apm_cli.core.tls_trust").origin) - # The shim dir must NOT have been prepended to PYTHONPATH. - assert os.path.join(shim_dir, "_child_tls") not in child_env.get("PYTHONPATH", "") +def test_bootstrap_is_additive_to_user_sitecustomize(tmp_path): + """T2: the .pth bootstrap does not shadow a user sitecustomize -- both run.""" + venv_python, site_packages = _make_foreign_venv(tmp_path) + _drop_bootstrap(site_packages) + + sentinel = tmp_path / "sitecustomize-ran.txt" + (site_packages / "sitecustomize.py").write_text( + "\n".join( + [ + "import os", + "import pathlib", + "pathlib.Path(os.environ['APM_TEST_SENTINEL']).write_text('ran', encoding='utf-8')", + ] + ), + encoding="utf-8", + ) + env = _clean_env() + env["APM_TEST_SENTINEL"] = str(sentinel) result = subprocess.run( - [sys.executable, "-c", _SSL_MODULE_PROBE], - env=child_env, + [str(venv_python), "-c", _SSL_MODULE_PROBE], + env=env, capture_output=True, text=True, ) assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "ssl", ( - f"disabled child should use stdlib ssl, got {result.stdout!r}" + + # The user sitecustomize ran (bootstrap did not shadow it)... + assert sentinel.exists(), "user sitecustomize.py must still run alongside the .pth bootstrap" + assert sentinel.read_text(encoding="utf-8") == "ran" + # ...AND truststore still injected (the .pth is additive, not exclusive). + assert result.stdout.strip().startswith("truststore"), ( + f"truststore must still inject with a user sitecustomize present, got {result.stdout!r}" ) diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index 8cc94e749..e6c691149 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -21,9 +21,12 @@ import pytest from apm_cli.core.tls_trust import ( + _BUNDLED_CERT_MARKER, _DISABLE_ENV_VAR, _EXPLICIT_CA_ENV_VARS, + build_child_tls_env, configure_tls_trust, + ensure_child_tls_bootstrap, has_explicit_ca_override, ) @@ -269,3 +272,126 @@ def test_diag_import_failure_names_certifi_fallback(monkeypatch, caplog): ), messages for message in messages: message.encode("ascii") + + +# --------------------------------------------------------------------------- +# T4 -- the internal bundled-default marker must NEVER leak out of +# configure_tls_trust, on ANY of its return branches. A leaked marker would tell +# a child interpreter to pop a SSL_CERT_FILE that is not actually a bundled +# default, silently weakening trust. +# --------------------------------------------------------------------------- + + +def _marker_env(**extra): + env = {_BUNDLED_CERT_MARKER: "1"} + env.update(extra) + return env + + +def test_marker_cleared_on_opt_out_branch(monkeypatch): + _install_fake_truststore(monkeypatch) + env = _marker_env(**{_DISABLE_ENV_VAR: "1"}) + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_marker_cleared_on_explicit_override_branch(monkeypatch): + _install_fake_truststore(monkeypatch) + env = _marker_env(REQUESTS_CA_BUNDLE="/etc/ssl/corp.pem") + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_marker_cleared_on_truststore_import_failure(monkeypatch): + monkeypatch.setitem(sys.modules, "truststore", None) + env = _marker_env(SSL_CERT_FILE="/bundled/certifi.pem") + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_marker_cleared_on_inject_failure(monkeypatch): + def _boom(): + raise RuntimeError("platform trust API unavailable") + + _install_fake_truststore(monkeypatch, inject=_boom) + env = _marker_env(SSL_CERT_FILE="/bundled/certifi.pem") + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + # certifi fallback restored (never zero trust). + assert env.get("SSL_CERT_FILE") == "/bundled/certifi.pem" + + +def test_marker_cleared_on_inject_success(monkeypatch): + _install_fake_truststore(monkeypatch) + env = _marker_env(SSL_CERT_FILE="/bundled/certifi.pem") + assert configure_tls_trust(env=env) is True + assert _BUNDLED_CERT_MARKER not in env + # bundled default popped so the OS store is consulted. + assert "SSL_CERT_FILE" not in env + + +# --------------------------------------------------------------------------- +# build_child_tls_env is now an env-hygiene pass: it strips the bundled-default +# marker and does NOT mutate PYTHONPATH (no more sitecustomize shim hijack). +# --------------------------------------------------------------------------- + + +def test_build_child_tls_env_strips_marker(): + base = {_BUNDLED_CERT_MARKER: "1", "PATH": "/usr/bin", "FOO": "bar"} + child = build_child_tls_env(base) + assert _BUNDLED_CERT_MARKER not in child + assert child["PATH"] == "/usr/bin" + assert child["FOO"] == "bar" + + +def test_build_child_tls_env_does_not_touch_pythonpath(): + base = {"PYTHONPATH": "/user/site"} + child = build_child_tls_env(base) + # No shim dir prepended -- a user/corporate PYTHONPATH survives untouched. + assert child["PYTHONPATH"] == "/user/site" + + +def test_build_child_tls_env_returns_independent_copy(): + base = {"PATH": "/usr/bin"} + child = build_child_tls_env(base) + child["PATH"] = "/mutated" + assert base["PATH"] == "/usr/bin" + + +# --------------------------------------------------------------------------- +# T7 -- ensure_child_tls_bootstrap drops both delivery artifacts into a venv's +# site-packages so the child interpreter can import the bootstrap. +# --------------------------------------------------------------------------- + + +def _fake_venv(tmp_path: Path) -> Path: + """Create a POSIX-style venv skeleton with an empty site-packages dir.""" + site = tmp_path / "venv" / "lib" / "python3.12" / "site-packages" + site.mkdir(parents=True) + return tmp_path / "venv" + + +def test_ensure_child_tls_bootstrap_installs_both_files(tmp_path): + venv = _fake_venv(tmp_path) + assert ensure_child_tls_bootstrap(venv) is True + + site = venv / "lib" / "python3.12" / "site-packages" + module = site / "_apm_tls_bootstrap.py" + pth = site / "_apm_tls.pth" + assert module.is_file() + assert pth.is_file() + # The .pth is exactly the one-line import that triggers the bootstrap. + assert pth.read_text(encoding="utf-8").strip() == "import _apm_tls_bootstrap" + # The bootstrap has no apm_cli dependency (self-contained). + assert "import apm_cli" not in module.read_text(encoding="utf-8") + + +def test_ensure_child_tls_bootstrap_is_idempotent(tmp_path): + venv = _fake_venv(tmp_path) + assert ensure_child_tls_bootstrap(venv) is True + assert ensure_child_tls_bootstrap(venv) is True + + +def test_ensure_child_tls_bootstrap_returns_false_for_missing_site_packages(tmp_path): + # A path with no venv site-packages layout -> best-effort False, no raise. + assert ensure_child_tls_bootstrap(tmp_path / "does-not-exist") is False diff --git a/tests/unit/test_tls_docs_scope.py b/tests/unit/test_tls_docs_scope.py new file mode 100644 index 000000000..355b3baef --- /dev/null +++ b/tests/unit/test_tls_docs_scope.py @@ -0,0 +1,53 @@ +"""T3: the #2005 docs/CHANGELOG must scope OS-trust honestly. + +Round-1 shipped copy claiming ``apm run`` child runtimes (incl. ``codex``) +re-run the OS-trust bootstrap. That was a field no-op for the ``llm`` venv and +never true for the Node/Rust runtimes. These tests are the silent-drift guard +that keeps the prose scoped to what actually ships: ``apm install`` plus the +Python ``llm`` runtime, with Node (Copilot) / Rust (Codex) tracked in #2034. +""" + +from __future__ import annotations + +from pathlib import Path + + +def _repo_root() -> Path: + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("Cannot locate repository root") + + +def _unreleased_block(changelog: str) -> str: + start = changelog.index("## [Unreleased]") + rest = changelog[start + len("## [Unreleased]") :] + end = rest.find("\n## [") + return rest if end == -1 else rest[:end] + + +def test_changelog_scopes_os_trust_and_references_followup(): + changelog = (_repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + block = _unreleased_block(changelog) + + # Follow-up issue for the uncovered runtimes must be cited. + assert "#2034" in block, "CHANGELOG must reference the Node/Rust follow-up (#2034)" + # The honest scope: llm runtime named, Node/Codex explicitly not-yet-covered. + assert "`llm`" in block + assert "not yet covered" in block + # The stale round-1 joint claim must be gone. + assert "and `apm run` (child runtimes)" not in block + + +def test_ssl_docs_scope_and_known_limitations(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).read_text(encoding="utf-8") + + assert "### Known limitations" in docs, "ssl-issues.md must have a Known limitations section" + assert "#2034" in docs, "ssl-issues.md must reference the Node/Rust follow-up (#2034)" + # Node (Copilot) / Rust (Codex) must be described as NOT covered. + assert "not yet covered" in docs + # The stale round-1 claim that codex re-runs the bootstrap must be gone. + assert "the `llm` and `codex` CLIs) re-run the same OS-trust bootstrap" not in docs From 381dd4e4628048107e7a36b03f33b89a92af9b7e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 12:45:46 +0200 Subject: [PATCH 15/27] test(tls): independent round-2 verification of foreign-venv OS-trust delivery Adds a from-scratch verifier suite (V1-V6) proving PR #2005's install-time .pth bootstrap delivers OS-trust into a genuine FOREIGN venv that cannot import apm_cli -- the exact field scenario the round-1 PYTHONPATH/sitecustomize shim silently no-op'd on. Independent assertions (own probes/sentinels), not a reuse of the implementer's tests: - V1: foreign venv (no apm_cli) + shipped bootstrap -> truststore-backed ssl; remove the .pth -> reverts to stdlib ssl (asymmetry is the proof). Bootstrap carries zero apm_cli imports. - V2: a pre-existing user sitecustomize.py still runs AND truststore injects (no hijack). - V3: ensure_child_tls_bootstrap lands both files in a real venv and that interpreter injects while apm_cli stays unimportable. - V4: marker cleared on all 5 configure_tls_trust branches; bundled SSL_CERT_FILE popped before a successful inject, restored when inject raises. - V6: build_child_tls_env strips the marker with no PYTHONPATH mutation. Offline-by-design (truststore copied from the dev env, not pip-installed). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/integration/test_tls_r2_verify.py | 335 ++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 tests/integration/test_tls_r2_verify.py diff --git a/tests/integration/test_tls_r2_verify.py b/tests/integration/test_tls_r2_verify.py new file mode 100644 index 000000000..3e219217d --- /dev/null +++ b/tests/integration/test_tls_r2_verify.py @@ -0,0 +1,335 @@ +"""Independent round-2 verification of PR #2005 OS-trust child-runtime delivery. + +Written FROM SCRATCH by the verifier (not the implementer) to prove -- on a +surface the implementer could not have gamed -- that the shipped ``.pth`` +bootstrap delivers OS-trust into a FOREIGN venv that cannot import ``apm_cli``. +That foreign venv is the exact field scenario the round-1 ``PYTHONPATH``/ +``sitecustomize`` shim failed on: the real ``llm`` runtime lives in +``~/.apm/runtimes/llm-venv`` which has neither ``apm_cli`` nor (historically) +``truststore``, so the round-1 ``from apm_cli...`` import silently no-op'd and +the child fell back to ``certifi``. + +Assertions here are intentionally independent of the implementer's tests: + +* V1 -- a genuine foreign venv (no ``apm_cli``) with the shipped bootstrap + dropped in verifies HTTPS via ``truststore``; removing the ``.pth`` reverts to + stdlib ``ssl`` (the asymmetry is the proof, not the presence). +* V2 -- the ``.pth`` is additive: a pre-existing user ``sitecustomize.py`` still + runs (a distinct ``_VERIF_*`` sentinel) AND ``truststore`` still injects. +* V3 -- the real delivery helper ``ensure_child_tls_bootstrap`` lands both files + in a real venv's site-packages and that interpreter then injects, with + ``apm_cli`` still not importable in it. +* V4 -- ``configure_tls_trust`` clears the bundled-default marker on all five + return paths, pops the bundled ``SSL_CERT_FILE`` before a successful inject, + and restores it when inject raises. +* V6 -- ``build_child_tls_env`` performs no ``PYTHONPATH`` mutation and strips + the internal marker. + +Offline-by-design: ``truststore`` is copied from the running dev environment +into the foreign venv rather than ``pip install``-ed. The interpreter under test +is still a foreign venv with NO ``apm_cli``. +""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import subprocess +import sys +import types +from pathlib import Path + +import pytest + +from apm_cli.core.tls_trust import ( + _BUNDLED_CERT_MARKER, + _DISABLE_ENV_VAR, + _child_bootstrap_dir, + _venv_site_packages, + build_child_tls_env, + configure_tls_trust, + ensure_child_tls_bootstrap, +) + +pytestmark = pytest.mark.integration + +_SSL_CERT_FILE = "SSL_CERT_FILE" + +# Independent probe: report the owning module of ssl.SSLContext. truststore +# rewires this to "truststore._api"; a stock interpreter reports plain "ssl". +_SSL_OWNER_PROBE = "import ssl; print(ssl.SSLContext.__module__)" + +# Sentinel used ONLY by this verifier's non-shadowing check (distinct name so it +# cannot collide with the implementer's APM_TEST_SENTINEL). +_SITECUSTOMIZE_SENTINEL = "_VERIF_SITECUSTOMIZE_RAN" + +_truststore_unavailable = importlib.util.find_spec("truststore") is None +_needs_truststore = pytest.mark.skipif( + _truststore_unavailable, + reason="truststore not importable in the dev environment (needed to seed the foreign venv)", +) + +# Env vars that must be cleared so the foreign-venv probe starts pristine. +_STRIP_ENV = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + _SSL_CERT_FILE, + "SSL_CERT_DIR", + _DISABLE_ENV_VAR, + _BUNDLED_CERT_MARKER, + "PYTHONPATH", + _SITECUSTOMIZE_SENTINEL, +) + + +def _pristine_env() -> dict[str, str]: + """A copy of os.environ with every trust-related variable removed.""" + return {k: v for k, v in os.environ.items() if k not in _STRIP_ENV} + + +def _venv_interpreter(venv: Path) -> Path: + if sys.platform == "win32": + return venv / "Scripts" / "python.exe" + return venv / "bin" / "python" + + +def _seed_foreign_venv(root: Path) -> tuple[Path, Path]: + """Create a real venv WITHOUT apm_cli and seed truststore into it. + + Returns ``(interpreter, site_packages)``. Uses ``sys.executable`` (the dev + interpreter, >=3.10) so the seeded truststore actually imports; the point is + that ``apm_cli`` is absent, matching the shipped ``llm`` runtime venv. + """ + venv = root / "foreign" + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(venv)], + check=True, + capture_output=True, + ) + site_packages = _venv_site_packages(venv) + assert site_packages is not None, "could not locate foreign venv site-packages" + + import truststore + + truststore_pkg = Path(truststore.__file__).resolve().parent + shutil.copytree(truststore_pkg, site_packages / "truststore") + return _venv_interpreter(venv), site_packages + + +def _copy_shipped_bootstrap(site_packages: Path) -> Path: + """Copy the SHIPPED bootstrap module + .pth into *site_packages*. + + Returns the path of the copied ``.pth`` so a test can delete it for the + control probe. + """ + source = Path(_child_bootstrap_dir()) + shutil.copyfile(source / "_apm_tls_bootstrap.py", site_packages / "_apm_tls_bootstrap.py") + pth = site_packages / "_apm_tls.pth" + shutil.copyfile(source / "_apm_tls.pth", pth) + return pth + + +def _run_probe(interpreter: Path, probe: str, env: dict[str, str] | None = None) -> str: + result = subprocess.run( + [str(interpreter), "-c", probe], + env=_pristine_env() if env is None else env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"probe failed: {result.stderr}" + return result.stdout.strip() + + +def _assert_no_apm_cli(interpreter: Path) -> None: + """Fail unless ``import apm_cli`` genuinely fails in *interpreter*.""" + result = subprocess.run( + [str(interpreter), "-c", "import apm_cli"], + env=_pristine_env(), + capture_output=True, + text=True, + ) + assert result.returncode != 0, "foreign venv must NOT be able to import apm_cli" + assert "ModuleNotFoundError" in result.stderr or "No module named" in result.stderr + + +# --------------------------------------------------------------------------- V1 + + +@_needs_truststore +def test_v1_foreign_venv_without_apm_cli_gets_os_trust(tmp_path): + """V1: the .pth bootstrap injects truststore in a venv that cannot import apm_cli.""" + interpreter, site_packages = _seed_foreign_venv(tmp_path) + + # The venv is genuinely foreign: apm_cli is unreachable (round-1 field case). + _assert_no_apm_cli(interpreter) + + # Control BEFORE the bootstrap: stock stdlib ssl (would verify via certifi). + assert _run_probe(interpreter, _SSL_OWNER_PROBE) == "ssl" + + # Drop the SHIPPED bootstrap -> the child's ssl becomes truststore-backed. + pth = _copy_shipped_bootstrap(site_packages) + owner = _run_probe(interpreter, _SSL_OWNER_PROBE) + assert owner.startswith("truststore"), ( + f"expected truststore-backed ssl after bootstrap, got {owner!r}" + ) + + # Control AFTER: remove ONLY the .pth (leave the module) -> revert to stdlib + # ssl. Proves the .pth is the driver, not incidental import side effects. + pth.unlink() + assert _run_probe(interpreter, _SSL_OWNER_PROBE) == "ssl" + + +def test_v1_bootstrap_has_zero_apm_cli_imports(): + """V1: the shipped bootstrap must carry no apm_cli import dependency.""" + source = Path(_child_bootstrap_dir()) / "_apm_tls_bootstrap.py" + body = source.read_text(encoding="utf-8") + for line in body.splitlines(): + stripped = line.strip() + assert not stripped.startswith("import apm_cli"), "bootstrap must not import apm_cli" + assert not stripped.startswith("from apm_cli"), "bootstrap must not import from apm_cli" + + +# --------------------------------------------------------------------------- V2 + + +@_needs_truststore +def test_v2_pth_does_not_shadow_user_sitecustomize(tmp_path): + """V2: a pre-existing user sitecustomize.py still runs AND truststore injects.""" + interpreter, site_packages = _seed_foreign_venv(tmp_path) + _copy_shipped_bootstrap(site_packages) + + # A user/corporate sitecustomize that records it ran via a distinct sentinel. + (site_packages / "sitecustomize.py").write_text( + f'import os\nos.environ["{_SITECUSTOMIZE_SENTINEL}"] = "1"\n', + encoding="utf-8", + ) + + probe = ( + "import os, ssl;" + f' print(os.environ.get("{_SITECUSTOMIZE_SENTINEL}"));' + " print(ssl.SSLContext.__module__)" + ) + out = _run_probe(interpreter, probe).splitlines() + sentinel_value, ssl_owner = out[0].strip(), out[1].strip() + + assert sentinel_value == "1", "user sitecustomize.py must still run (no hijack)" + assert ssl_owner.startswith("truststore"), "bootstrap must ALSO run alongside sitecustomize" + + +# --------------------------------------------------------------------------- V3 + + +@_needs_truststore +def test_v3_delivery_helper_installs_and_injects(tmp_path): + """V3: ensure_child_tls_bootstrap lands both files and the venv then injects.""" + interpreter, site_packages = _seed_foreign_venv(tmp_path) + venv_root = tmp_path / "foreign" + + installed = ensure_child_tls_bootstrap(venv_root) + assert installed is True, "ensure_child_tls_bootstrap should report success" + assert (site_packages / "_apm_tls_bootstrap.py").is_file() + assert (site_packages / "_apm_tls.pth").is_file() + + # The venv still cannot import apm_cli, yet its interpreter now injects. + _assert_no_apm_cli(interpreter) + owner = _run_probe(interpreter, _SSL_OWNER_PROBE) + assert owner.startswith("truststore"), f"helper-installed bootstrap must inject, got {owner!r}" + + # Idempotent re-run and graceful failure on a non-venv path. + assert ensure_child_tls_bootstrap(venv_root) is True + assert ensure_child_tls_bootstrap(tmp_path / "does-not-exist") is False + + +# --------------------------------------------------------------------------- V4 + + +def _fake_truststore(monkeypatch, inject): + module = types.ModuleType("truststore") + module.inject_into_ssl = inject + monkeypatch.setitem(sys.modules, "truststore", module) + + +def _bundled_env(extra: dict[str, str] | None = None) -> dict[str, str]: + env = {_BUNDLED_CERT_MARKER: "1", _SSL_CERT_FILE: "/bundled/certifi.pem"} + if extra: + env.update(extra) + return env + + +def test_v4_marker_cleared_on_opt_out_branch(): + env = _bundled_env({_DISABLE_ENV_VAR: "1"}) + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_v4_marker_cleared_on_explicit_override_branch(): + env = _bundled_env({"REQUESTS_CA_BUNDLE": "/corp/ca.pem"}) + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_v4_marker_cleared_when_truststore_import_fails(monkeypatch): + # Force `import truststore` to raise ImportError inside configure_tls_trust. + monkeypatch.setitem(sys.modules, "truststore", None) + env = _bundled_env() + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + # Import fails before the pop-before-inject step, so SSL_CERT_FILE is kept. + assert env.get(_SSL_CERT_FILE) == "/bundled/certifi.pem" + + +def test_v4_marker_cleared_and_bundled_popped_on_successful_inject(monkeypatch): + calls = {"n": 0} + + def _inject(): + calls["n"] += 1 + + _fake_truststore(monkeypatch, _inject) + env = _bundled_env() + assert configure_tls_trust(env=env) is True + assert calls["n"] == 1 + assert _BUNDLED_CERT_MARKER not in env + # B2 crypto: the bundled certifi path is popped before injection so the OS + # store is consulted (a stale SSL_CERT_FILE would shadow it). + assert _SSL_CERT_FILE not in env + + +def test_v4_marker_cleared_and_bundled_restored_when_inject_raises(monkeypatch): + def _boom(): + raise RuntimeError("inject failure") + + _fake_truststore(monkeypatch, _boom) + env = _bundled_env() + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + # B2 crypto: on failure the bundled path is RESTORED so hosts without an OS + # store still verify against certifi (never end with zero trust). + assert env.get(_SSL_CERT_FILE) == "/bundled/certifi.pem" + + +# --------------------------------------------------------------------------- V6 + + +def test_v6_build_child_tls_env_strips_marker_without_pythonpath_mutation(): + base = { + _BUNDLED_CERT_MARKER: "1", + "PATH": "/usr/bin", + "HOME": "/home/verif", + } + child = build_child_tls_env(base) + assert _BUNDLED_CERT_MARKER not in child, "internal marker must be stripped from child env" + # No PYTHONPATH injection: the round-1 shim mechanism is gone. + assert "PYTHONPATH" not in child + # Non-marker vars pass through untouched. + assert child["PATH"] == "/usr/bin" + assert child["HOME"] == "/home/verif" + # The input mapping is not mutated in place. + assert _BUNDLED_CERT_MARKER in base + + +def test_v6_build_child_tls_env_preserves_existing_pythonpath(): + """A caller-set PYTHONPATH must pass through unchanged (no TLS shim prepend).""" + base = {"PYTHONPATH": "/caller/libs", _BUNDLED_CERT_MARKER: "1"} + child = build_child_tls_env(base) + assert child.get("PYTHONPATH") == "/caller/libs" From f4a921eb8399056ffbe84ad37b44c8f01c5470df Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 13:11:13 +0200 Subject: [PATCH 16/27] fix(tls): generate child .pth inline + package-data, harden child-trust delivery (#2005) Round-3 remediation of PR #2005: - H1: generate _apm_tls.pth inline in ensure_child_tls_bootstrap so child trust no longer depends on the .pth being packaged (setuptools packages.find dropped it from the wheel). Add [tool.setuptools.package-data] belt-and- suspenders + a wheel-content regression test. - M1: best-effort, pinned (truststore>=0.10.0) install in setup-llm.sh/.ps1 so a failed install no longer aborts llm setup under set -e. - M2: build_child_tls_env drops a BUNDLED certifi SSL_CERT_FILE so the child truststore reaches the OS store on Linux; a genuine user value is preserved. - M3: write the bootstrap module + .pth atomically (temp + os.replace, module first) so a failed write leaves no truncated module under a live .pth. - M5/L1/M4-docs: surface the Node/Codex caveat early, scope CHANGELOG to Python-based, note REQUESTS_CA_BUNDLE replaces the OS store + stale-bundle hint, document the PIP_CERT setup caveat. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 19 +-- .../docs/troubleshooting/ssl-issues.md | 6 +- pyproject.toml | 6 + scripts/runtime/setup-llm.ps1 | 7 +- scripts/runtime/setup-llm.sh | 2 +- src/apm_cli/core/tls_trust.py | 109 +++++++++++++++--- tests/integration/test_tls_wheel_content.py | 68 +++++++++++ tests/unit/core/test_tls_trust.py | 94 +++++++++++++++ tests/unit/test_tls_docs_scope.py | 38 ++++++ 9 files changed, 319 insertions(+), 30 deletions(-) create mode 100644 tests/integration/test_tls_wheel_content.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b29a175e..f28d1cc7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,15 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - APM now verifies HTTPS against the OS trust store by default (via `truststore`), so it works out-of-the-box behind a corporate CA or a TLS-inspecting proxy -- matching the behaviour of `git`/`curl` on the same - host instead of failing against the bundled `certifi` set. This covers `apm - install` (in-process) and the Python-based `llm` child runtime spawned by `apm - run`, into whose venv APM installs `truststore` and a self-contained `.pth` - bootstrap at setup time. The frozen binary honours the system store as well, - with `certifi` as a genuine fallback. An explicitly set `REQUESTS_CA_BUNDLE` / - `CURL_CA_BUNDLE` still wins, and `APM_DISABLE_TRUSTSTORE=1` restores the - previous certifi-only behaviour. Node-based (Copilot) and Rust-based (Codex) - child runtimes are not yet covered and continue to use their own default - trust; tracked in #2034. (closes #2004) (#2005) + host instead of failing against the bundled `certifi` set. This covers the + Python-based paths: `apm install` (in-process) and the Python-based `llm` + child runtime spawned by `apm run`, into whose venv APM installs `truststore` + and a self-contained `.pth` bootstrap at setup time. The frozen binary honours + the system store as well, with `certifi` as a genuine fallback. An explicitly + set `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` still wins, and + `APM_DISABLE_TRUSTSTORE=1` restores the previous certifi-only behaviour. + Node-based (Copilot) and Rust-based (Codex) child runtimes are not yet covered + and continue to use their own default trust; tracked in #2034. (closes #2004) + (#2005) ## [0.24.0] - 2026-07-05 diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index df5e33ee1..006cacb12 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -58,6 +58,8 @@ apm install --verbose APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. This applies to `apm install` (in-process) and to the Python-based `llm` child runtime that `apm run` spawns: `apm runtime setup llm` installs `truststore` into the runtime's virtual environment and drops a self-contained bootstrap into it, so that interpreter also verifies against the OS store. So if your corporate CA or TLS-proxy root is installed in the OS trust store (Keychain on macOS, `update-ca-certificates`/`update-ca-trust` on Linux, the Trusted Root store on Windows), APM picks it up with **no configuration** -- importing the CA at the OS level is the recommended fix. The standalone frozen binary honours the system store as well, falling back to its bundled `certifi` set only when the OS store is unavailable. +**Scope caveat:** only the Python-based paths are covered. The Node-based (Copilot) and Rust-based (Codex) child runtimes are **not yet** wired to the OS store (tracked in #2034). Behind a TLS-proxy today, export `NODE_EXTRA_CA_CERTS=/path/to/org-ca-bundle.pem` for the Node runtime (and configure the Codex/Rust runtime's own trust) until #2034 lands. + You only need the steps below when the CA is *not* in the OS store, or you want to pin a specific bundle: - Setting `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` makes APM's HTTP layer verify against that bundle instead of the OS store. (`SSL_CERT_FILE` configures the stdlib `ssl` layer but is *not* read by `requests`, so on its own it does not override the HTTP path -- use `REQUESTS_CA_BUNDLE` for that.) @@ -66,6 +68,7 @@ You only need the steps below when the CA is *not* in the OS store, or you want ### Known limitations - The Node-based (Copilot) and Rust-based (Codex) child runtimes are **not yet covered** by OS-trust propagation and continue to use their own default trust; behind a TLS-inspecting proxy, configure those runtimes' own trust or export `REQUESTS_CA_BUNDLE`/`NODE_EXTRA_CA_CERTS` for them. Tracked in #2034. +- The initial `pip install` run *during* `apm runtime setup llm` uses pip's **own** certificate resolution, not APM's OS-trust path. Behind a MITM proxy, `pip` may fail to fetch `llm`/`truststore` before the bootstrap is even in place. Export `PIP_CERT=/path/to/org-ca-bundle.pem` (or run `pip config set global.cert /path/to/org-ca-bundle.pem`) before running setup so pip trusts your proxy CA. - An additive `APM_EXTRA_CA_BUNDLE` (trust the OS store *and* an extra bundle) is not yet available -- use `REQUESTS_CA_BUNDLE` to pin a single bundle for now. - On Windows, the `schannel` backend has trust caveats. @@ -184,6 +187,7 @@ unset GIT_SSL_NO_VERIFY PYTHONHTTPSVERIFY [>] Re-run with `--verbose` and capture the full exception chain. [>] Check `curl -v https://` from the same shell - if it fails, the problem is the system trust store, not APM. -[>] Confirm `REQUESTS_CA_BUNDLE` and `GIT_SSL_CAINFO` point at a readable PEM file (`openssl x509 -in $REQUESTS_CA_BUNDLE -noout -subject` should print a subject line). +[>] Confirm `REQUESTS_CA_BUNDLE` and `GIT_SSL_CAINFO` point at a readable PEM file (`openssl x509 -in $REQUESTS_CA_BUNDLE -noout -subject` should print a subject line). Note `REQUESTS_CA_BUNDLE` *replaces* the OS store rather than augmenting it (like `git`'s `http.sslCAInfo` and `curl --cacert`), so a bundle missing your proxy root will still fail even though the OS store has it. +[>] If `git`/`curl` succeed but `apm` does not, suspect a **stale `REQUESTS_CA_BUNDLE`** (or `CURL_CA_BUNDLE`) pinning APM to an old bundle that predates the OS store. `unset REQUESTS_CA_BUNDLE CURL_CA_BUNDLE` and retry to let APM fall back to the OS trust store. [>] If only one host fails, see [GHES and GitLab self-managed](#ghes-and-gitlab-self-managed) and the per-host `git config` recipe above. [>] If the install proceeds past TLS but then fails, continue at [install failures](./install-failures/). diff --git a/pyproject.toml b/pyproject.toml index 35b153fd5..35a9990cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,12 @@ apm = "apm_cli.cli:main" where = ["src"] include = ["apm_cli*"] +[tool.setuptools.package-data] +# Belt-and-suspenders: ship the child-TLS .pth alongside the bootstrap module. +# Delivery no longer depends on this (ensure_child_tls_bootstrap generates the +# .pth inline), but packaging it keeps the source tree and wheel in sync. +"apm_cli.core._child_tls" = ["*.pth"] + [tool.ruff] line-length = 100 target-version = "py310" diff --git a/scripts/runtime/setup-llm.ps1 b/scripts/runtime/setup-llm.ps1 index 44e6ac9d4..e4f041f1c 100644 --- a/scripts/runtime/setup-llm.ps1 +++ b/scripts/runtime/setup-llm.ps1 @@ -43,7 +43,12 @@ function Install-Llm { # bootstrap into this venv's site-packages after setup; that bootstrap # depends only on truststore, which must be present here. Write-Info "Installing truststore for OS-trust HTTPS verification..." - & $pipExe install truststore + try { + & $pipExe install "truststore>=0.10.0" + if ($LASTEXITCODE -ne 0) { throw "pip exited $LASTEXITCODE" } + } catch { + Write-WarningText "truststore install failed; llm child will fall back to bundled CAs behind a proxy" + } # Install GitHub Models plugin in non-vanilla mode if (-not $Vanilla) { diff --git a/scripts/runtime/setup-llm.sh b/scripts/runtime/setup-llm.sh index 9a3b3782b..e28493f2e 100755 --- a/scripts/runtime/setup-llm.sh +++ b/scripts/runtime/setup-llm.sh @@ -55,7 +55,7 @@ setup_llm() { # bootstrap into this venv's site-packages after setup; that bootstrap # depends only on truststore, which must be present here. log_info "Installing truststore for OS-trust HTTPS verification..." - "$llm_venv/bin/pip" install truststore + "$llm_venv/bin/pip" install "truststore>=0.10.0" || log_warning "truststore install failed; llm child will fall back to bundled CAs behind a proxy" # Install GitHub Models plugin in non-vanilla mode if [[ "$VANILLA_MODE" == "false" ]]; then diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index e7a06827f..08e4a8da8 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -25,10 +25,11 @@ from __future__ import annotations +import contextlib import logging import os -import shutil import sys +import tempfile from collections.abc import Mapping, MutableMapping from pathlib import Path @@ -57,11 +58,21 @@ # Directory (relative to the installed package) that holds the child bootstrap. _CHILD_SHIM_DIRNAME = "_child_tls" -# The two artifacts copied into a child venv's site-packages to deliver -# OS-trust at interpreter startup (see ensure_child_tls_bootstrap). +# The two artifacts delivered into a child venv's site-packages to deliver +# OS-trust at interpreter startup (see ensure_child_tls_bootstrap). The module +# ships as a data file; the .pth is generated inline (its content is trivial and +# setuptools' packages.find omits stray .pth files from the wheel). _BOOTSTRAP_MODULE_FILE = "_apm_tls_bootstrap.py" _BOOTSTRAP_PTH_FILE = "_apm_tls.pth" +# Importable name of the bootstrap module (drives the generated .pth line). +_BOOTSTRAP_MODULE_NAME = _BOOTSTRAP_MODULE_FILE.removesuffix(".py") + +# Exact content of the generated .pth: a single import line the interpreter runs +# at startup. ASCII, trailing newline. Generated inline so child-trust delivery +# never depends on the .pth being packaged into the wheel. +_PTH_CONTENT = f"import {_BOOTSTRAP_MODULE_NAME}\n" + _TRUTHY = {"1", "true", "yes", "on"} @@ -190,17 +201,47 @@ def _venv_site_packages(venv_path: Path) -> Path | None: return None +def _atomic_write(target: Path, data: bytes) -> None: + """Write *data* to *target* atomically via a same-dir temp file + os.replace. + + A same-directory temp file guarantees ``os.replace`` performs a rename + (atomic on POSIX and NTFS), so a reader never observes a truncated file + under a live ``.pth``. On any OSError the temp file is removed and the error + re-raised so the caller can report failure without leaving a partial file. + """ + fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=".apm_tls_", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + os.replace(tmp_name, target) + except OSError: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + + def ensure_child_tls_bootstrap(venv_path: str | os.PathLike[str]) -> bool: """Install the self-contained OS-trust bootstrap into a child venv. - Copies ``_apm_tls_bootstrap.py`` and ``_apm_tls.pth`` into the venv's - site-packages so its interpreter injects ``truststore`` at startup -- no - ``apm_cli`` dependency, no ``PYTHONPATH`` mutation. Python-driven (rather - than shell-globbed) so it resolves the shipped source identically for a - source install (package dir) and a frozen binary (``sys._MEIPASS``). + Writes ``_apm_tls_bootstrap.py`` (the shipped module) and a generated + ``_apm_tls.pth`` into the venv's site-packages so its interpreter injects + ``truststore`` at startup -- no ``apm_cli`` dependency, no ``PYTHONPATH`` + mutation. Python-driven (rather than shell-globbed) so it resolves the + shipped module identically for a source install (package dir) and a frozen + binary (``sys._MEIPASS``). + + The ``.pth`` content is GENERATED inline rather than copied: setuptools' + ``packages.find`` omits stray ``.pth`` data files from the wheel, so copying + it would silently no-op on the PyPI channel. Delivery therefore depends only + on the module data file, which is packaged. + + Both files are written atomically, module FIRST, so the ``.pth`` is never + present without a complete bootstrap module behind it (avoiding a truncated + module under a live ``.pth`` -> per-invocation stderr traceback storm). Idempotent and best-effort: returns ``True`` when both files are present - after the call, ``False`` on any failure. Never raises. + after the call, ``False`` on any failure (no partial file left). Never + raises. """ try: site_packages = _venv_site_packages(Path(venv_path)) @@ -209,17 +250,39 @@ def ensure_child_tls_bootstrap(venv_path: str | os.PathLike[str]) -> bool: source_dir = _child_bootstrap_dir() if not source_dir: return False - source = Path(source_dir) - for name in (_BOOTSTRAP_MODULE_FILE, _BOOTSTRAP_PTH_FILE): - src_file = source / name - if not src_file.is_file(): - return False - shutil.copyfile(src_file, site_packages / name) + module_src = Path(source_dir) / _BOOTSTRAP_MODULE_FILE + if not module_src.is_file(): + return False + # Module first (atomic), then the generated .pth (atomic) -- the write + # order guarantees the .pth never activates an incomplete module. + _atomic_write(site_packages / _BOOTSTRAP_MODULE_FILE, module_src.read_bytes()) + _atomic_write(site_packages / _BOOTSTRAP_PTH_FILE, _PTH_CONTENT.encode("ascii")) return True except Exception: return False +def _is_bundled_certifi(path: str) -> bool: + """Return True when *path* is APM's bundled certifi CA set (not a user value). + + The frozen runtime hook (build/hooks/runtime_hook_ssl_certs.py) sets + ``SSL_CERT_FILE`` to ``certifi.where()``. A genuine user-set ``SSL_CERT_FILE`` + must NEVER match. We compare against ``certifi.where()`` and, as a + frozen-safe fallback, the ``certifi/cacert.pem`` path tail (the frozen + ``_MEIPASS`` path may differ from the live dev ``certifi.where()``). + """ + if not path: + return False + if path.replace("\\", "/").endswith("certifi/cacert.pem"): + return True + try: + import certifi + + return os.path.abspath(path) == os.path.abspath(certifi.where()) + except Exception: + return False + + def build_child_tls_env(base_env: Mapping[str, str]) -> dict[str, str]: """Return a copy of *base_env* scrubbed for a spawned child runtime. @@ -228,10 +291,20 @@ def build_child_tls_env(base_env: Mapping[str, str]) -> dict[str, str]: -- prepending a shim dir would shadow a user/corporate ``sitecustomize.py`` and only reached children that shared this process's ``sys.path``. - This function is now an env-hygiene pass: it strips the internal - bundled-default marker so the frozen binary's ``SSL_CERT_FILE`` marker never - leaks into a child, then returns the env unchanged otherwise. + This function is an env-hygiene pass: + + * strips the internal bundled-default marker so the frozen binary's + ``SSL_CERT_FILE`` marker never leaks into a child, and + * drops ``SSL_CERT_FILE`` WHEN it points at the bundled certifi set, so the + child's ``truststore`` reaches the OS store on Linux (where truststore + honours ``SSL_CERT_FILE``). A frozen parent whose injection failed + restores ``SSL_CERT_FILE=certifi`` and would otherwise leak it, pinning + the child to certifi instead of the OS store. A GENUINE user + ``SSL_CERT_FILE`` is preserved -- only the bundled default is dropped. """ child = dict(base_env) child.pop(_BUNDLED_CERT_MARKER, None) + cert_file = child.get(_SSL_CERT_FILE_VAR) + if cert_file and _is_bundled_certifi(cert_file): + child.pop(_SSL_CERT_FILE_VAR, None) return child diff --git a/tests/integration/test_tls_wheel_content.py b/tests/integration/test_tls_wheel_content.py new file mode 100644 index 000000000..5839a9781 --- /dev/null +++ b/tests/integration/test_tls_wheel_content.py @@ -0,0 +1,68 @@ +"""T1 (H1 regression): the built wheel must ship the child-TLS delivery files. + +The round-2 mechanism drops ``_apm_tls_bootstrap.py`` + ``_apm_tls.pth`` into a +child venv's site-packages. A red team found the ``.pth`` was silently DROPPED +from the wheel (setuptools' ``packages.find`` ships only ``.py`` and there was +no package-data), so ``ensure_child_tls_bootstrap`` could not copy it on the +PyPI channel -> child trust was a silent no-op. + +Round-3 fixes this two ways: the ``.pth`` is generated inline (so delivery no +longer depends on packaging) AND ``[tool.setuptools.package-data]`` now ships +the ``.pth`` too. This test is the belt-and-suspenders regression guard: build +the wheel hermetically and assert BOTH files are present in the archive. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.integration, pytest.mark.slow] + + +def _repo_root() -> Path: + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("Cannot locate repository root") + + +def _build_wheel(out_dir: Path) -> Path: + """Build a wheel into *out_dir* and return its path. Prefer uv, fall back to build.""" + repo = _repo_root() + if shutil.which("uv"): + cmd = ["uv", "build", "--wheel", "--out-dir", str(out_dir)] + else: + cmd = [sys.executable, "-m", "build", "--wheel", "--outdir", str(out_dir)] + result = subprocess.run( + cmd, + cwd=str(repo), + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip( + f"wheel build unavailable in this environment: {result.stdout}\n{result.stderr}" + ) + wheels = list(out_dir.glob("*.whl")) + if not wheels: + pytest.skip("wheel build produced no .whl artifact") + return wheels[0] + + +def test_wheel_ships_child_tls_bootstrap_and_pth(tmp_path): + wheel = _build_wheel(tmp_path) + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + + module = "apm_cli/core/_child_tls/_apm_tls_bootstrap.py" + pth = "apm_cli/core/_child_tls/_apm_tls.pth" + assert module in names, f"{module} missing from wheel: {names}" + assert pth in names, f"{pth} missing from wheel: {names}" diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index e6c691149..5f93475b6 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -395,3 +395,97 @@ def test_ensure_child_tls_bootstrap_is_idempotent(tmp_path): def test_ensure_child_tls_bootstrap_returns_false_for_missing_site_packages(tmp_path): # A path with no venv site-packages layout -> best-effort False, no raise. assert ensure_child_tls_bootstrap(tmp_path / "does-not-exist") is False + + +# --------------------------------------------------------------------------- +# T2 (H1) -- the .pth is GENERATED inline, so delivery does not depend on the +# source .pth being packaged into the wheel (setuptools' packages.find drops +# stray .pth data files). Prove the content is produced, not copied. +# --------------------------------------------------------------------------- + + +def test_pth_is_generated_not_copied(tmp_path, monkeypatch): + import apm_cli.core.tls_trust as tls + + venv = _fake_venv(tmp_path) + + # Point _child_bootstrap_dir at a source dir that has the MODULE but NO + # .pth file -- if delivery copied the .pth it would fail; generation must + # still produce it. + fake_src = tmp_path / "shipped" + fake_src.mkdir() + (fake_src / "_apm_tls_bootstrap.py").write_text("# bootstrap\n", encoding="ascii") + assert not (fake_src / "_apm_tls.pth").exists() + monkeypatch.setattr(tls, "_child_bootstrap_dir", lambda: str(fake_src)) + + assert ensure_child_tls_bootstrap(venv) is True + + site = venv / "lib" / "python3.12" / "site-packages" + pth = site / "_apm_tls.pth" + assert pth.is_file() + # Exact generated content: a single import line the interpreter runs. + assert pth.read_text(encoding="ascii") == "import _apm_tls_bootstrap\n" + assert (site / "_apm_tls_bootstrap.py").read_text(encoding="ascii") == "# bootstrap\n" + + +# --------------------------------------------------------------------------- +# T4 (M2) -- build_child_tls_env drops a BUNDLED certifi SSL_CERT_FILE so the +# child truststore reaches the OS store on Linux, but PRESERVES a genuine user +# SSL_CERT_FILE. The marker is still stripped either way. +# --------------------------------------------------------------------------- + + +def test_build_child_tls_env_drops_bundled_certifi_ssl_cert_file(): + import certifi + + base = { + _BUNDLED_CERT_MARKER: "1", + "SSL_CERT_FILE": certifi.where(), + "PATH": "/usr/bin", + } + child = build_child_tls_env(base) + assert "SSL_CERT_FILE" not in child + assert _BUNDLED_CERT_MARKER not in child + assert child["PATH"] == "/usr/bin" + + +def test_build_child_tls_env_drops_frozen_meipass_certifi_tail(tmp_path): + # A frozen _MEIPASS path won't equal the live certifi.where(); the + # certifi/cacert.pem tail match must still catch it. + frozen = "/tmp/_MEIabc123/certifi/cacert.pem" + child = build_child_tls_env({_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": frozen}) + assert "SSL_CERT_FILE" not in child + + +def test_build_child_tls_env_preserves_genuine_user_ssl_cert_file(tmp_path): + user_ca = tmp_path / "corp" / "custom-ca.pem" + user_ca.parent.mkdir(parents=True) + user_ca.write_text("-----BEGIN CERTIFICATE-----\n", encoding="ascii") + base = {"SSL_CERT_FILE": str(user_ca), "PATH": "/usr/bin"} + child = build_child_tls_env(base) + # A genuine user CA path must NEVER be dropped. + assert child["SSL_CERT_FILE"] == str(user_ca) + + +# --------------------------------------------------------------------------- +# T5 (M3) -- a write failure must leave NO partial _apm_tls_bootstrap.py under a +# live .pth and must return False (atomic-write contract). +# --------------------------------------------------------------------------- + + +def test_ensure_child_tls_bootstrap_write_failure_leaves_no_partial(tmp_path, monkeypatch): + import apm_cli.core.tls_trust as tls + + venv = _fake_venv(tmp_path) + site = venv / "lib" / "python3.12" / "site-packages" + + def _boom(src, dst): + raise OSError("simulated replace failure") + + monkeypatch.setattr(tls.os, "replace", _boom) + + assert ensure_child_tls_bootstrap(venv) is False + # No partial artifacts left behind, and no leftover temp files. + assert not (site / "_apm_tls_bootstrap.py").exists() + assert not (site / "_apm_tls.pth").exists() + assert list(site.glob(".apm_tls_*.tmp")) == [] diff --git a/tests/unit/test_tls_docs_scope.py b/tests/unit/test_tls_docs_scope.py index 355b3baef..dc6862034 100644 --- a/tests/unit/test_tls_docs_scope.py +++ b/tests/unit/test_tls_docs_scope.py @@ -51,3 +51,41 @@ def test_ssl_docs_scope_and_known_limitations(): assert "not yet covered" in docs # The stale round-1 claim that codex re-runs the bootstrap must be gone. assert "the `llm` and `codex` CLIs) re-run the same OS-trust bootstrap" not in docs + + +def test_changelog_scopes_python_based(): + changelog = (_repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + block = _unreleased_block(changelog) + # The #2005 entry must scope coverage to the Python-based paths explicitly, + # so it never overclaims Node/Codex coverage. + assert "Python-based" in block + + +def test_ssl_docs_node_caveat_appears_early(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).read_text(encoding="utf-8") + + heading = "## Default behaviour: the OS trust store" + start = docs.index(heading) + known_limits = docs.index("### Known limitations") + # The Node/Codex caveat must surface EARLY -- inside the Default behaviour + # section, well before the Known limitations block far below. + caveat = docs.index("Scope caveat", start) + assert caveat < known_limits, "Node/Codex caveat must appear before Known limitations" + # And it must offer the workaround users can apply today. + caveat_region = docs[start:known_limits] + assert "NODE_EXTRA_CA_CERTS" in caveat_region + + +def test_ssl_docs_pip_cert_and_replaces_notes(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).read_text(encoding="utf-8") + + # M4-docs: the pip-own-cert caveat during runtime setup. + assert "PIP_CERT" in docs + # L1: REQUESTS_CA_BUNDLE replaces (not augments) the OS store, plus the + # stale-bundle "still failing?" note. + assert "*replaces*" in docs or "replaces" in docs + assert "stale `REQUESTS_CA_BUNDLE`" in docs From aabf970821af8f8a15a3fc11d8ccb79b9668b936 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 13:21:24 +0200 Subject: [PATCH 17/27] test(tls): independent round-3 verification of wheel-shipped OS-trust delivery Independently proves PR #2005 round-3 fixes on the surface the HIGH bug lived on: the built wheel now ships _apm_tls.pth, a pip-installed wheel delivers the bootstrap into a foreign venv, and that interpreter injects truststore (with .pth removal as the negative control). Also covers the M2 certifi env-hygiene, M3 atomic no-partial write + module-before-pth ordering, M1 best-effort truststore install, and M5/L1/M4 docs scope. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/integration/test_tls_r3_verify.py | 389 ++++++++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 tests/integration/test_tls_r3_verify.py diff --git a/tests/integration/test_tls_r3_verify.py b/tests/integration/test_tls_r3_verify.py new file mode 100644 index 000000000..33eeca331 --- /dev/null +++ b/tests/integration/test_tls_r3_verify.py @@ -0,0 +1,389 @@ +"""Independent round-3 verification of PR #2005 OS-trust delivery (issue #2004). + +Written FROM SCRATCH by the verifier (not the implementer) to prove -- on the +one surface the HIGH bug lived on -- that the child-trust bootstrap survives a +real setuptools wheel build/install and reaches a foreign interpreter. + +Round-2 shipped a ``.pth`` + bootstrap module into a child venv's +site-packages, but a red team found the ``.pth`` was silently DROPPED from the +wheel (``packages.find`` ships only ``.py`` and there was no package-data), so +``ensure_child_tls_bootstrap`` had nothing to copy on the PyPI channel and child +trust was a no-op. Round-3 (a) generates the ``.pth`` inline so delivery no +longer depends on packaging, and (b) adds ``package-data`` so the wheel ships it +anyway. + +The gates here are deliberately independent of the implementer's tests: + +* V1 -- build the wheel, assert BOTH child-TLS files are in the archive, pip + install the wheel into a clean venv, then from THAT venv deliver the bootstrap + into a SECOND foreign venv (no ``apm_cli``) and prove its interpreter injects + ``truststore`` (with the ``.pth`` removed as the negative control). This is the + exact end-to-end chain the wheel bug broke. +* V2 -- ``build_child_tls_env`` drops a bundled-certifi ``SSL_CERT_FILE`` (so the + child reaches the OS store on Linux) but preserves a genuine user value. +* V3 -- ``ensure_child_tls_bootstrap`` writes atomically, leaves no partial file + when the atomic replace fails, and writes the module BEFORE the ``.pth``. +* V4 -- the runtime setup scripts install ``truststore`` best-effort (a failure + never aborts setup) and pin ``truststore>=0.10.0``. +* V5 -- the ssl-issues doc surfaces the Node/Codex scope caveat early with the + ``NODE_EXTRA_CA_CERTS`` workaround, keeps the ``PIP_CERT`` and stale-bundle + notes, and the CHANGELOG scopes coverage to the Python-based paths. + +Offline-by-design: ``truststore`` is copied from the running test environment +into the foreign venv rather than pip-installed. The interpreter under test is +still a foreign venv with NO ``apm_cli``. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.integration] + + +# --------------------------------------------------------------------------- # +# Shared helpers +# --------------------------------------------------------------------------- # +def _repo_root() -> Path: + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("Cannot locate repository root") + + +def _uv() -> str | None: + return shutil.which("uv") + + +def _venv_python(venv: Path) -> Path: + posix = venv / "bin" / "python" + windows = venv / "Scripts" / "python.exe" + return posix if posix.exists() else windows + + +def _make_venv(venv: Path) -> None: + """Create a venv at *venv* using the same interpreter running the tests. + + Prefers ``uv venv`` (the repo toolchain) with the current interpreter + pinned, falling back to the stdlib ``venv`` module. + """ + uv = _uv() + if uv: + subprocess.run( + [uv, "venv", "--python", sys.executable, str(venv)], + check=True, + capture_output=True, + text=True, + ) + else: + subprocess.run( + [sys.executable, "-m", "venv", str(venv)], + check=True, + capture_output=True, + text=True, + ) + + +def _pip_install(target_python: Path, spec: str) -> None: + uv = _uv() + if uv: + subprocess.run( + [uv, "pip", "install", "--python", str(target_python), spec], + check=True, + capture_output=True, + text=True, + ) + else: + subprocess.run( + [str(target_python), "-m", "pip", "install", spec], + check=True, + capture_output=True, + text=True, + ) + + +def _site_packages(venv: Path) -> Path: + candidates = list(venv.glob("lib/python*/site-packages")) + candidates.extend(venv.glob("Lib/site-packages")) + for candidate in candidates: + if candidate.is_dir(): + return candidate + raise AssertionError(f"no site-packages under {venv}") + + +def _run_python(python: Path, code: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(python), "-c", code], + capture_output=True, + text=True, + check=False, + ) + + +def _build_wheel(out_dir: Path) -> Path: + repo = _repo_root() + uv = _uv() + if uv: + cmd = [uv, "build", "--wheel", "--out-dir", str(out_dir)] + else: + cmd = [sys.executable, "-m", "build", "--wheel", "--outdir", str(out_dir)] + result = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, check=False) + if result.returncode != 0: + pytest.skip(f"wheel build unavailable: {result.stdout}\n{result.stderr}") + wheels = list(out_dir.glob("*.whl")) + if not wheels: + pytest.skip("wheel build produced no .whl artifact") + return wheels[0] + + +def _copy_truststore_into(site_packages: Path) -> None: + """Copy the pure-Python ``truststore`` package offline into *site_packages*.""" + try: + import truststore + except Exception: # pragma: no cover - environment guard + pytest.skip("truststore not importable in the test environment to copy offline") + src = Path(truststore.__file__).resolve().parent + shutil.copytree(src, site_packages / "truststore", dirs_exist_ok=True) + + +# --------------------------------------------------------------------------- # +# V1 (H1): wheel build + install + foreign-venv delivery + injection +# --------------------------------------------------------------------------- # +@pytest.mark.slow +def test_v1_wheel_delivers_os_trust_end_to_end(tmp_path): + # 1. Build the wheel and 2. assert BOTH child-TLS files are archived. + wheel = _build_wheel(tmp_path / "wheel") + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + module_member = "apm_cli/core/_child_tls/_apm_tls_bootstrap.py" + pth_member = "apm_cli/core/_child_tls/_apm_tls.pth" + assert module_member in names, f"wheel missing bootstrap module: {names}" + assert pth_member in names, f"H1 regression: wheel missing .pth: {names}" + + # 3. Install the BUILT WHEEL into a clean venv (not editable, not source). + apm_venv = tmp_path / "apm" + _make_venv(apm_venv) + apm_python = _venv_python(apm_venv) + _pip_install(apm_python, str(wheel)) + located = _run_python( + apm_python, + "import apm_cli.core.tls_trust as t; print(t.__file__)", + ) + assert located.returncode == 0, located.stderr + installed_file = Path(located.stdout.strip()) + assert apm_venv in installed_file.parents, ( + f"apm_cli must import from the wheel venv, got {installed_file}" + ) + + # 4. From the wheel venv, deliver the bootstrap into a SECOND foreign venv. + foreign_venv = tmp_path / "foreign" + _make_venv(foreign_venv) + foreign_python = _venv_python(foreign_venv) + no_apm = _run_python(foreign_python, "import apm_cli") + assert no_apm.returncode != 0, "foreign venv must NOT have apm_cli" + + delivered = _run_python( + apm_python, + ( + "from apm_cli.core.tls_trust import ensure_child_tls_bootstrap;" + f"print(ensure_child_tls_bootstrap({str(foreign_venv)!r}))" + ), + ) + assert delivered.returncode == 0, delivered.stderr + assert delivered.stdout.strip() == "True", delivered.stdout + foreign_sp = _site_packages(foreign_venv) + assert (foreign_sp / "_apm_tls_bootstrap.py").is_file() + assert (foreign_sp / "_apm_tls.pth").is_file() + + # 5. Make truststore importable in the foreign venv and prove injection. + _copy_truststore_into(foreign_sp) + with_pth = _run_python(foreign_python, "import ssl; print(ssl.SSLContext.__module__)") + assert with_pth.returncode == 0, with_pth.stderr + assert with_pth.stdout.strip().startswith("truststore"), with_pth.stdout + + # Negative control: remove the .pth -> stdlib ssl is back. + (foreign_sp / "_apm_tls.pth").unlink() + without_pth = _run_python(foreign_python, "import ssl; print(ssl.SSLContext.__module__)") + assert without_pth.returncode == 0, without_pth.stderr + assert without_pth.stdout.strip() == "ssl", without_pth.stdout + + # apm_cli is still absent from the foreign interpreter under test. + still_no_apm = _run_python(foreign_python, "import apm_cli") + assert still_no_apm.returncode != 0 + + +# --------------------------------------------------------------------------- # +# V2 (M2): child env drops bundled certifi, preserves a user value +# --------------------------------------------------------------------------- # +def test_v2_build_child_env_drops_bundled_certifi(): + import certifi + + from apm_cli.core.tls_trust import build_child_tls_env + + marker = "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT" + + # Bundled certifi (== certifi.where()) is dropped; marker is popped. + bundled = build_child_tls_env({"SSL_CERT_FILE": certifi.where(), marker: "1", "PATH": "/keep"}) + assert "SSL_CERT_FILE" not in bundled + assert marker not in bundled + assert bundled.get("PATH") == "/keep" + + # A genuine user SSL_CERT_FILE is preserved; marker still popped. + user = build_child_tls_env({"SSL_CERT_FILE": "/etc/pki/corp-ca.pem", marker: "1"}) + assert user.get("SSL_CERT_FILE") == "/etc/pki/corp-ca.pem" + assert marker not in user + + +def test_v2_build_child_env_drops_frozen_certifi_shape(): + from apm_cli.core.tls_trust import build_child_tls_env + + # The frozen hook sets SSL_CERT_FILE to a certifi/cacert.pem path under + # _MEIPASS whose prefix differs from the live certifi.where(); the tail + # match must still classify it as bundled and drop it. Cover both slashes. + posix = build_child_tls_env({"SSL_CERT_FILE": "/var/_MEIabc/certifi/cacert.pem"}) + assert "SSL_CERT_FILE" not in posix + windows = build_child_tls_env({"SSL_CERT_FILE": "C:\\Temp\\_MEI9\\certifi\\cacert.pem"}) + assert "SSL_CERT_FILE" not in windows + + +# --------------------------------------------------------------------------- # +# V3 (M3): atomic write, no partial file on failure, module before .pth +# --------------------------------------------------------------------------- # +def test_v3_no_partial_file_when_replace_fails(tmp_path, monkeypatch): + from apm_cli.core import tls_trust + + site_packages = tmp_path / "venv" / "lib" / "python3.12" / "site-packages" + site_packages.mkdir(parents=True) + + def boom(src, dst, *args, **kwargs): + raise OSError("simulated atomic replace failure") + + monkeypatch.setattr(os, "replace", boom) + result = tls_trust.ensure_child_tls_bootstrap(tmp_path / "venv") + + assert result is False + leftovers = sorted(p.name for p in site_packages.iterdir()) + assert not (site_packages / "_apm_tls_bootstrap.py").exists(), leftovers + assert not (site_packages / "_apm_tls.pth").exists(), leftovers + assert not any(name.endswith(".tmp") or name.startswith(".apm_tls_") for name in leftovers), ( + f"stray temp file left behind: {leftovers}" + ) + + +def test_v3_writes_module_before_pth(tmp_path, monkeypatch): + from apm_cli.core import tls_trust + + site_packages = tmp_path / "venv" / "lib" / "python3.12" / "site-packages" + site_packages.mkdir(parents=True) + + order: list[str] = [] + original = tls_trust._atomic_write + + def spy(target, data): + order.append(Path(target).name) + return original(target, data) + + monkeypatch.setattr(tls_trust, "_atomic_write", spy) + result = tls_trust.ensure_child_tls_bootstrap(tmp_path / "venv") + + assert result is True + assert order == ["_apm_tls_bootstrap.py", "_apm_tls.pth"], order + assert (site_packages / "_apm_tls_bootstrap.py").is_file() + assert (site_packages / "_apm_tls.pth").is_file() + assert (site_packages / "_apm_tls.pth").read_text(encoding="ascii") == ( + "import _apm_tls_bootstrap\n" + ) + + +# --------------------------------------------------------------------------- # +# V4 (M1): runtime setup scripts install truststore best-effort + pinned +# --------------------------------------------------------------------------- # +def test_v4_setup_llm_truststore_is_best_effort_and_pinned(): + root = _repo_root() + sh = (root / "scripts" / "runtime" / "setup-llm.sh").read_text(encoding="utf-8") + ps1 = (root / "scripts" / "runtime" / "setup-llm.ps1").read_text(encoding="utf-8") + + # Both scripts pin the floor so the child gets an OS-trust-capable truststore. + assert "truststore>=0.10.0" in sh + assert "truststore>=0.10.0" in ps1 + + # Bash: the pip install is guarded by `|| log_warning`, so a failure under + # `set -euo pipefail` does not abort setup. + sh_line = next( + line for line in sh.splitlines() if "truststore>=0.10.0" in line and "pip" in line + ) + assert "|| log_warning" in sh_line, sh_line + + # PowerShell: the install lives inside a try/catch that downgrades to a + # warning rather than surfacing under `$ErrorActionPreference = 'Stop'`. + tail = ps1[ps1.index("truststore>=0.10.0") :] + assert "} catch {" in tail + assert "Write-WarningText" in tail + + +def test_v4_best_effort_control_flow_does_not_abort(tmp_path): + # Dynamically prove the bash guard: a failing `pip` under `set -euo + # pipefail` guarded by `|| log_warning` still exits 0 and runs later steps. + script = tmp_path / "probe.sh" + script.write_text( + "set -euo pipefail\n" + 'log_warning() { echo "[!] $*"; }\n' + "pip() { echo 'simulated failure' >&2; return 1; }\n" + "pip install 'truststore>=0.10.0' || log_warning 'truststore install failed'\n" + "echo CONTINUED\n", + encoding="ascii", + ) + result = subprocess.run(["bash", str(script)], capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr + assert "CONTINUED" in result.stdout + + +# --------------------------------------------------------------------------- # +# V5 (M5/L1/M4-docs): docs + changelog scope honesty +# --------------------------------------------------------------------------- # +def test_v5_ssl_docs_early_caveat_and_notes(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).read_text(encoding="utf-8") + + lines = docs.splitlines() + heading = "## Default behaviour: the OS trust store" + heading_idx = next(i for i, line in enumerate(lines) if line.strip() == heading) + caveat_idx = next(i for i, line in enumerate(lines) if "Scope caveat" in line) + assert caveat_idx > heading_idx + # The caveat must be near the top of the section, not buried far below. + gap = sum(1 for line in lines[heading_idx + 1 : caveat_idx] if line.strip()) + assert gap <= 2, f"Node/Codex caveat must sit within ~2 content lines, got {gap}" + + known_limits = docs.index("### Known limitations") + early_region = docs[docs.index(heading) : known_limits] + assert "NODE_EXTRA_CA_CERTS" in early_region + + # M4-docs: pip's own cert resolution caveat during setup. + assert "PIP_CERT" in docs + # L1: REQUESTS_CA_BUNDLE replaces (not augments) + the stale-bundle note. + assert "*replaces*" in docs + assert "stale `REQUESTS_CA_BUNDLE`" in docs + + +def test_v5_changelog_scopes_python_based(): + changelog = (_repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + start = changelog.index("## [Unreleased]") + rest = changelog[start + len("## [Unreleased]") :] + end = rest.find("\n## [") + block = rest if end == -1 else rest[:end] + + assert "#2005" in block + assert "Python-based" in block + assert "#2034" in block + assert "not yet covered" in block + # The stale round-1 joint claim (child runtimes covered) must be gone. + assert "and `apm run` (child runtimes)" not in block From e8993be8558b34ffe800adcc8519eb510bc04dd5 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 5 Jul 2026 13:37:11 +0200 Subject: [PATCH 18/27] fix(tls): round-4 red-team LOW folds (certifi boundary, macOS py3.9 honesty, sdist guard) Round-4 confirmation red team returned SHIP_NOW on all four lenses (no new HIGH/CRITICAL). Folds the surfaced LOW residuals that sharpen the round-3 surfaces: - F1 (ssl): _is_bundled_certifi matched on a raw string suffix, so a genuine user bundle at e.g. /opt/mycertifi/cacert.pem was wrongly dropped from the child env. Match on path COMPONENTS (last two = certifi/cacert.pem) instead. Regression test covers lookalike dirs + the genuine boundary. - FINDING 1 (proxy): truststore>=0.10.0 needs Python 3.10+, but setup-llm builds the llm venv from whatever python3 resolves to; stock macOS python3 is 3.9 -> silent certifi fallback behind a proxy. Name the Python-version cause in the setup-llm.sh/.ps1 warning and add a Known-limitations caveat to ssl-issues.md. - LOW-1 (pkgmgr): add an sdist-content regression test paralleling the wheel guard, so a future setuptools bump that drops the .pth from the sdist is caught (the bootstrap module must always ship; the .pth rides on package-data). - LOW-2 (pkgmgr): correct the stale "copies the .pth" docstring in _install_llm_tls_bootstrap (the .pth is generated inline; only the module is copied). Lint 10.00/10; 69 TLS tests green (incl. 2 new). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../docs/troubleshooting/ssl-issues.md | 1 + scripts/runtime/setup-llm.ps1 | 2 +- scripts/runtime/setup-llm.sh | 2 +- src/apm_cli/core/tls_trust.py | 4 +- src/apm_cli/runtime/manager.py | 7 +-- tests/integration/test_tls_wheel_content.py | 44 +++++++++++++++++++ tests/unit/core/test_tls_trust.py | 18 ++++++++ 7 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index 006cacb12..44901b0d5 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -68,6 +68,7 @@ You only need the steps below when the CA is *not* in the OS store, or you want ### Known limitations - The Node-based (Copilot) and Rust-based (Codex) child runtimes are **not yet covered** by OS-trust propagation and continue to use their own default trust; behind a TLS-inspecting proxy, configure those runtimes' own trust or export `REQUESTS_CA_BUNDLE`/`NODE_EXTRA_CA_CERTS` for them. Tracked in #2034. +- The `llm` child runtime's OS-trust bootstrap needs the runtime venv's interpreter to be **Python 3.10+** (the `truststore` library requires 3.10). On systems where `apm runtime setup llm` builds the venv from a stock **Python 3.9** (for example Apple's `/usr/bin/python3`), `truststore` cannot install and the `llm` child silently falls back to its bundled `certifi` set behind a proxy. Use a Python 3.10+ `python3` on your `PATH` before running setup. - The initial `pip install` run *during* `apm runtime setup llm` uses pip's **own** certificate resolution, not APM's OS-trust path. Behind a MITM proxy, `pip` may fail to fetch `llm`/`truststore` before the bootstrap is even in place. Export `PIP_CERT=/path/to/org-ca-bundle.pem` (or run `pip config set global.cert /path/to/org-ca-bundle.pem`) before running setup so pip trusts your proxy CA. - An additive `APM_EXTRA_CA_BUNDLE` (trust the OS store *and* an extra bundle) is not yet available -- use `REQUESTS_CA_BUNDLE` to pin a single bundle for now. - On Windows, the `schannel` backend has trust caveats. diff --git a/scripts/runtime/setup-llm.ps1 b/scripts/runtime/setup-llm.ps1 index e4f041f1c..eff529ccd 100644 --- a/scripts/runtime/setup-llm.ps1 +++ b/scripts/runtime/setup-llm.ps1 @@ -47,7 +47,7 @@ function Install-Llm { & $pipExe install "truststore>=0.10.0" if ($LASTEXITCODE -ne 0) { throw "pip exited $LASTEXITCODE" } } catch { - Write-WarningText "truststore install failed; llm child will fall back to bundled CAs behind a proxy" + Write-WarningText "truststore install failed (needs Python 3.10+); llm child will fall back to bundled CAs behind a proxy" } # Install GitHub Models plugin in non-vanilla mode diff --git a/scripts/runtime/setup-llm.sh b/scripts/runtime/setup-llm.sh index e28493f2e..dfc849ec1 100755 --- a/scripts/runtime/setup-llm.sh +++ b/scripts/runtime/setup-llm.sh @@ -55,7 +55,7 @@ setup_llm() { # bootstrap into this venv's site-packages after setup; that bootstrap # depends only on truststore, which must be present here. log_info "Installing truststore for OS-trust HTTPS verification..." - "$llm_venv/bin/pip" install "truststore>=0.10.0" || log_warning "truststore install failed; llm child will fall back to bundled CAs behind a proxy" + "$llm_venv/bin/pip" install "truststore>=0.10.0" || log_warning "truststore install failed (needs Python 3.10+; stock macOS python3 is 3.9); llm child will fall back to bundled CAs behind a proxy" # Install GitHub Models plugin in non-vanilla mode if [[ "$VANILLA_MODE" == "false" ]]; then diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index 08e4a8da8..8161ceaad 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -273,7 +273,9 @@ def _is_bundled_certifi(path: str) -> bool: """ if not path: return False - if path.replace("\\", "/").endswith("certifi/cacert.pem"): + # Match on path COMPONENTS, not a raw suffix: a genuine user bundle at + # e.g. /opt/mycertifi/cacert.pem must NOT be treated as APM's bundled set. + if path.replace("\\", "/").split("/")[-2:] == ["certifi", "cacert.pem"]: return True try: import certifi diff --git a/src/apm_cli/runtime/manager.py b/src/apm_cli/runtime/manager.py index 329a70a7c..735102624 100644 --- a/src/apm_cli/runtime/manager.py +++ b/src/apm_cli/runtime/manager.py @@ -283,9 +283,10 @@ def _install_llm_tls_bootstrap(self) -> None: """Drop the OS-trust bootstrap into the llm runtime venv (best-effort). The llm venv is created by ``setup-llm.sh`` with ``truststore`` - installed; this copies the ``.pth`` bootstrap into its site-packages so - the child interpreter injects the OS trust store at startup. Silent and - non-fatal -- a bootstrap failure must not fail runtime setup. + installed; this generates the ``.pth`` and copies the bootstrap module + into its site-packages so the child interpreter injects the OS trust + store at startup. Silent and non-fatal -- a bootstrap failure must not + fail runtime setup. """ venv_path = self.runtime_dir / "llm-venv" try: diff --git a/tests/integration/test_tls_wheel_content.py b/tests/integration/test_tls_wheel_content.py index 5839a9781..c79c72b89 100644 --- a/tests/integration/test_tls_wheel_content.py +++ b/tests/integration/test_tls_wheel_content.py @@ -17,6 +17,7 @@ import shutil import subprocess import sys +import tarfile import zipfile from pathlib import Path @@ -66,3 +67,46 @@ def test_wheel_ships_child_tls_bootstrap_and_pth(tmp_path): pth = "apm_cli/core/_child_tls/_apm_tls.pth" assert module in names, f"{module} missing from wheel: {names}" assert pth in names, f"{pth} missing from wheel: {names}" + + +def _build_sdist(out_dir: Path) -> Path: + """Build an sdist into *out_dir* and return its path. Prefer uv, fall back to build.""" + repo = _repo_root() + if shutil.which("uv"): + cmd = ["uv", "build", "--sdist", "--out-dir", str(out_dir)] + else: + cmd = [sys.executable, "-m", "build", "--sdist", "--outdir", str(out_dir)] + result = subprocess.run( + cmd, + cwd=str(repo), + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip( + f"sdist build unavailable in this environment: {result.stdout}\n{result.stderr}" + ) + sdists = list(out_dir.glob("*.tar.gz")) + if not sdists: + pytest.skip("sdist build produced no .tar.gz artifact") + return sdists[0] + + +def test_sdist_ships_child_tls_bootstrap_and_pth(tmp_path): + """LOW-1 (round-4): guard the sdist channel too. + + The bootstrap MODULE is a plain package ``.py`` (auto-shipped), but the + ``.pth`` rides on ``package-data`` and can silently diverge from the wheel + across setuptools versions. Assert BOTH members are in the tarball so a + future backend bump that drops the ``.pth`` from the sdist is caught. + """ + sdist = _build_sdist(tmp_path) + with tarfile.open(sdist, "r:gz") as archive: + names = archive.getnames() + + # sdist members are prefixed with the top-level "-/" dir. + module_tail = "src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py" + pth_tail = "src/apm_cli/core/_child_tls/_apm_tls.pth" + assert any(n.endswith(module_tail) for n in names), f"{module_tail} missing from sdist: {names}" + assert any(n.endswith(pth_tail) for n in names), f"{pth_tail} missing from sdist: {names}" diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index 5f93475b6..333e68d83 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -467,6 +467,24 @@ def test_build_child_tls_env_preserves_genuine_user_ssl_cert_file(tmp_path): assert child["SSL_CERT_FILE"] == str(user_ca) +def test_build_child_tls_env_preserves_certifi_lookalike_dir(): + # F1 (round-4): the match is on path COMPONENTS, not a raw suffix. A user + # bundle under a directory that merely ENDS in "certifi" (e.g. a corporate + # "mycertifi/") must be preserved, not mistaken for APM's bundled set. + for lookalike in ( + "/opt/mycertifi/cacert.pem", + "/opt/supercertifi/cacert.pem", + "/a/notcertifi/cacert.pem", + ): + child = build_child_tls_env({_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": lookalike}) + assert child.get("SSL_CERT_FILE") == lookalike, f"lookalike {lookalike} was wrongly dropped" + # The genuine component boundary still matches. + child = build_child_tls_env( + {_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": "/x/certifi/cacert.pem"} + ) + assert "SSL_CERT_FILE" not in child + + # --------------------------------------------------------------------------- # T5 (M3) -- a write failure must leave NO partial _apm_tls_bootstrap.py under a # live .pth and must return False (atomic-write contract). From ebbb497bbb588a5bfbb603012c9c5fe57c2a91b4 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 9 Jul 2026 22:00:20 +0200 Subject: [PATCH 19/27] chore(notice): add truststore attribution + waive spec Mode-B for TLS trust Adding `truststore` as a runtime dependency requires a curated NOTICE metadata block (Microsoft CELA manual-NOTICE process) -- the NOTICE Drift Check gate fails without one. Adds the truststore component (MIT, (c) 2022 Seth Michael Larson, github.com/sethmlarson/truststore) to scripts/notice-metadata.yaml and regenerates NOTICE. The PR touches OpenAPM runtime/install critical paths, tripping the spec Mode-B silent-extension detector. TLS transport trust is orthogonal to the package-format normative surface, so a waiver is the correct path. apm-spec-waiver: TLS transport trust is orthogonal to the OpenAPM package-format normative surface; no new manifest/lockfile/resolver/policy/registry/runtime-contract requirement is introduced by this HTTPS trust change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- NOTICE | 37 ++++++++++++++++++++++++++++++++++++ scripts/notice-metadata.yaml | 6 ++++++ 2 files changed, 43 insertions(+) diff --git a/NOTICE b/NOTICE index d77b75ba0..436f5413a 100644 --- a/NOTICE +++ b/NOTICE @@ -334,6 +334,43 @@ Copyright 2019 Kenneth Reitz --- +## Component. truststore + +- Version requirement: `>=0.10.0` +- Upstream: https://github.com/sethmlarson/truststore +- SPDX: `MIT` +- Notes: Verifies HTTPS against the operating-system trust store by default so `apm` works behind a corporate CA / TLS-inspecting proxy. + +### Open Source License/Copyright Notice. + +_Copyright (c) 2022 Seth Michael Larson_ + +``` +The MIT License (MIT) + +Copyright (c) 2022 Seth Michael Larson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +--- + ## Component. python-frontmatter - Version requirement: `>=1.0.0` diff --git a/scripts/notice-metadata.yaml b/scripts/notice-metadata.yaml index a2ea43c1b..ffa1c059b 100644 --- a/scripts/notice-metadata.yaml +++ b/scripts/notice-metadata.yaml @@ -283,3 +283,9 @@ components: spdx: BSD-3-Clause copyright_snippet: Copyright (c) Aymeric Augustin and contributors notes: Used by the copilot-app integrator to nudge the Copilot desktop App over its local WebSocket IPC channel after writing workflows to the App DB. + - name: truststore + pyproject_name: truststore + upstream: https://github.com/sethmlarson/truststore + spdx: MIT + copyright_snippet: Copyright (c) 2022 Seth Michael Larson + notes: Verifies HTTPS against the operating-system trust store by default so `apm` works behind a corporate CA / TLS-inspecting proxy. From eb66745557245f8c96e5416089da67dcc0aa33d6 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 9 Jul 2026 22:53:43 +0200 Subject: [PATCH 20/27] test(tls): pin TLSv1.2 floor on loopback CA test servers CodeQL py/insecure-protocol flagged two new HIGH alerts: the loopback HTTPS test servers in _tls_ca_server.py and test_tls_custom_ca.py build an ssl.SSLContext(PROTOCOL_TLS_SERVER) whose default still permits TLSv1/TLSv1.1. Set context.minimum_version = TLSv1_2 on both so the scanner stays clean; modern clients already negotiate 1.2/1.3 so the handshake tests are unaffected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/integration/_tls_ca_server.py | 4 ++++ tests/integration/test_tls_custom_ca.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/tests/integration/_tls_ca_server.py b/tests/integration/_tls_ca_server.py index 154fb9bf0..b7a2877df 100644 --- a/tests/integration/_tls_ca_server.py +++ b/tests/integration/_tls_ca_server.py @@ -41,6 +41,10 @@ def private_ca_https_server(dirpath: Path): pass context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + # Pin a modern floor -- the default context still permits TLSv1/TLSv1.1 + # (CodeQL py/insecure-protocol). This is a loopback test server, but keep + # it correct so the scanner stays clean. + context.minimum_version = ssl.TLSVersion.TLSv1_2 context.load_cert_chain(certfile=str(srv_pem), keyfile=str(srv_key)) httpd = http.server.HTTPServer(("127.0.0.1", 0), _OkHandler) diff --git a/tests/integration/test_tls_custom_ca.py b/tests/integration/test_tls_custom_ca.py index 2f222e221..c49ca6fc5 100644 --- a/tests/integration/test_tls_custom_ca.py +++ b/tests/integration/test_tls_custom_ca.py @@ -158,6 +158,9 @@ def custom_ca_server(tmp_path_factory): ca_pem, srv_pem, srv_key = _mint_ca_and_leaf(dirpath) context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + # Pin a modern floor -- the default context still permits TLSv1/TLSv1.1 + # (CodeQL py/insecure-protocol). Loopback test server, but keep it clean. + context.minimum_version = ssl.TLSVersion.TLSv1_2 context.load_cert_chain(certfile=str(srv_pem), keyfile=str(srv_key)) httpd = http.server.HTTPServer(("127.0.0.1", 0), _OkHandler) From 83eb7e568f13472b82da5139e5674f13705b6d12 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 12 Jul 2026 08:26:18 +0200 Subject: [PATCH 21/27] fix(tls): replay trust source under verbose logging Cache the early TLS decision in its canonical owner and replay it after CLI logging is configured, without delaying security-sensitive injection. Addresses the CLI logging and architecture panel follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/cli.py | 19 ++++-------------- src/apm_cli/core/tls_trust.py | 32 ++++++++++++++++++++++++++----- tests/unit/core/test_tls_trust.py | 22 ++++++++++++++++----- 3 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 038fe44f9..0515655de 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -13,21 +13,9 @@ import click -from apm_cli.core.tls_trust import configure_tls_trust +from apm_cli.core.tls_trust import configure_process_tls_trust, log_tls_trust_status -_TLS_TRUST_CONFIGURED = False - - -def _configure_process_tls_trust() -> None: - """Configure process-wide TLS trust before network clients are imported.""" - global _TLS_TRUST_CONFIGURED - if _TLS_TRUST_CONFIGURED: - return - configure_tls_trust() - _TLS_TRUST_CONFIGURED = True - - -_configure_process_tls_trust() +configure_process_tls_trust() from apm_cli.commands._helpers import ( ERROR, @@ -171,6 +159,7 @@ def cli(ctx, verbose: bool) -> None: if verbose: # Upgrade to DEBUG when the flag is set; env-var path runs in main(). _configure_logging(verbose=True) + log_tls_trust_status() # Suppress only the agents-target deprecation warning so CLI users see # the formatted logger.warning() in the install phase, not a double print. @@ -370,7 +359,7 @@ def main(): """Main entry point for the CLI.""" _configure_logging() # honours APM_LOG_LEVEL env var; --verbose upgrades in cli() _configure_encoding() - _configure_process_tls_trust() + configure_process_tls_trust() try: cli(obj={}) except Exception as e: diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index 8161ceaad..9e5eb3179 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -26,6 +26,7 @@ from __future__ import annotations import contextlib +import functools import logging import os import sys @@ -74,6 +75,21 @@ _PTH_CONTENT = f"import {_BOOTSTRAP_MODULE_NAME}\n" _TRUTHY = {"1", "true", "yes", "on"} +_LAST_TLS_STATUS: tuple[str, tuple[object, ...]] | None = None + + +def _record_tls_trust_status(message: str, *args: object) -> None: + """Cache and emit the selected trust source at debug level.""" + global _LAST_TLS_STATUS + _LAST_TLS_STATUS = (message, args) + logger.debug(message, *args) + + +def log_tls_trust_status() -> None: + """Re-emit the cached trust source after CLI logging is configured.""" + if _LAST_TLS_STATUS is not None: + message, args = _LAST_TLS_STATUS + logger.debug(message, *args) def _env_flag(name: str, env: Mapping[str, str] | None = None) -> bool: @@ -131,11 +147,11 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: environ.pop(_BUNDLED_CERT_MARKER, None) if _env_flag(_DISABLE_ENV_VAR, env): - logger.debug("[i] TLS: OS trust-store injection disabled (%s)", _DISABLE_ENV_VAR) + _record_tls_trust_status("TLS: OS trust-store injection disabled (%s)", _DISABLE_ENV_VAR) return False if has_explicit_ca_override(env): - logger.debug("[i] TLS: explicit CA bundle in use: %s", _explicit_ca_path(env)) + _record_tls_trust_status("TLS: explicit CA bundle in use: %s", _explicit_ca_path(env)) return False try: @@ -143,7 +159,7 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: # only with ImportError -- degrade instead of crashing startup. import truststore except Exception as exc: - logger.debug("[i] TLS: verifying against bundled CA (certifi fallback) [%s]", exc) + _record_tls_trust_status("TLS: verifying against bundled CA (certifi fallback) [%s]", exc) return False # If the frozen hook pinned SSL_CERT_FILE to bundled certifi, pop it so @@ -161,13 +177,19 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: # musl/minimal-container hosts still verify against certifi. if bundled_cert is not None: environ[_SSL_CERT_FILE_VAR] = bundled_cert - logger.debug("[i] TLS: verifying against bundled CA (certifi fallback) [%s]", exc) + _record_tls_trust_status("TLS: verifying against bundled CA (certifi fallback) [%s]", exc) return False - logger.debug("[i] TLS: verifying against OS trust store (truststore)") + _record_tls_trust_status("TLS: verifying against OS trust store (truststore)") return True +@functools.lru_cache(maxsize=1) +def configure_process_tls_trust() -> bool: + """Configure process TLS once while retaining the selected trust source.""" + return configure_tls_trust() + + def _child_bootstrap_dir() -> str | None: """Absolute path to the directory holding the child TLS bootstrap files. diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index 333e68d83..90f9c7a3f 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -28,6 +28,7 @@ configure_tls_trust, ensure_child_tls_bootstrap, has_explicit_ca_override, + log_tls_trust_status, ) _NON_REQUESTS_CA_ENV_VARS = ("SSL_CERT_FILE", "SSL_CERT_DIR") @@ -212,7 +213,7 @@ def test_cli_bootstrap_is_idempotent_across_import_and_main(tmp_path): # --------------------------------------------------------------------------- # H2 -- visible trust-source diagnostic. Each branch of configure_tls_trust must -# emit an ASCII "[i] TLS: ..." line at DEBUG naming which trust source is in +# emit an ASCII "TLS: ..." line at DEBUG naming which trust source is in # effect, so an operator can tell OS-store vs certifi-fallback vs explicit # bundle vs opt-out from the logs alone. # --------------------------------------------------------------------------- @@ -233,7 +234,7 @@ def test_diag_default_inject_names_os_trust_store(monkeypatch, caplog): assert configure_tls_trust() is True messages = _trust_source_messages(caplog) - assert "[i] TLS: verifying against OS trust store (truststore)" in messages + assert "TLS: verifying against OS trust store (truststore)" in messages for message in messages: message.encode("ascii") # must not raise @@ -243,7 +244,7 @@ def test_diag_disabled_names_opt_out(caplog): assert configure_tls_trust(env={_DISABLE_ENV_VAR: "1"}) is False messages = _trust_source_messages(caplog) - assert "[i] TLS: OS trust-store injection disabled (APM_DISABLE_TRUSTSTORE)" in messages + assert "TLS: OS trust-store injection disabled (APM_DISABLE_TRUSTSTORE)" in messages for message in messages: message.encode("ascii") @@ -254,7 +255,7 @@ def test_diag_explicit_bundle_names_the_path(caplog): assert configure_tls_trust(env={"REQUESTS_CA_BUNDLE": ca_path}) is False messages = _trust_source_messages(caplog) - assert f"[i] TLS: explicit CA bundle in use: {ca_path}" in messages + assert f"TLS: explicit CA bundle in use: {ca_path}" in messages for message in messages: message.encode("ascii") @@ -268,12 +269,23 @@ def test_diag_import_failure_names_certifi_fallback(monkeypatch, caplog): messages = _trust_source_messages(caplog) # The branch appends the captured exception in brackets; match the stable core. assert any( - m.startswith("[i] TLS: verifying against bundled CA (certifi fallback)") for m in messages + m.startswith("TLS: verifying against bundled CA (certifi fallback)") for m in messages ), messages for message in messages: message.encode("ascii") +def test_cached_trust_source_can_be_replayed_after_logging_configuration(monkeypatch, caplog): + _install_fake_truststore(monkeypatch) + configure_tls_trust() + caplog.clear() + + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + log_tls_trust_status() + + assert _trust_source_messages(caplog) == ["TLS: verifying against OS trust store (truststore)"] + + # --------------------------------------------------------------------------- # T4 -- the internal bundled-default marker must NEVER leak out of # configure_tls_trust, on ANY of its return branches. A leaked marker would tell From 8b9d27ecff658e16a24a91c055750e0412a972c3 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 12 Jul 2026 08:29:24 +0200 Subject: [PATCH 22/27] fix(tls): harden llm bootstrap delivery feedback Prove RuntimeManager delivers the child bootstrap, distinguish Python and proxy setup failures, and keep unexpected best-effort errors visible under debug logging. Addresses the test coverage, DevX, and CLI logging panel follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/runtime/setup-llm.ps1 | 9 ++- scripts/runtime/setup-llm.sh | 9 ++- src/apm_cli/runtime/manager.py | 12 +++- tests/integration/test_tls_child_runtime.py | 61 ++++++++++++++++++++ tests/integration/test_tls_r3_verify.py | 16 +++-- tests/unit/install/test_validation_phase3.py | 4 +- 6 files changed, 99 insertions(+), 12 deletions(-) diff --git a/scripts/runtime/setup-llm.ps1 b/scripts/runtime/setup-llm.ps1 index eff529ccd..0cd2a78dd 100644 --- a/scripts/runtime/setup-llm.ps1 +++ b/scripts/runtime/setup-llm.ps1 @@ -32,6 +32,7 @@ function Install-Llm { $pipExe = Join-Path (Join-Path $llmVenv "Scripts") "pip.exe" $llmExe = Join-Path (Join-Path $llmVenv "Scripts") "llm.exe" + $venvPython = Join-Path (Join-Path $llmVenv "Scripts") "python.exe" # Install LLM Write-Info "Installing LLM library..." @@ -47,7 +48,13 @@ function Install-Llm { & $pipExe install "truststore>=0.10.0" if ($LASTEXITCODE -ne 0) { throw "pip exited $LASTEXITCODE" } } catch { - Write-WarningText "truststore install failed (needs Python 3.10+); llm child will fall back to bundled CAs behind a proxy" + & $venvPython -c "import sys; raise SystemExit(sys.version_info < (3, 10))" + if ($LASTEXITCODE -ne 0) { + $pythonVersion = & $venvPython -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" + Write-WarningText "truststore install failed: Python 3.10+ is required (found $pythonVersion); recreate the llm runtime with a supported Python. See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + } else { + Write-WarningText "truststore install failed: if pip is behind a TLS proxy, set PIP_CERT=C:\path\to\org-ca-bundle.pem and re-run setup; the llm runtime will otherwise use bundled CAs. See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + } } # Install GitHub Models plugin in non-vanilla mode diff --git a/scripts/runtime/setup-llm.sh b/scripts/runtime/setup-llm.sh index dfc849ec1..a3fc08943 100755 --- a/scripts/runtime/setup-llm.sh +++ b/scripts/runtime/setup-llm.sh @@ -55,7 +55,14 @@ setup_llm() { # bootstrap into this venv's site-packages after setup; that bootstrap # depends only on truststore, which must be present here. log_info "Installing truststore for OS-trust HTTPS verification..." - "$llm_venv/bin/pip" install "truststore>=0.10.0" || log_warning "truststore install failed (needs Python 3.10+; stock macOS python3 is 3.9); llm child will fall back to bundled CAs behind a proxy" + if ! "$llm_venv/bin/pip" install "truststore>=0.10.0"; then + if ! "$llm_venv/bin/python" -c "import sys; raise SystemExit(sys.version_info < (3, 10))"; then + version=$("$llm_venv/bin/python" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") + log_warning "truststore install failed: Python 3.10+ is required (found $version); recreate the llm runtime with a supported Python. See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + else + log_warning "truststore install failed: if pip is behind a TLS proxy, set PIP_CERT=/path/to/org-ca-bundle.pem and re-run setup; the llm runtime will otherwise use bundled CAs. See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + fi + fi # Install GitHub Models plugin in non-vanilla mode if [[ "$VANILLA_MODE" == "false" ]]; then diff --git a/src/apm_cli/runtime/manager.py b/src/apm_cli/runtime/manager.py index 697e8a365..3ae3052f2 100644 --- a/src/apm_cli/runtime/manager.py +++ b/src/apm_cli/runtime/manager.py @@ -3,6 +3,7 @@ Handles installation, configuration, and management of AI runtimes. """ +import logging import os import shutil import subprocess @@ -17,6 +18,8 @@ from ..core.token_manager import setup_runtime_environment from .registry import get_runtime_descriptor, runtime_descriptors +logger = logging.getLogger(__name__) + class RuntimeManager: """Manages AI runtime installation and configuration via embedded scripts.""" @@ -280,10 +283,13 @@ def _install_llm_tls_bootstrap(self) -> None: if not ensure_child_tls_bootstrap(venv_path): click.echo( f"{Fore.YELLOW}[!] Could not install OS-trust bootstrap into the llm venv; " - f"the llm runtime may fall back to bundled CAs behind a proxy.{Style.RESET_ALL}" + "use Python 3.10+ or set PIP_CERT to your proxy CA bundle, then re-run " + "`apm runtime setup llm`. See: " + "https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + f"{Style.RESET_ALL}" ) - except Exception: - pass + except Exception as exc: + logger.debug("TLS bootstrap installation raised unexpectedly: %s", exc) def list_runtimes(self) -> dict[str, dict[str, str]]: """List available and installed runtimes.""" diff --git a/tests/integration/test_tls_child_runtime.py b/tests/integration/test_tls_child_runtime.py index e9d0f8038..20c92263e 100644 --- a/tests/integration/test_tls_child_runtime.py +++ b/tests/integration/test_tls_child_runtime.py @@ -29,6 +29,7 @@ from __future__ import annotations import importlib.util +import logging import os import shutil import subprocess @@ -38,6 +39,7 @@ import pytest from apm_cli.core.tls_trust import _child_bootstrap_dir, _venv_site_packages +from apm_cli.runtime.manager import RuntimeManager pytestmark = pytest.mark.integration @@ -167,3 +169,62 @@ def test_bootstrap_is_additive_to_user_sitecustomize(tmp_path): assert result.stdout.strip().startswith("truststore"), ( f"truststore must still inject with a user sitecustomize present, got {result.stdout!r}" ) + + +def test_runtime_manager_setup_llm_installs_tls_bootstrap(tmp_path, monkeypatch): + """Runtime setup must deliver both bootstrap files into the created venv.""" + manager = RuntimeManager() + manager.runtime_dir = tmp_path / "runtimes" + monkeypatch.setattr(manager, "get_embedded_script", lambda _name: "") + monkeypatch.setattr(manager, "get_common_script", lambda: "") + + def _create_llm_venv(_script, _common, _args): + site_packages = ( + manager.runtime_dir + / "llm-venv" + / "lib" + / f"python{sys.version_info.major}.{sys.version_info.minor}" + / "site-packages" + ) + site_packages.mkdir(parents=True) + return True + + monkeypatch.setattr(manager, "run_embedded_script", _create_llm_venv) + + assert manager.setup_runtime("llm") is True + site_packages = _venv_site_packages(manager.runtime_dir / "llm-venv") + assert site_packages is not None + assert (site_packages / "_apm_tls_bootstrap.py").is_file() + assert (site_packages / "_apm_tls.pth").read_text(encoding="ascii") == ( + "import _apm_tls_bootstrap\n" + ) + + +def test_runtime_manager_bootstrap_warning_is_actionable(tmp_path, capsys): + """A best-effort delivery failure must tell proxy users how to recover.""" + manager = RuntimeManager() + manager.runtime_dir = tmp_path / "runtimes" + + manager._install_llm_tls_bootstrap() + + output = capsys.readouterr().out + assert "PIP_CERT" in output + assert "Python 3.10+" in output + assert "https://microsoft.github.io/apm/troubleshooting/ssl-issues/" in output + + +def test_runtime_manager_bootstrap_exception_is_visible_in_debug_log(tmp_path, monkeypatch, caplog): + """Unexpected helper failures must remain visible under verbose logging.""" + import apm_cli.runtime.manager as manager_module + + manager = RuntimeManager() + manager.runtime_dir = tmp_path / "runtimes" + + def _raise(_venv_path): + raise RuntimeError("unexpected bootstrap failure") + + monkeypatch.setattr(manager_module, "ensure_child_tls_bootstrap", _raise) + with caplog.at_level(logging.DEBUG, logger="apm_cli.runtime.manager"): + manager._install_llm_tls_bootstrap() + + assert "unexpected bootstrap failure" in caplog.text diff --git a/tests/integration/test_tls_r3_verify.py b/tests/integration/test_tls_r3_verify.py index 33eeca331..548b72e89 100644 --- a/tests/integration/test_tls_r3_verify.py +++ b/tests/integration/test_tls_r3_verify.py @@ -314,13 +314,17 @@ def test_v4_setup_llm_truststore_is_best_effort_and_pinned(): # Both scripts pin the floor so the child gets an OS-trust-capable truststore. assert "truststore>=0.10.0" in sh assert "truststore>=0.10.0" in ps1 - - # Bash: the pip install is guarded by `|| log_warning`, so a failure under + assert "sys.version_info < (3, 10)" in sh + assert "sys.version_info < (3, 10)" in ps1 + assert "PIP_CERT" in sh + assert "PIP_CERT" in ps1 + docs_url = "https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + assert docs_url in sh + assert docs_url in ps1 + + # Bash: the pip install is guarded by an if block, so a failure under # `set -euo pipefail` does not abort setup. - sh_line = next( - line for line in sh.splitlines() if "truststore>=0.10.0" in line and "pip" in line - ) - assert "|| log_warning" in sh_line, sh_line + assert 'if ! "$llm_venv/bin/pip" install "truststore>=0.10.0"; then' in sh # PowerShell: the install lives inside a try/catch that downgrades to a # warning rather than surfacing under `$ErrorActionPreference = 'Stop'`. diff --git a/tests/unit/install/test_validation_phase3.py b/tests/unit/install/test_validation_phase3.py index 0b32a1cb1..2bb48eb5b 100644 --- a/tests/unit/install/test_validation_phase3.py +++ b/tests/unit/install/test_validation_phase3.py @@ -94,7 +94,9 @@ def test_non_verbose_emits_single_warning(self) -> None: exc = RuntimeError("TLS verification failed") _log_tls_failure("example.com", exc, verbose_log=None, logger=logger) logger.warning.assert_called_once() - assert "REQUESTS_CA_BUNDLE" in logger.warning.call_args[0][0] + message = logger.warning.call_args[0][0] + assert "system trust store" in message + assert "REQUESTS_CA_BUNDLE" in message def test_verbose_log_called_with_host_and_exc(self) -> None: from apm_cli.install.validation import _log_tls_failure From f4d51b0ab0008fd5ab8f732ed54639842f9eae00 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 12 Jul 2026 08:31:59 +0200 Subject: [PATCH 23/27] docs(tls): document enterprise transport trust Add the security-model boundary, lead troubleshooting with the OS-store fix, remove duplicate runtime caveats, and avoid advertising an unimplemented bundle variable. Addresses the security, growth, and documentation panel follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/enterprise/security.md | 11 +++++++++ .../docs/troubleshooting/ssl-issues.md | 12 ++++++---- tests/unit/test_tls_docs_scope.py | 23 +++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md index e513434ba..2dfb9495c 100644 --- a/docs/src/content/docs/enterprise/security.md +++ b/docs/src/content/docs/enterprise/security.md @@ -39,6 +39,17 @@ APM has no runtime footprint. Once `apm install` or `apm compile` completes, the - **No persistent background processes.** APM does not install daemons, services, or scheduled tasks. - **No telemetry or data collection.** APM collects no usage data, analytics, or diagnostics. Nothing is transmitted to Microsoft or any third party. +## HTTPS transport trust + +APM keeps certificate verification enabled for every HTTPS request. Python-based paths verify against the operating-system trust store by default through `truststore`, so corporate roots trusted by `git` and `curl` are also trusted by `apm install`. + +- `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` replace the OS store with an explicitly selected PEM bundle for APM's HTTP layer. +- `APM_DISABLE_TRUSTSTORE=1` restores the previous bundled-`certifi` behavior. +- If `truststore` is unavailable or injection fails, APM falls back to `certifi`; it does not disable verification. +- The Python-based `llm` runtime receives a shipped, self-contained `.pth` bootstrap in its managed virtual environment. The bootstrap imports only `truststore`; it does not execute dependency-provided package content. + +Node-based (Copilot) and Rust-based (Codex) child runtimes retain their own trust configuration for now. See [SSL / TLS issues](../troubleshooting/ssl-issues/) for scope, overrides, and recovery steps. + ## Dependency provenance APM resolves dependencies directly from git repositories. There is no intermediary registry, proxy, or mirror. diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index 44901b0d5..815990f97 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -56,9 +56,13 @@ apm install --verbose ## Default behaviour: the OS trust store -APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. This applies to `apm install` (in-process) and to the Python-based `llm` child runtime that `apm run` spawns: `apm runtime setup llm` installs `truststore` into the runtime's virtual environment and drops a self-contained bootstrap into it, so that interpreter also verifies against the OS store. So if your corporate CA or TLS-proxy root is installed in the OS trust store (Keychain on macOS, `update-ca-certificates`/`update-ca-trust` on Linux, the Trusted Root store on Windows), APM picks it up with **no configuration** -- importing the CA at the OS level is the recommended fix. The standalone frozen binary honours the system store as well, falling back to its bundled `certifi` set only when the OS store is unavailable. +**Fastest fix:** install your corporate CA in the OS trust store and retry. APM picks it up automatically on the covered Python paths. -**Scope caveat:** only the Python-based paths are covered. The Node-based (Copilot) and Rust-based (Codex) child runtimes are **not yet** wired to the OS store (tracked in #2034). Behind a TLS-proxy today, export `NODE_EXTRA_CA_CERTS=/path/to/org-ca-bundle.pem` for the Node runtime (and configure the Codex/Rust runtime's own trust) until #2034 lands. +APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. This covers in-process commands such as `apm install` and the standalone frozen binary, with bundled `certifi` as a fallback. + +**Scope caveat:** only the Python-based paths are covered. The Node-based (Copilot) and Rust-based (Codex) child runtimes are **not yet covered** by OS-store propagation (tracked in #2034). Behind a TLS-proxy today, export `NODE_EXTRA_CA_CERTS=/path/to/org-ca-bundle.pem` for the Node runtime and configure the Codex/Rust runtime's own trust. + +For the Python-based `llm` child runtime, `apm runtime setup llm` installs `truststore` in its virtual environment and adds a self-contained bootstrap. Corporate CAs installed in Keychain on macOS, through `update-ca-certificates`/`update-ca-trust` on Linux, or in the Windows Trusted Root store then work without APM-specific configuration. You only need the steps below when the CA is *not* in the OS store, or you want to pin a specific bundle: @@ -67,10 +71,10 @@ You only need the steps below when the CA is *not* in the OS store, or you want ### Known limitations -- The Node-based (Copilot) and Rust-based (Codex) child runtimes are **not yet covered** by OS-trust propagation and continue to use their own default trust; behind a TLS-inspecting proxy, configure those runtimes' own trust or export `REQUESTS_CA_BUNDLE`/`NODE_EXTRA_CA_CERTS` for them. Tracked in #2034. +- Node (Copilot) and Rust (Codex) coverage -- see the scope caveat above. - The `llm` child runtime's OS-trust bootstrap needs the runtime venv's interpreter to be **Python 3.10+** (the `truststore` library requires 3.10). On systems where `apm runtime setup llm` builds the venv from a stock **Python 3.9** (for example Apple's `/usr/bin/python3`), `truststore` cannot install and the `llm` child silently falls back to its bundled `certifi` set behind a proxy. Use a Python 3.10+ `python3` on your `PATH` before running setup. - The initial `pip install` run *during* `apm runtime setup llm` uses pip's **own** certificate resolution, not APM's OS-trust path. Behind a MITM proxy, `pip` may fail to fetch `llm`/`truststore` before the bootstrap is even in place. Export `PIP_CERT=/path/to/org-ca-bundle.pem` (or run `pip config set global.cert /path/to/org-ca-bundle.pem`) before running setup so pip trusts your proxy CA. -- An additive `APM_EXTRA_CA_BUNDLE` (trust the OS store *and* an extra bundle) is not yet available -- use `REQUESTS_CA_BUNDLE` to pin a single bundle for now. +- APM cannot currently combine the OS store with an additional PEM bundle. Use `REQUESTS_CA_BUNDLE` to pin a single bundle instead. - On Windows, the `schannel` backend has trust caveats. ## Configure trust diff --git a/tests/unit/test_tls_docs_scope.py b/tests/unit/test_tls_docs_scope.py index dc6862034..a1bd2ab15 100644 --- a/tests/unit/test_tls_docs_scope.py +++ b/tests/unit/test_tls_docs_scope.py @@ -89,3 +89,26 @@ def test_ssl_docs_pip_cert_and_replaces_notes(): # stale-bundle "still failing?" note. assert "*replaces*" in docs or "replaces" in docs assert "stale `REQUESTS_CA_BUNDLE`" in docs + + +def test_ssl_docs_keep_planned_configuration_generic(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).read_text(encoding="utf-8") + + assert "APM_EXTRA_CA_BUNDLE" not in docs + assert docs.count("#2034") == 1 + + +def test_enterprise_security_docs_transport_trust_model(): + security = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "enterprise" / "security.md" + ).read_text(encoding="utf-8") + + assert "## HTTPS transport trust" in security + assert "APM_DISABLE_TRUSTSTORE" in security + assert "REQUESTS_CA_BUNDLE" in security + assert "CURL_CA_BUNDLE" in security + assert ".pth" in security + assert "Node" in security + assert "Rust" in security From 6483e2284749cea0e5e0f0b624ad9e6c88e79acd Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 12 Jul 2026 08:58:30 +0200 Subject: [PATCH 24/27] fix(tls): complete canonical trust guardrails Fence truststore injection to its two owners, lock parent-child environment policy parity, normalize child diagnostics and subprocess env hygiene, and lead the changelog with user value. Addresses the reinforcement panel follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 22 ++++++--------- scripts/lint-architecture-boundaries.sh | 6 ++++ .../core/_child_tls/_apm_tls_bootstrap.py | 4 +-- src/apm_cli/runtime/llm_runtime.py | 2 ++ .../test_architecture_authorities.py | 18 ++++++++++++ tests/unit/core/test_tls_trust.py | 27 ++++++++++++++++++ tests/unit/test_llm_runtime.py | 28 +++++++++++++++++-- 7 files changed, 90 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 016b67c1f..07feb46ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,19 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- APM now verifies HTTPS against the OS trust store by default (via - `truststore`), so it works out-of-the-box behind a corporate CA or a - TLS-inspecting proxy -- matching the behaviour of `git`/`curl` on the same - host instead of failing against the bundled `certifi` set. This covers the - Python-based paths: `apm install` (in-process) and the Python-based `llm` - child runtime spawned by `apm run`, into whose venv APM installs `truststore` - and a self-contained `.pth` bootstrap at setup time. The frozen binary honours - the system store as well, with `certifi` as a genuine fallback. An explicitly - set `REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` still wins, and - `APM_DISABLE_TRUSTSTORE=1` restores the previous certifi-only behaviour. - Node-based (Copilot) and Rust-based (Codex) child runtimes are not yet covered - and continue to use their own default trust; tracked in #2034. (closes #2004) - (#2005) +- Corporate proxy and internal-CA users can now use Python-based APM HTTPS paths + without per-shell TLS setup. APM verifies against the OS trust store through + `truststore` for `apm install`, the Python `llm` child runtime, and the frozen + binary, with `certifi` as fallback. `REQUESTS_CA_BUNDLE` / + `CURL_CA_BUNDLE` still wins, and `APM_DISABLE_TRUSTSTORE=1` restores + certifi-only behavior. Node (Copilot) and Rust (Codex) children are not yet covered + and retain their own trust configuration; tracked in #2034. + (closes #2004) (#2005) + ### Changed - Hardened command behavior: invalid lockfiles and incomplete policy chains diff --git a/scripts/lint-architecture-boundaries.sh b/scripts/lint-architecture-boundaries.sh index 5a0c9e99a..3df7eaf83 100755 --- a/scripts/lint-architecture-boundaries.sh +++ b/scripts/lint-architecture-boundaries.sh @@ -156,6 +156,12 @@ if ! grep -q '_clear_git_auth_env(env)' src/apm_cli/core/auth.py; then echo "[x] AuthResolver must scrub inherited Git authorization state" violations=$((violations + 1)) fi +check_pattern \ + "TLS trust injection belongs to canonical owners" \ + 'truststore\.inject_into_ssl\(' \ + $(find src/apm_cli -name '*.py' \ + ! -path 'src/apm_cli/core/tls_trust.py' \ + ! -path 'src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py') echo "[*] AC6: neutral IR and schema contracts" check_pattern \ diff --git a/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py b/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py index 0d83c254b..4d0ba3fbe 100644 --- a/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py +++ b/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py @@ -37,11 +37,11 @@ def _bootstrap(): _os.environ.pop(marker, None) try: truststore.inject_into_ssl() - _logger.debug("[i] TLS: child verifying against OS trust store") + _logger.debug("TLS: child verifying against OS trust store") except Exception: if bundled is not None: _os.environ["SSL_CERT_FILE"] = bundled - _logger.debug("[i] TLS: child falling back to certifi") + _logger.debug("TLS: child falling back to certifi") _bootstrap() diff --git a/src/apm_cli/runtime/llm_runtime.py b/src/apm_cli/runtime/llm_runtime.py index 2f82aa2de..eade877b0 100644 --- a/src/apm_cli/runtime/llm_runtime.py +++ b/src/apm_cli/runtime/llm_runtime.py @@ -27,6 +27,7 @@ def __init__(self, model_name: str | None = None): text=True, encoding="utf-8", check=True, + env=build_child_tls_env(os.environ), ) except (subprocess.CalledProcessError, FileNotFoundError): raise RuntimeError("llm CLI not found. Please install: pip install llm") # noqa: B904 @@ -133,6 +134,7 @@ def is_available() -> bool: text=True, encoding="utf-8", check=True, + env=build_child_tls_env(os.environ), ) return True except (subprocess.CalledProcessError, FileNotFoundError): diff --git a/tests/integration/test_architecture_authorities.py b/tests/integration/test_architecture_authorities.py index d84579796..853f4771c 100644 --- a/tests/integration/test_architecture_authorities.py +++ b/tests/integration/test_architecture_authorities.py @@ -199,3 +199,21 @@ def test_dependency_winner_selection_has_one_algorithm() -> None: "nodes_at_depth.sort", ): assert duplicate not in source + + +def test_tls_injection_has_one_canonical_authority() -> None: + """Only the parent TLS owner and standalone child bootstrap may inject.""" + root = Path(__file__).parents[2] + guard = (root / "scripts/lint-architecture-boundaries.sh").read_text() + allowed = { + root / "src/apm_cli/core/tls_trust.py", + root / "src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py", + } + duplicate_owners = [ + path.relative_to(root).as_posix() + for path in (root / "src/apm_cli").rglob("*.py") + if path not in allowed and "truststore.inject_into_ssl(" in path.read_text() + ] + + assert "TLS trust injection belongs to canonical owners" in guard + assert duplicate_owners == [] diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index 90f9c7a3f..fe109a741 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -11,6 +11,7 @@ from __future__ import annotations +import ast import logging import os import subprocess @@ -440,6 +441,32 @@ def test_pth_is_generated_not_copied(tmp_path, monkeypatch): assert (site / "_apm_tls_bootstrap.py").read_text(encoding="ascii") == "# bootstrap\n" +def test_child_bootstrap_tls_policy_matches_parent_constants(): + import apm_cli.core.tls_trust as tls + + bootstrap = Path(tls._child_bootstrap_dir()) / "_apm_tls_bootstrap.py" + literals = { + node.value + for node in ast.walk(ast.parse(bootstrap.read_text(encoding="utf-8"))) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + expected = { + tls._DISABLE_ENV_VAR, + *tls._EXPLICIT_CA_ENV_VARS, + tls._BUNDLED_CERT_MARKER, + tls._SSL_CERT_FILE_VAR, + } + + assert expected <= literals + + +def test_child_bootstrap_debug_messages_do_not_embed_console_symbols(): + import apm_cli.core.tls_trust as tls + + bootstrap = Path(tls._child_bootstrap_dir()) / "_apm_tls_bootstrap.py" + assert '"[i] TLS:' not in bootstrap.read_text(encoding="utf-8") + + # --------------------------------------------------------------------------- # T4 (M2) -- build_child_tls_env drops a BUNDLED certifi SSL_CERT_FILE so the # child truststore reaches the OS store on Linux, but PRESERVES a genuine user diff --git a/tests/unit/test_llm_runtime.py b/tests/unit/test_llm_runtime.py index 0190bb2b7..17bb5c5bc 100644 --- a/tests/unit/test_llm_runtime.py +++ b/tests/unit/test_llm_runtime.py @@ -1,5 +1,6 @@ """Test LLM runtime integration.""" +import os from unittest.mock import Mock, patch import pytest @@ -10,8 +11,9 @@ class TestLLMRuntime: """Test LLM runtime adapter.""" + @patch("apm_cli.runtime.llm_runtime.build_child_tls_env", return_value={"PATH": "clean"}) @patch("apm_cli.runtime.llm_runtime.subprocess.run") - def test_init_success(self, mock_run): + def test_init_success(self, mock_run, mock_child_env): """Test successful initialization.""" # Mock the --version check mock_run.return_value = Mock(returncode=0, stdout="llm 0.17.0") @@ -20,8 +22,14 @@ def test_init_success(self, mock_run): assert runtime.model_name == "gpt-4o-mini" mock_run.assert_called_once_with( - ["llm", "--version"], capture_output=True, text=True, encoding="utf-8", check=True + ["llm", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + env={"PATH": "clean"}, ) + mock_child_env.assert_called_once_with(os.environ) @patch("apm_cli.runtime.llm_runtime.subprocess.run") def test_init_fallback(self, mock_run): @@ -81,3 +89,19 @@ def test_str_representation(self, mock_run): runtime = LLMRuntime("claude-3-sonnet") assert str(runtime) == "LLMRuntime(model=claude-3-sonnet)" + + @patch("apm_cli.runtime.llm_runtime.build_child_tls_env", return_value={"PATH": "clean"}) + @patch("apm_cli.runtime.llm_runtime.subprocess.run") + def test_is_available_uses_child_tls_env(self, mock_run, mock_child_env): + mock_run.return_value = Mock(returncode=0) + + assert LLMRuntime.is_available() is True + mock_run.assert_called_once_with( + ["llm", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + env={"PATH": "clean"}, + ) + mock_child_env.assert_called_once_with(os.environ) From a1ab076f9c245356570e9889e15beada4752e32c Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 12 Jul 2026 09:21:31 +0200 Subject: [PATCH 25/27] fix(tls): contain bootstrap writes and exact cert paths Reject child site-packages symlink escapes, preserve user SSL_CERT_FILE paths unless they exactly match a recorded bundled cert, and correct verification and planned-scope docs. Addresses the terminal security, architecture, and documentation follow-ups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/troubleshooting/ssl-issues.md | 16 +++---- src/apm_cli/core/tls_trust.py | 25 +++++++---- src/apm_cli/runtime/codex_runtime.py | 3 -- tests/integration/test_tls_r3_verify.py | 16 ++++--- tests/unit/core/test_tls_trust.py | 44 +++++++++++++++---- tests/unit/test_tls_docs_scope.py | 11 +++++ 6 files changed, 81 insertions(+), 34 deletions(-) diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index 815990f97..d9d74447c 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -58,9 +58,11 @@ apm install --verbose **Fastest fix:** install your corporate CA in the OS trust store and retry. APM picks it up automatically on the covered Python paths. -APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. This covers in-process commands such as `apm install` and the standalone frozen binary, with bundled `certifi` as a fallback. - +:::note[Planned] **Scope caveat:** only the Python-based paths are covered. The Node-based (Copilot) and Rust-based (Codex) child runtimes are **not yet covered** by OS-store propagation (tracked in #2034). Behind a TLS-proxy today, export `NODE_EXTRA_CA_CERTS=/path/to/org-ca-bundle.pem` for the Node runtime and configure the Codex/Rust runtime's own trust. +::: + +APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. This covers in-process commands such as `apm install` and the standalone frozen binary, with bundled `certifi` as a fallback. For the Python-based `llm` child runtime, `apm runtime setup llm` installs `truststore` in its virtual environment and adds a self-contained bootstrap. Corporate CAs installed in Keychain on macOS, through `update-ca-certificates`/`update-ca-trust` on Linux, or in the Windows Trusted Root store then work without APM-specific configuration. @@ -75,7 +77,6 @@ You only need the steps below when the CA is *not* in the OS store, or you want - The `llm` child runtime's OS-trust bootstrap needs the runtime venv's interpreter to be **Python 3.10+** (the `truststore` library requires 3.10). On systems where `apm runtime setup llm` builds the venv from a stock **Python 3.9** (for example Apple's `/usr/bin/python3`), `truststore` cannot install and the `llm` child silently falls back to its bundled `certifi` set behind a proxy. Use a Python 3.10+ `python3` on your `PATH` before running setup. - The initial `pip install` run *during* `apm runtime setup llm` uses pip's **own** certificate resolution, not APM's OS-trust path. Behind a MITM proxy, `pip` may fail to fetch `llm`/`truststore` before the bootstrap is even in place. Export `PIP_CERT=/path/to/org-ca-bundle.pem` (or run `pip config set global.cert /path/to/org-ca-bundle.pem`) before running setup so pip trusts your proxy CA. - APM cannot currently combine the OS store with an additional PEM bundle. Use `REQUESTS_CA_BUNDLE` to pin a single bundle instead. -- On Windows, the `schannel` backend has trust caveats. ## Configure trust @@ -155,17 +156,14 @@ If the proxy performs TLS interception, you also need the proxy's signing CA in ## Verify the fix ```bash -# Python side -python -c "import requests; print(requests.get('https://api.github.com').status_code)" +# APM Python HTTPS path +APM_LOG_LEVEL=DEBUG apm install # Git side GIT_CURL_VERBOSE=1 git ls-remote https://github.example.com/org/repo.git 2>&1 | grep -i 'ssl\|cert' - -# APM end-to-end -apm install --verbose ``` -A `200` from `requests`, a successful `ls-remote`, and a clean install confirm trust is wired through every layer APM uses. +Look for `TLS: verifying against OS trust store (truststore)` in the debug output. That line plus a clean install confirms APM's in-process Python path; a successful `ls-remote` confirms Git trust separately. Verify the managed `llm` child with its normal HTTPS-backed command after `apm runtime setup llm`. ## Development-only escape hatches diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index 9e5eb3179..ac77ef0ca 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -34,6 +34,8 @@ from collections.abc import Mapping, MutableMapping from pathlib import Path +from ..utils.path_security import PathTraversalError, ensure_path_within + logger = logging.getLogger(__name__) # The CA-bundle env vars ``requests`` honours (via merge_environment_settings); @@ -76,6 +78,7 @@ _TRUTHY = {"1", "true", "yes", "on"} _LAST_TLS_STATUS: tuple[str, tuple[object, ...]] | None = None +_KNOWN_BUNDLED_CERT_FILE: str | None = None def _record_tls_trust_status(message: str, *args: object) -> None: @@ -136,6 +139,7 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: ``certifi`` behaviour was left in place (explicit override, opt-out, ``truststore`` missing, or injection failure). Never raises. """ + global _KNOWN_BUNDLED_CERT_FILE environ = _mutable_environ(env) # Clear the bundled-default marker unconditionally, up-front, so it can @@ -145,6 +149,8 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: # to know whether the current SSL_CERT_FILE was OUR bundled default. had_bundled_marker = _env_flag(_BUNDLED_CERT_MARKER, env) environ.pop(_BUNDLED_CERT_MARKER, None) + if had_bundled_marker and environ.get(_SSL_CERT_FILE_VAR): + _KNOWN_BUNDLED_CERT_FILE = os.path.abspath(environ[_SSL_CERT_FILE_VAR]) if _env_flag(_DISABLE_ENV_VAR, env): _record_tls_trust_status("TLS: OS trust-store injection disabled (%s)", _DISABLE_ENV_VAR) @@ -219,7 +225,10 @@ def _venv_site_packages(venv_path: Path) -> Path | None: candidates.extend(venv_path.glob("Lib/site-packages")) for candidate in candidates: if candidate.is_dir(): - return candidate + try: + return ensure_path_within(candidate, venv_path) + except (OSError, PathTraversalError): + continue return None @@ -289,20 +298,20 @@ def _is_bundled_certifi(path: str) -> bool: The frozen runtime hook (build/hooks/runtime_hook_ssl_certs.py) sets ``SSL_CERT_FILE`` to ``certifi.where()``. A genuine user-set ``SSL_CERT_FILE`` - must NEVER match. We compare against ``certifi.where()`` and, as a - frozen-safe fallback, the ``certifi/cacert.pem`` path tail (the frozen - ``_MEIPASS`` path may differ from the live dev ``certifi.where()``). + must NEVER match. Compare exact normalized paths against the path captured + from the internal marker and against ``certifi.where()``. """ if not path: return False - # Match on path COMPONENTS, not a raw suffix: a genuine user bundle at - # e.g. /opt/mycertifi/cacert.pem must NOT be treated as APM's bundled set. - if path.replace("\\", "/").split("/")[-2:] == ["certifi", "cacert.pem"]: + normalized = os.path.normcase(os.path.abspath(path)) + if _KNOWN_BUNDLED_CERT_FILE and normalized == os.path.normcase( + os.path.abspath(_KNOWN_BUNDLED_CERT_FILE) + ): return True try: import certifi - return os.path.abspath(path) == os.path.abspath(certifi.where()) + return normalized == os.path.normcase(os.path.abspath(certifi.where())) except Exception: return False diff --git a/src/apm_cli/runtime/codex_runtime.py b/src/apm_cli/runtime/codex_runtime.py index 165212235..77d81b822 100644 --- a/src/apm_cli/runtime/codex_runtime.py +++ b/src/apm_cli/runtime/codex_runtime.py @@ -1,10 +1,8 @@ """Codex runtime adapter for APM.""" -import os import subprocess from typing import Any -from ..core.tls_trust import build_child_tls_env from .base import RuntimeAdapter, _stream_subprocess_output from .utils import find_runtime_binary @@ -42,7 +40,6 @@ def execute_prompt(self, prompt_content: str, **kwargs) -> str: codex_binary = find_runtime_binary("codex") or "codex" output_lines, return_code = _stream_subprocess_output( [codex_binary, "exec", "--skip-git-repo-check", prompt_content], - env=build_child_tls_env(os.environ), timeout=300, ) diff --git a/tests/integration/test_tls_r3_verify.py b/tests/integration/test_tls_r3_verify.py index 548b72e89..37b234635 100644 --- a/tests/integration/test_tls_r3_verify.py +++ b/tests/integration/test_tls_r3_verify.py @@ -242,15 +242,19 @@ def test_v2_build_child_env_drops_bundled_certifi(): assert marker not in user -def test_v2_build_child_env_drops_frozen_certifi_shape(): - from apm_cli.core.tls_trust import build_child_tls_env +def test_v2_build_child_env_drops_recorded_frozen_certifi_path(monkeypatch): + from apm_cli.core import tls_trust # The frozen hook sets SSL_CERT_FILE to a certifi/cacert.pem path under - # _MEIPASS whose prefix differs from the live certifi.where(); the tail - # match must still classify it as bundled and drop it. Cover both slashes. - posix = build_child_tls_env({"SSL_CERT_FILE": "/var/_MEIabc/certifi/cacert.pem"}) + # _MEIPASS. The parent records that exact path before clearing its internal + # marker, so unrelated user paths with the same suffix remain untouched. + posix_path = "/var/_MEIabc/certifi/cacert.pem" + monkeypatch.setattr(tls_trust, "_KNOWN_BUNDLED_CERT_FILE", posix_path) + posix = tls_trust.build_child_tls_env({"SSL_CERT_FILE": posix_path}) assert "SSL_CERT_FILE" not in posix - windows = build_child_tls_env({"SSL_CERT_FILE": "C:\\Temp\\_MEI9\\certifi\\cacert.pem"}) + windows_path = "C:\\Temp\\_MEI9\\certifi\\cacert.pem" + monkeypatch.setattr(tls_trust, "_KNOWN_BUNDLED_CERT_FILE", windows_path) + windows = tls_trust.build_child_tls_env({"SSL_CERT_FILE": windows_path}) assert "SSL_CERT_FILE" not in windows diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index fe109a741..9a2d32f67 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -410,6 +410,22 @@ def test_ensure_child_tls_bootstrap_returns_false_for_missing_site_packages(tmp_ assert ensure_child_tls_bootstrap(tmp_path / "does-not-exist") is False +def test_ensure_child_tls_bootstrap_rejects_site_packages_symlink_escape(tmp_path): + venv = tmp_path / "venv" + python_dir = venv / "lib" / "python3.12" + python_dir.mkdir(parents=True) + outside = tmp_path / "shared-site-packages" + outside.mkdir() + try: + (python_dir / "site-packages").symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + + assert ensure_child_tls_bootstrap(venv) is False + assert not (outside / "_apm_tls_bootstrap.py").exists() + assert not (outside / "_apm_tls.pth").exists() + + # --------------------------------------------------------------------------- # T2 (H1) -- the .pth is GENERATED inline, so delivery does not depend on the # source .pth being packaged into the wheel (setuptools' packages.find drops @@ -488,10 +504,13 @@ def test_build_child_tls_env_drops_bundled_certifi_ssl_cert_file(): assert child["PATH"] == "/usr/bin" -def test_build_child_tls_env_drops_frozen_meipass_certifi_tail(tmp_path): - # A frozen _MEIPASS path won't equal the live certifi.where(); the - # certifi/cacert.pem tail match must still catch it. +def test_build_child_tls_env_drops_recorded_frozen_certifi_path(monkeypatch): + import apm_cli.core.tls_trust as tls + + # The frozen hook path is recorded while its internal marker is present. + # Exact matching avoids classifying an unrelated user path by suffix. frozen = "/tmp/_MEIabc123/certifi/cacert.pem" + monkeypatch.setattr(tls, "_KNOWN_BUNDLED_CERT_FILE", frozen) child = build_child_tls_env({_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": frozen}) assert "SSL_CERT_FILE" not in child @@ -506,7 +525,9 @@ def test_build_child_tls_env_preserves_genuine_user_ssl_cert_file(tmp_path): assert child["SSL_CERT_FILE"] == str(user_ca) -def test_build_child_tls_env_preserves_certifi_lookalike_dir(): +def test_build_child_tls_env_preserves_certifi_lookalike_dir(monkeypatch): + import apm_cli.core.tls_trust as tls + # F1 (round-4): the match is on path COMPONENTS, not a raw suffix. A user # bundle under a directory that merely ENDS in "certifi" (e.g. a corporate # "mycertifi/") must be preserved, not mistaken for APM's bundled set. @@ -517,13 +538,20 @@ def test_build_child_tls_env_preserves_certifi_lookalike_dir(): ): child = build_child_tls_env({_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": lookalike}) assert child.get("SSL_CERT_FILE") == lookalike, f"lookalike {lookalike} was wrongly dropped" - # The genuine component boundary still matches. - child = build_child_tls_env( - {_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": "/x/certifi/cacert.pem"} - ) + # Only the exact path recorded from the frozen hook matches. + frozen = "/x/certifi/cacert.pem" + monkeypatch.setattr(tls, "_KNOWN_BUNDLED_CERT_FILE", frozen) + child = build_child_tls_env({_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": frozen}) assert "SSL_CERT_FILE" not in child +def test_build_child_tls_env_preserves_user_certifi_component_path(): + user_bundle = "/opt/certifi/cacert.pem" + child = build_child_tls_env({"SSL_CERT_FILE": user_bundle}) + + assert child["SSL_CERT_FILE"] == user_bundle + + # --------------------------------------------------------------------------- # T5 (M3) -- a write failure must leave NO partial _apm_tls_bootstrap.py under a # live .pth and must return False (atomic-write contract). diff --git a/tests/unit/test_tls_docs_scope.py b/tests/unit/test_tls_docs_scope.py index a1bd2ab15..cf5eeb779 100644 --- a/tests/unit/test_tls_docs_scope.py +++ b/tests/unit/test_tls_docs_scope.py @@ -112,3 +112,14 @@ def test_enterprise_security_docs_transport_trust_model(): assert ".pth" in security assert "Node" in security assert "Rust" in security + + +def test_ssl_docs_verify_apm_path_and_mark_planned_scope(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).read_text(encoding="utf-8") + + assert ":::note[Planned]" in docs + assert 'python -c "import requests' not in docs + assert "APM_LOG_LEVEL=DEBUG apm install" in docs + assert "schannel" not in docs.lower() From 2ffba27429d0814d089a2d69345bb0790c435d00 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 12 Jul 2026 09:25:37 +0200 Subject: [PATCH 26/27] chore(ci): retrigger checks for PR #2005 The synchronize webhook for the preceding head produced no workflow runs; this empty commit retriggers the required checks without changing the tree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From e08aa2145116f9c566057661c5a81d60ae211527 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 12 Jul 2026 09:26:58 +0200 Subject: [PATCH 27/27] docs(changelog): preserve unreleased separator Keep the PR #2005 entry separated from the newly cut v0.25.0 release block after merging current main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01760967d..a0567905b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 certifi-only behavior. Node (Copilot) and Rust (Codex) children are not yet covered and retain their own trust configuration; tracked in #2034. (closes #2004) (#2005) + ## [0.25.0] - 2026-07-12 ### Added