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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,57 @@ API-backed commands commonly accept these options. Explicit CLI options win over
| `--config` | YAML/JSON defaults. Many commands accept a single object, a list of objects, or `_defaults` + `configs`. |
| `--log-type` | SDK progress logs: `console`, `none`, or `file`. Logs go to stderr or a temp log file so structured stdout stays parseable. |
| `TANGLE_VERBOSE=1` | Redacted HTTP request/response diagnostics only. This is separate from normal progress logging. |
| `--ca-bundle` | Global CLI flag: path to a PEM CA bundle used as the TLS trust store for every transport. Overrides `TANGLE_API_CA_BUNDLE`. Place before the subcommand. |
| `--verify-tls` / `--no-verify-tls` | Global CLI flag: enable or disable TLS verification for every transport. Overrides `TANGLE_API_VERIFY_TLS`. `--no-verify-tls` is local-development only. Place before the subcommand. |
| `TANGLE_API_CA_BUNDLE` | Path to a PEM CA bundle used to verify TLS for every transport. Use this to trust a private or corporate CA without disabling verification. |
| `TANGLE_API_VERIFY_TLS` | TLS verification toggle. Values `0`, `false`, or `no` (case/space-insensitive) disable verification; any other nonempty value keeps it on. |

### TLS verification

TLS certificate verification is enabled by default for all HTTP transports (schema
fetches, `tangle api` calls, and the programmatic clients). The effective setting is
resolved with the following precedence, highest to lowest:

1. An explicit `verify=` argument to the Python clients (a `bool` or a path to a CA bundle).
2. The global CLI flags `--ca-bundle` / `--verify-tls` / `--no-verify-tls`.
3. `TANGLE_API_CA_BUNDLE` — verify against the given CA bundle.
4. `TANGLE_API_VERIFY_TLS` — enable or disable verification.
5. The secure default: verification enabled against the system trust store.

The global CLI flags are true root options that apply to every command — the static
`tangle sdk ...` clients, the dynamic `tangle api ...` commands, and `tangle api refresh`.
Place them **before** the subcommand, for example `tangle --ca-bundle ca.pem api ...` or
`tangle --no-verify-tls sdk ...`. They are honored even by the dynamic OpenAPI schema
discovery that runs before command dispatch. A defaulted (absent) flag does not override
the environment: when a flag is not supplied, the `TANGLE_API_*` variables and the standard
`REQUESTS_CA_BUNDLE` / `CURL_CA_BUNDLE` handling still apply. `--ca-bundle` combined with an
explicit `--no-verify-tls` is contradictory and fails fast before any request; `--ca-bundle`
with `--verify-tls` is redundant but accepted.

If both env vars are set, `TANGLE_API_CA_BUNDLE` wins and TLS stays verified against the
bundle. Empty values are treated as unset. A `--ca-bundle` or `TANGLE_API_CA_BUNDLE` that
does not point to an existing file fails fast with an actionable error before any request is
made. When no Tangle-specific setting is provided, the standard `REQUESTS_CA_BUNDLE` /
`CURL_CA_BUNDLE` handling and any caller-supplied `requests.Session.verify` are left
untouched.

For a private CA, prefer `--ca-bundle` / `TANGLE_API_CA_BUNDLE` over disabling verification:
it keeps certificates verified against a trusted root. `--no-verify-tls` /
`TANGLE_API_VERIFY_TLS=0` disables verification entirely and is intended for local
development only — never use it against production endpoints.

```bash
# Trust a private CA with the global flag (recommended for internal/self-hosted APIs)
uv run tangle --ca-bundle /etc/ssl/private-ca.pem \
api refresh --base-url https://internal.example

# Or via environment variable
TANGLE_API_CA_BUNDLE=/etc/ssl/private-ca.pem \
uv run tangle api refresh --base-url https://internal.example

# Disable verification (local development only)
uv run tangle --no-verify-tls api refresh --base-url https://localhost:8443
```

Examples for protected APIs:

Expand Down
16 changes: 14 additions & 2 deletions packages/tangle-cli/src/tangle_cli/api_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,10 +640,22 @@ def _argv_dispatches_dynamic_command(argv: list[str]) -> bool:


def _api_argv_tail(argv: list[str]) -> list[str] | None:
"""Return args after the root `api` command, or None for non-API invocations."""
"""Return args after the root `api` command, or None for non-API invocations.

Global TLS flags (``--ca-bundle``/``--verify-tls``/``--no-verify-tls``) may
precede the subcommand, so they are skipped before locating `api`.
"""

args = list(argv[1:])
for index, arg in enumerate(args):
index = 0
while index < len(args):
arg = args[index]
if arg == "--ca-bundle" and index + 1 < len(args):
index += 2
continue
if arg.startswith("--ca-bundle=") or arg in {"--verify-tls", "--no-verify-tls"}:
index += 1
continue
if arg == "--":
if index + 1 < len(args) and args[index + 1] == "api":
return args[index + 2 :]
Expand Down
8 changes: 8 additions & 0 deletions packages/tangle-cli/src/tangle_cli/api_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
_normalize_base_url,
_openapi_url,
_request_headers,
_VERIFY_UNSET,
default_base_url,
httpx_verify,
)

SUPPORTED_METHODS = {"get", "post", "put", "patch", "delete"}
Expand Down Expand Up @@ -122,6 +124,7 @@ def fetch_schema(
auth_header: str | None = None,
headers: dict[str, str] | None = None,
include_env_credentials: bool = True,
verify: Any = _VERIFY_UNSET,
) -> dict[str, Any]:
"""Fetch ``/openapi.json``, applying bearer and custom auth headers."""

Expand All @@ -136,6 +139,7 @@ def fetch_schema(
include_env_credentials=include_env_credentials,
),
timeout=DEFAULT_TIMEOUT_SECONDS,
verify=httpx_verify(verify),
)
response.raise_for_status()
payload = response.text
Expand All @@ -152,6 +156,7 @@ def refresh_schema(
auth_header: str | None = None,
headers: dict[str, str] | None = None,
include_env_credentials: bool = True,
verify: Any = _VERIFY_UNSET,
) -> tuple[dict[str, Any], Path]:
"""Fetch and cache the latest schema for a backend."""

Expand All @@ -163,6 +168,7 @@ def refresh_schema(
auth_header,
headers,
include_env_credentials=include_env_credentials,
verify=verify,
)
path = write_cached_schema(schema, base_url)
return schema, path
Expand All @@ -175,6 +181,7 @@ def load_or_fetch_schema(
auth_header: str | None = None,
headers: dict[str, str] | None = None,
include_env_credentials: bool = True,
verify: Any = _VERIFY_UNSET,
) -> dict[str, Any]:
"""Use a cached schema when available, otherwise fetch once and cache it."""

Expand All @@ -188,6 +195,7 @@ def load_or_fetch_schema(
auth_header,
headers,
include_env_credentials=include_env_credentials,
verify=verify,
)
return schema

Expand Down
126 changes: 126 additions & 0 deletions packages/tangle-cli/src/tangle_cli/api_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import re
import ssl
import sys
import urllib.parse
from pathlib import Path
Expand All @@ -16,6 +17,20 @@
DEFAULT_TIMEOUT_SECONDS = 30.0
_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
_MISSING = object()

# Canonical resolved TLS verification value: ``True``/``False`` or a CA bundle
# path. Both requests and httpx transports adapt this single contract.
VerifyValue = bool | str
VerifyArgument = bool | str | os.PathLike[str] | None
_VERIFY_UNSET = object()
_TLS_FALSE_VALUES = frozenset({"0", "false", "no"})

# Process-wide TLS override set from global CLI flags (``--ca-bundle`` /
# ``--verify-tls`` / ``--no-verify-tls``). It sits between an explicit
# ``verify=`` argument and the environment variables in :func:`resolve_verify`,
# so a single resolver serves every transport, including the schema discovery
# that runs before normal command dispatch.
_CLI_VERIFY_OVERRIDE: Any = _VERIFY_UNSET
_SENSITIVE_HEADER_NAMES = {"authorization", "cloud-auth", "cookie", "x-api-key"}
_SENSITIVE_KEY_RE = re.compile(
r"(authorization|authentication|(^|[-_])auth($|[-_])|cloud[-_]?auth|cookie|x[-_]?api[-_]?key|token|secret|password|credential|pre[-_]?signed[-_]?url|signed[-_]?url)",
Expand All @@ -40,6 +55,115 @@ def tangle_verbose_enabled() -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"}


def _parse_verify_flag(raw: str) -> bool:
"""Interpret ``TANGLE_API_VERIFY_TLS``.

Only the case/space-insensitive values ``0``, ``false``, and ``no`` disable
verification; any other nonempty value keeps it enabled.
"""

return raw.strip().lower() not in _TLS_FALSE_VALUES


def _validate_ca_bundle(path: str, source: str) -> str:
"""Return an existing CA bundle path or fail before any network request."""

candidate = Path(path).expanduser()
if not candidate.is_file():
raise SystemExit(
f"{source} points to a CA bundle that does not exist: {path!r}. "
"Provide a path to an existing PEM file, or unset it to use the "
"system trust store."
)
return str(candidate)


def _coerce_explicit_verify(value: bool | str | os.PathLike[str]) -> VerifyValue:
if isinstance(value, bool):
return value
if isinstance(value, (str, os.PathLike)):
return _validate_ca_bundle(os.fspath(value), "verify")
raise SystemExit("verify must be a bool or a path to a CA bundle file")


def configure_cli_verify(
ca_bundle: str | os.PathLike[str] | None = None,
verify_tls: bool | None = None,
) -> None:
"""Install (or clear) the process-wide TLS override from global CLI flags.

``ca_bundle`` is a path to a PEM trust store; ``verify_tls`` is the tri-state
``--verify-tls`` / ``--no-verify-tls`` flag where ``None`` means the flag was
not supplied. A ``--ca-bundle`` combined with an explicit
``--no-verify-tls`` is contradictory and fails fast before any network
request. Passing neither clears any previously installed override.

The resolved value is validated here so an invalid or missing CA bundle
fails before dynamic schema discovery. It is consulted by
:func:`resolve_verify` for every transport.
"""

global _CLI_VERIFY_OVERRIDE
if ca_bundle is not None:
if verify_tls is False:
raise SystemExit(
"--ca-bundle cannot be combined with --no-verify-tls: a CA "
"bundle only takes effect when verification is enabled. Drop "
"one of the two flags."
)
_CLI_VERIFY_OVERRIDE = _validate_ca_bundle(os.fspath(ca_bundle), "--ca-bundle")
return
if verify_tls is not None:
_CLI_VERIFY_OVERRIDE = bool(verify_tls)
return
_CLI_VERIFY_OVERRIDE = _VERIFY_UNSET


def resolve_verify(verify: Any = _VERIFY_UNSET) -> Any:
"""Resolve the effective TLS verification setting.

Precedence, highest to lowest: an explicit ``verify`` argument, the global
CLI override (``--ca-bundle`` / ``--verify-tls`` / ``--no-verify-tls``), a
nonempty ``TANGLE_API_CA_BUNDLE``, ``TANGLE_API_VERIFY_TLS``, then a secure
enabled default. When none of these are set, ``_VERIFY_UNSET`` is returned
so callers can preserve library and caller defaults (for example requests'
``REQUESTS_CA_BUNDLE``/``CURL_CA_BUNDLE`` handling and a caller-supplied
``Session.verify``). Empty environment values are treated as unset.
"""

if verify is not _VERIFY_UNSET and verify is not None:
return _coerce_explicit_verify(verify)
if _CLI_VERIFY_OVERRIDE is not _VERIFY_UNSET:
return _CLI_VERIFY_OVERRIDE
ca_bundle = os.environ.get("TANGLE_API_CA_BUNDLE", "")
if ca_bundle.strip():
return _validate_ca_bundle(ca_bundle.strip(), "TANGLE_API_CA_BUNDLE")
flag = os.environ.get("TANGLE_API_VERIFY_TLS", "")
if flag.strip():
return _parse_verify_flag(flag)
return _VERIFY_UNSET


def resolve_verify_default(verify: Any = _VERIFY_UNSET) -> VerifyValue:
"""Like :func:`resolve_verify`, but fall back to the secure ``True`` default."""

resolved = resolve_verify(verify)
return True if resolved is _VERIFY_UNSET else resolved


def httpx_verify(verify: Any = _VERIFY_UNSET) -> bool | ssl.SSLContext:
"""Adapt the resolved verify value to what httpx expects.

httpx 0.28 deprecates string CA-bundle paths, so a path is turned into an
:class:`ssl.SSLContext`; booleans pass through unchanged.
"""

resolved = resolve_verify_default(verify)
if isinstance(resolved, bool):
return resolved
return ssl.create_default_context(cafile=resolved)


def _redact_headers(headers: dict[str, Any] | None) -> dict[str, Any]:
redacted: dict[str, Any] = {}
for name, value in (headers or {}).items():
Expand Down Expand Up @@ -280,6 +404,7 @@ def request_operation(
timeout: float = DEFAULT_TIMEOUT_SECONDS,
allow_body_file_references: bool = False,
include_env_credentials: bool = True,
verify: Any = _VERIFY_UNSET,
) -> httpx.Response:
"""Dispatch one normalized OpenAPI operation as an HTTP request.

Expand All @@ -306,6 +431,7 @@ def request_operation(
content=content,
headers=request_headers,
timeout=timeout,
verify=httpx_verify(verify),
)
if tangle_verbose_enabled():
log_http_exchange(
Expand Down
Loading