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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ base_cli.TelemetryOptions(...)

### `TelemetrySession`
**Kind:** class<br>
**Signature:** `TelemetrySession(span: 'Any', started_monotonic_ns: 'int') -> None`
**Signature:** `TelemetrySession(span: 'Any', started_monotonic_ns: 'int', context_api: 'Any | None' = None, context_token: 'Any | None' = None) -> None`

**Behavior:** Best-effort state for a single lifecycle span.

Expand Down
37 changes: 37 additions & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,43 @@ exceptions. Interrupts and other non-success outcomes are therefore visible as
errors while retaining the existing `base_cli.outcome` attribute for detailed
dashboard filtering.

## Child-process run and trace propagation

Use the active context when a command launches another CLI:

```python
import subprocess

subprocess.run(["other-cli", "sync"], env=ctx.child_environment(), check=True)
```

`Context.child_environment()` returns a copy of the environment containing the
following language-neutral contract:

| Variable | Meaning |
| --- | --- |
| `BASE_CLI_RUN_ID` | The current run identity. It is validated as one safe path component. |
| `BASE_CLI_PARENT_RUN_ID` | The launching run identity. |
| `BASE_CLI_RUN_ROOT` | The absolute, framework-owned bundle to reuse when it exists. |
| `BASE_CLI_RUN_SCOPE` | `internal` for a propagated child, or `primary` for a deliberate top-level record. |
| `BASE_CLI_PROPAGATE` | Set to `0` to opt out of runtime and trace propagation. |
| `TRACEPARENT` / `TRACESTATE` | W3C trace context for OpenTelemetry-enabled consumers. |

The generic profile honors these variables without a custom `RuntimeResolver`.
An inherited `BASE_CLI_RUN_ROOT` must be absolute, non-symlinked, and contain
valid `run.json` metadata; malformed IDs, traversal, absolute IDs, and invalid
scopes fail with an actionable configuration error before any path is created.
The generic profile's explicit `RuntimeResolver`, when supplied, is
authoritative and is not overridden by environment values.

An inherited child reuses the parent's bundle and is marked `internal`; only
the process that created the bundle owns its lifecycle metadata and retention.
The framework cannot intercept arbitrary subprocess APIs, so callers must pass
the returned mapping (or selectively copy its documented variables). Use
`ctx.child_environment(propagate=False)` for an intentionally independent
child. OpenTelemetry extraction/injection remains best-effort and never makes
the optional dependency required.

## Logger ownership

The lifecycle uses `base_cli.<cli_name>` and owns only the handlers it creates.
Expand Down
1 change: 1 addition & 0 deletions docs/security-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ not a replacement for explicit `sensitive=True` declarations.
| A plugin executes unwanted code or changes command behavior | Lazy discovery, deterministic ordering, duplicate detection, explicit `load`/`load_all`, canonical `group:name` or `dist:distribution` allowlists, and `disabled`; `tests/test_extensions.py` | Loaded extensions have the process's authority and are not sandboxed. Capability extras are informational metadata, not authorization. Pin and review distributions, disable discovery by default where possible, and use an allowlist. |
| Concurrency corrupts logs, history, metadata, or retention state | Atomic replacement, sidecar/process locks, per-run leases, bounded complete-bundle retention, and concurrency/adversarial tests | The framework cannot make consumer databases or external stores transactional. Use a transactional writer and define recovery for multi-process consumers. Unknown run liveness fails closed. |
| Child/inherited runs mutate or delete a parent's artifacts | Inherited runtimes do not own a new bundle, do not finalize parent metadata, and are excluded from cleanup/retention; inherited-run tests cover startup, success, and failure | Parent and child processes still share the user's filesystem authority. Consumers must validate parent provenance and avoid inheriting from untrusted paths. |
| Untrusted process environment redirects a child into an arbitrary run bundle | The generic propagation contract validates single-component run IDs, absolute non-symlinked bundle roots, active `run.json` metadata, and `primary`/`internal` scopes before constructing a layout; `Context.child_environment()` emits a copied, opt-out-capable contract | Environment values are still same-user input. Use the helper rather than forwarding arbitrary paths, keep cache roots private, and provide an explicit `RuntimeResolver` when a consumer has a stronger provenance policy. |
| Telemetry leaks argv, config, paths, or secrets | Telemetry is opt-in; only run ID, CLI name, environment, dry-run, outcome, exit code, and duration are attached; missing/broken exporters are no-ops; `tests/test_integrations.py` | A consumer-supplied tracer/exporter can add arbitrary attributes or send to an untrusted endpoint. Review provider configuration, use TLS/authentication, and never attach raw command data. |
| Optional integrations or dependencies broaden the attack surface | Rich, Typer, and OpenTelemetry are optional extras; import/use is lazy; quality CI runs Bandit and pip-audit | A vulnerable or malicious dependency remains a supply-chain risk. Pin/lock deployments, review updates, and install only required extras. |
| Secondary persistence failure hides the real command result or leaves false state | Transactional startup/teardown, terminal outcome finalization, best-effort history, and rollback tests | A crash can still lose diagnostics. Treat logs/history as evidence, not an authorization or billing source, and alert on persistence warnings. |
Expand Down
14 changes: 7 additions & 7 deletions lib/python/base_cli/_app_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1115,14 +1115,14 @@ def wrapper(**kwargs: Any) -> Any:
recorder = RunRecorder(context, started_at, started_monotonic_ns)
except BaseException as exc: # pylint: disable=broad-exception-caught
_warn_lifecycle_failure(context, "Run recorder construction failed", exc)
finish_telemetry(
telemetry_session,
context,
outcome,
ended_monotonic_ns=ended_monotonic_ns,
exception=exception,
)
if recorder is not None:
finish_telemetry(
telemetry_session,
context,
outcome,
ended_monotonic_ns=ended_monotonic_ns,
exception=exception,
)
_finish_run_recorder(
recorder,
outcome,
Expand Down
176 changes: 176 additions & 0 deletions lib/python/base_cli/_propagation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Validated process-boundary propagation for runtime identity.

The environment is a transport, not an authority. Values received from it are
validated before they influence runtime state or filesystem paths.
"""

from __future__ import annotations

import json
import os
import stat
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path

from ._runtime import _open_absolute_directory
from .errors import ConfigurationError

RUN_ID_ENV = "BASE_CLI_RUN_ID"
PARENT_RUN_ID_ENV = "BASE_CLI_PARENT_RUN_ID"
RUN_ROOT_ENV = "BASE_CLI_RUN_ROOT"
RUN_SCOPE_ENV = "BASE_CLI_RUN_SCOPE"
PROPAGATE_ENV = "BASE_CLI_PROPAGATE"
TRACEPARENT_ENV = "TRACEPARENT"
TRACESTATE_ENV = "TRACESTATE"

_SAFE_RUN_ID_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
_VALID_SCOPES = frozenset({"primary", "internal"})


@dataclass(frozen=True)
class RuntimePropagation:
"""Validated values received across a process boundary."""

run_id: str | None = None
parent_run_id: str | None = None
run_root: Path | None = None
scope: str | None = None


def read_runtime_propagation(environ: Mapping[str, str] | None = None) -> RuntimePropagation:
"""Read and validate the base-cli runtime environment contract."""

values = os.environ if environ is None else environ
if not _is_enabled(values.get(PROPAGATE_ENV)):
return RuntimePropagation()

run_id = _optional_run_id(values.get(RUN_ID_ENV), RUN_ID_ENV)
parent_run_id = _optional_run_id(values.get(PARENT_RUN_ID_ENV), PARENT_RUN_ID_ENV)
raw_root = _optional_text(values.get(RUN_ROOT_ENV))
scope = _optional_text(values.get(RUN_SCOPE_ENV))
if scope is not None:
scope = scope.lower()
if scope not in _VALID_SCOPES:
raise ConfigurationError(f"{RUN_SCOPE_ENV} must be 'primary' or 'internal', not {scope!r}.")
if raw_root is not None and run_id is None and parent_run_id is None:
raise ConfigurationError(
f"{RUN_ROOT_ENV} requires {RUN_ID_ENV} or {PARENT_RUN_ID_ENV}; "
"a runtime path cannot be trusted without an identity."
)
run_root = _validate_run_root(raw_root) if raw_root is not None else None
return RuntimePropagation(
run_id=run_id,
parent_run_id=parent_run_id,
run_root=run_root,
scope=scope,
)


def validate_run_root(run_root: Path, expected_run_id: str | None = None) -> Path:
"""Validate a parent run bundle supplied by a process boundary.

The path must be absolute, an actual directory (not a symlink), and carry
a private ``run.json`` whose identity matches the supplied run ID when one
is available. The returned path is resolved and safe to use as an
inherited runtime root.
"""

candidate = Path(run_root).expanduser()
if not candidate.is_absolute():
raise ConfigurationError(f"{RUN_ROOT_ENV} must be an absolute path.")
if candidate != candidate.resolve(strict=False):
raise ConfigurationError(f"{RUN_ROOT_ENV} must not contain symlinked path components.")
try:
resolved = candidate.resolve(strict=True)
except OSError as exc:
raise ConfigurationError(f"{RUN_ROOT_ENV} does not point to a readable directory: {exc}") from exc
if not resolved.is_dir() or resolved.is_symlink():
Comment thread
codeforester marked this conversation as resolved.
raise ConfigurationError(f"{RUN_ROOT_ENV} must point to a real directory.")

try:
payload = _read_run_metadata(resolved)
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ConfigurationError(f"{RUN_ROOT_ENV} has no valid run metadata.") from exc
metadata_run_id = payload.get("run_id") if isinstance(payload, dict) else None
if not isinstance(metadata_run_id, str) or not _is_safe_run_id(metadata_run_id):
raise ConfigurationError(f"{RUN_ROOT_ENV} has invalid run metadata.")
if expected_run_id is not None and metadata_run_id != expected_run_id:
raise ConfigurationError(f"{RUN_ROOT_ENV} belongs to run {metadata_run_id!r}, not {expected_run_id!r}.")
return resolved


def _read_run_metadata(run_root: Path) -> object:
"""Read run metadata through a stable directory handle when available."""

if _supports_secure_directory_reads():
root_fd = _open_absolute_directory(run_root)
metadata_fd: int | None = None
try:
flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
metadata_fd = os.open("run.json", flags, dir_fd=root_fd)
metadata_stat = os.fstat(metadata_fd)
if not stat.S_ISREG(metadata_stat.st_mode):
raise OSError("run.json is not a regular file")
with os.fdopen(metadata_fd, "r", encoding="utf-8") as stream:
metadata_fd = None
return json.load(stream)
finally:
if metadata_fd is not None:
os.close(metadata_fd)
os.close(root_fd)

metadata_path = run_root / "run.json"
if metadata_path.is_symlink() or not metadata_path.is_file():
raise OSError("run.json is not a regular file")
return json.loads(metadata_path.read_text(encoding="utf-8"))


def _supports_secure_directory_reads() -> bool:
return (
os.name != "nt" and hasattr(os, "O_DIRECTORY") and hasattr(os, "O_NOFOLLOW") and os.open in os.supports_dir_fd
)


def _validate_run_root(raw_root: str) -> Path:
return validate_run_root(Path(raw_root))


def _optional_run_id(value: str | None, variable: str) -> str | None:
text = _optional_text(value)
if text is None:
return None
if not _is_safe_run_id(text):
raise ConfigurationError(
f"{variable} must be a single filesystem-safe run ID containing only letters, digits, '.', '_' or '-'."
)
return text


def _optional_text(value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
return text or None


def _is_safe_run_id(value: str) -> bool:
return bool(value) and ".." not in value and all(character in _SAFE_RUN_ID_CHARS for character in value)


def _is_enabled(value: str | None) -> bool:
return value is None or value.strip().lower() not in {"0", "false", "no", "off"}


__all__ = [
"PARENT_RUN_ID_ENV",
"PROPAGATE_ENV",
"RUN_ID_ENV",
"RUN_ROOT_ENV",
"RUN_SCOPE_ENV",
"TRACEPARENT_ENV",
"TRACESTATE_ENV",
"RuntimePropagation",
"read_runtime_propagation",
"validate_run_root",
]
50 changes: 50 additions & 0 deletions lib/python/base_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,18 @@
from typing import Any, Generic, TypeVar

from ._cleanup import remove_owned_temp_directory
from ._propagation import (
PARENT_RUN_ID_ENV,
PROPAGATE_ENV,
RUN_ID_ENV,
RUN_ROOT_ENV,
RUN_SCOPE_ENV,
TRACEPARENT_ENV,
TRACESTATE_ENV,
)
from .config import FrameworkConfig
from .history import display_command as _default_history_display_command
from .integrations import inject_trace_context

_current_context: contextvars.ContextVar[Context[Any, Any, Any] | None] = contextvars.ContextVar(
"base_cli_current_context",
Expand Down Expand Up @@ -108,6 +118,46 @@ def bind_project(self, project_name: str | None, project_root: Path, manifest_pa
self.project_root = project_root.resolve()
self.manifest_path = manifest_path.resolve() if manifest_path is not None else None

def child_environment(
self,
*,
environ: Mapping[str, str] | None = None,
propagate: bool = True,
) -> dict[str, str]:
"""Return an environment for a child process correlated to this run.

The returned copy carries the run identity, parent identity, shared
bundle path, and internal history scope. When the optional telemetry
extra is installed, the current W3C trace context is injected too.
Set ``propagate=False`` for an intentionally independent child.
"""

child_env = dict(os.environ if environ is None else environ)
if not propagate:
for variable in (
RUN_ID_ENV,
PARENT_RUN_ID_ENV,
RUN_ROOT_ENV,
RUN_SCOPE_ENV,
TRACEPARENT_ENV,
TRACESTATE_ENV,
):
child_env.pop(variable, None)
child_env[PROPAGATE_ENV] = "0"
return child_env

child_env[PROPAGATE_ENV] = "1"
child_env[RUN_ID_ENV] = self.run_id
child_env[PARENT_RUN_ID_ENV] = self.run_id
child_env[RUN_SCOPE_ENV] = "internal"
if self.run_root is not None:
child_env[RUN_ROOT_ENV] = str(self.run_root.resolve())
else:
child_env.pop(RUN_ROOT_ENV, None)

inject_trace_context(child_env)
return child_env

def _warn_cleanup_failure(self, message: str, *args: object) -> None:
try:
self.log.warning(message, *args)
Expand Down
Loading
Loading