Skip to content

Commit 2ada5aa

Browse files
committed
feat: propagate run identity across child processes
1 parent 0e9236d commit 2ada5aa

7 files changed

Lines changed: 468 additions & 10 deletions

File tree

‎docs/integrations.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,43 @@ exceptions. Interrupts and other non-success outcomes are therefore visible as
6262
errors while retaining the existing `base_cli.outcome` attribute for detailed
6363
dashboard filtering.
6464

65+
## Child-process run and trace propagation
66+
67+
Use the active context when a command launches another CLI:
68+
69+
```python
70+
import subprocess
71+
72+
subprocess.run(["other-cli", "sync"], env=ctx.child_environment(), check=True)
73+
```
74+
75+
`Context.child_environment()` returns a copy of the environment containing the
76+
following language-neutral contract:
77+
78+
| Variable | Meaning |
79+
| --- | --- |
80+
| `BASE_CLI_RUN_ID` | The current run identity. It is validated as one safe path component. |
81+
| `BASE_CLI_PARENT_RUN_ID` | The launching run identity. |
82+
| `BASE_CLI_RUN_ROOT` | The absolute, framework-owned bundle to reuse when it exists. |
83+
| `BASE_CLI_RUN_SCOPE` | `internal` for a propagated child, or `primary` for a deliberate top-level record. |
84+
| `BASE_CLI_PROPAGATE` | Set to `0` to opt out of runtime and trace propagation. |
85+
| `TRACEPARENT` / `TRACESTATE` | W3C trace context for OpenTelemetry-enabled consumers. |
86+
87+
The generic profile honors these variables without a custom `RuntimeResolver`.
88+
An inherited `BASE_CLI_RUN_ROOT` must be absolute, non-symlinked, and contain
89+
valid `run.json` metadata; malformed IDs, traversal, absolute IDs, and invalid
90+
scopes fail with an actionable configuration error before any path is created.
91+
The generic profile's explicit `RuntimeResolver`, when supplied, is
92+
authoritative and is not overridden by environment values.
93+
94+
An inherited child reuses the parent's bundle and is marked `internal`; only
95+
the process that created the bundle owns its lifecycle metadata and retention.
96+
The framework cannot intercept arbitrary subprocess APIs, so callers must pass
97+
the returned mapping (or selectively copy its documented variables). Use
98+
`ctx.child_environment(propagate=False)` for an intentionally independent
99+
child. OpenTelemetry extraction/injection remains best-effort and never makes
100+
the optional dependency required.
101+
65102
## Logger ownership
66103

67104
The lifecycle uses `base_cli.<cli_name>` and owns only the handlers it creates.

‎docs/security-threat-model.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ not a replacement for explicit `sensitive=True` declarations.
7777
| 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. |
7878
| 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. |
7979
| 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. |
80+
| 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. |
8081
| 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. |
8182
| 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. |
8283
| 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. |
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Validated process-boundary propagation for runtime identity.
2+
3+
The environment is a transport, not an authority. Values received from it are
4+
validated before they influence runtime state or filesystem paths.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import os
11+
from collections.abc import Mapping
12+
from dataclasses import dataclass
13+
from pathlib import Path
14+
15+
from .errors import ConfigurationError
16+
17+
RUN_ID_ENV = "BASE_CLI_RUN_ID"
18+
PARENT_RUN_ID_ENV = "BASE_CLI_PARENT_RUN_ID"
19+
RUN_ROOT_ENV = "BASE_CLI_RUN_ROOT"
20+
RUN_SCOPE_ENV = "BASE_CLI_RUN_SCOPE"
21+
PROPAGATE_ENV = "BASE_CLI_PROPAGATE"
22+
TRACEPARENT_ENV = "TRACEPARENT"
23+
TRACESTATE_ENV = "TRACESTATE"
24+
25+
_SAFE_RUN_ID_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
26+
_VALID_SCOPES = frozenset({"primary", "internal"})
27+
28+
29+
@dataclass(frozen=True)
30+
class RuntimePropagation:
31+
"""Validated values received across a process boundary."""
32+
33+
run_id: str | None = None
34+
parent_run_id: str | None = None
35+
run_root: Path | None = None
36+
scope: str | None = None
37+
38+
39+
def read_runtime_propagation(environ: Mapping[str, str] | None = None) -> RuntimePropagation:
40+
"""Read and validate the base-cli runtime environment contract."""
41+
42+
values = os.environ if environ is None else environ
43+
if not _is_enabled(values.get(PROPAGATE_ENV)):
44+
return RuntimePropagation()
45+
46+
run_id = _optional_run_id(values.get(RUN_ID_ENV), RUN_ID_ENV)
47+
parent_run_id = _optional_run_id(values.get(PARENT_RUN_ID_ENV), PARENT_RUN_ID_ENV)
48+
raw_root = _optional_text(values.get(RUN_ROOT_ENV))
49+
scope = _optional_text(values.get(RUN_SCOPE_ENV))
50+
if scope is not None:
51+
scope = scope.lower()
52+
if scope not in _VALID_SCOPES:
53+
raise ConfigurationError(f"{RUN_SCOPE_ENV} must be 'primary' or 'internal', not {scope!r}.")
54+
if raw_root is not None and run_id is None and parent_run_id is None:
55+
raise ConfigurationError(
56+
f"{RUN_ROOT_ENV} requires {RUN_ID_ENV} or {PARENT_RUN_ID_ENV}; "
57+
"a runtime path cannot be trusted without an identity."
58+
)
59+
run_root = _validate_run_root(raw_root) if raw_root is not None else None
60+
return RuntimePropagation(
61+
run_id=run_id,
62+
parent_run_id=parent_run_id,
63+
run_root=run_root,
64+
scope=scope,
65+
)
66+
67+
68+
def validate_run_root(run_root: Path, expected_run_id: str | None = None) -> Path:
69+
"""Validate a parent run bundle supplied by a process boundary.
70+
71+
The path must be absolute, an actual directory (not a symlink), and carry
72+
a private ``run.json`` whose identity matches the supplied run ID when one
73+
is available. The returned path is resolved and safe to use as an
74+
inherited runtime root.
75+
"""
76+
77+
candidate = Path(run_root).expanduser()
78+
if not candidate.is_absolute():
79+
raise ConfigurationError(f"{RUN_ROOT_ENV} must be an absolute path.")
80+
if candidate != candidate.resolve(strict=False):
81+
raise ConfigurationError(f"{RUN_ROOT_ENV} must not contain symlinked path components.")
82+
try:
83+
resolved = candidate.resolve(strict=True)
84+
except OSError as exc:
85+
raise ConfigurationError(f"{RUN_ROOT_ENV} does not point to a readable directory: {exc}") from exc
86+
if not resolved.is_dir() or resolved.is_symlink():
87+
raise ConfigurationError(f"{RUN_ROOT_ENV} must point to a real directory.")
88+
89+
metadata_path = resolved / "run.json"
90+
if metadata_path.is_symlink() or not metadata_path.is_file():
91+
raise ConfigurationError(f"{RUN_ROOT_ENV} must point to an active base-cli run bundle.")
92+
try:
93+
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
94+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
95+
raise ConfigurationError(f"{RUN_ROOT_ENV} has no valid run metadata.") from exc
96+
metadata_run_id = payload.get("run_id") if isinstance(payload, dict) else None
97+
if not isinstance(metadata_run_id, str) or not _is_safe_run_id(metadata_run_id):
98+
raise ConfigurationError(f"{RUN_ROOT_ENV} has invalid run metadata.")
99+
if expected_run_id is not None and metadata_run_id != expected_run_id:
100+
raise ConfigurationError(f"{RUN_ROOT_ENV} belongs to run {metadata_run_id!r}, not {expected_run_id!r}.")
101+
return resolved
102+
103+
104+
def _validate_run_root(raw_root: str) -> Path:
105+
return validate_run_root(Path(raw_root))
106+
107+
108+
def _optional_run_id(value: str | None, variable: str) -> str | None:
109+
text = _optional_text(value)
110+
if text is None:
111+
return None
112+
if not _is_safe_run_id(text):
113+
raise ConfigurationError(
114+
f"{variable} must be a single filesystem-safe run ID containing only letters, digits, '.', '_' or '-'."
115+
)
116+
return text
117+
118+
119+
def _optional_text(value: str | None) -> str | None:
120+
if value is None:
121+
return None
122+
text = value.strip()
123+
return text or None
124+
125+
126+
def _is_safe_run_id(value: str) -> bool:
127+
return bool(value) and ".." not in value and all(character in _SAFE_RUN_ID_CHARS for character in value)
128+
129+
130+
def _is_enabled(value: str | None) -> bool:
131+
return value is None or value.strip().lower() not in {"0", "false", "no", "off"}
132+
133+
134+
__all__ = [
135+
"PARENT_RUN_ID_ENV",
136+
"PROPAGATE_ENV",
137+
"RUN_ID_ENV",
138+
"RUN_ROOT_ENV",
139+
"RUN_SCOPE_ENV",
140+
"TRACEPARENT_ENV",
141+
"TRACESTATE_ENV",
142+
"RuntimePropagation",
143+
"read_runtime_propagation",
144+
"validate_run_root",
145+
]

‎lib/python/base_cli/context.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,58 @@ def bind_project(self, project_name: str | None, project_root: Path, manifest_pa
8585
self.project_root = project_root.resolve()
8686
self.manifest_path = manifest_path.resolve() if manifest_path is not None else None
8787

88+
def child_environment(
89+
self,
90+
*,
91+
environ: Mapping[str, str] | None = None,
92+
propagate: bool = True,
93+
) -> dict[str, str]:
94+
"""Return an environment for a child process correlated to this run.
95+
96+
The returned copy carries the run identity, parent identity, shared
97+
bundle path, and internal history scope. When the optional telemetry
98+
extra is installed, the current W3C trace context is injected too.
99+
Set ``propagate=False`` for an intentionally independent child.
100+
"""
101+
102+
child_env = dict(os.environ if environ is None else environ)
103+
from ._propagation import (
104+
PARENT_RUN_ID_ENV,
105+
PROPAGATE_ENV,
106+
RUN_ID_ENV,
107+
RUN_ROOT_ENV,
108+
RUN_SCOPE_ENV,
109+
TRACEPARENT_ENV,
110+
TRACESTATE_ENV,
111+
)
112+
113+
if not propagate:
114+
for variable in (
115+
RUN_ID_ENV,
116+
PARENT_RUN_ID_ENV,
117+
RUN_ROOT_ENV,
118+
RUN_SCOPE_ENV,
119+
TRACEPARENT_ENV,
120+
TRACESTATE_ENV,
121+
):
122+
child_env.pop(variable, None)
123+
child_env[PROPAGATE_ENV] = "0"
124+
return child_env
125+
126+
child_env[PROPAGATE_ENV] = "1"
127+
child_env[RUN_ID_ENV] = self.run_id
128+
child_env[PARENT_RUN_ID_ENV] = self.run_id
129+
child_env[RUN_SCOPE_ENV] = "internal"
130+
if self.run_root is not None:
131+
child_env[RUN_ROOT_ENV] = str(self.run_root.resolve())
132+
else:
133+
child_env.pop(RUN_ROOT_ENV, None)
134+
135+
from .integrations import inject_trace_context
136+
137+
inject_trace_context(child_env)
138+
return child_env
139+
88140
def _warn_cleanup_failure(self, message: str, *args: object) -> None:
89141
try:
90142
self.log.warning(message, *args)

‎lib/python/base_cli/integrations.py‎

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@
1010

1111
from __future__ import annotations
1212

13+
import os
1314
import time
1415
from collections.abc import Sequence
1516
from dataclasses import dataclass
1617
from typing import Any, TextIO
1718

19+
from ._propagation import TRACEPARENT_ENV, TRACESTATE_ENV
20+
1821

1922
@dataclass(frozen=True)
2023
class TelemetryOptions:
@@ -37,6 +40,8 @@ class TelemetrySession:
3740

3841
span: Any
3942
started_monotonic_ns: int
43+
context_api: Any | None = None
44+
context_token: Any | None = None
4045

4146

4247
def try_render_rich_table(
@@ -102,23 +107,57 @@ def start_telemetry(options: TelemetryOptions | None, context: Any) -> Telemetry
102107

103108
try:
104109
tracer = options.tracer
110+
otel_context_api: Any | None = None
111+
parent_context: Any | None = None
112+
otel_trace_api: Any | None = None
113+
try:
114+
from opentelemetry import context as otel_context
115+
from opentelemetry import propagate
116+
from opentelemetry import trace as otel_trace
117+
118+
# W3C carriers are lower-case; process environments are commonly
119+
# upper-case. Normalize only the carrier copy, never os.environ.
120+
carrier = {key.lower(): value for key, value in os.environ.items()}
121+
if "traceparent" in carrier or "tracestate" in carrier:
122+
parent_context = propagate.extract(carrier)
123+
otel_context_api = otel_context
124+
otel_trace_api = otel_trace
125+
except ImportError:
126+
pass
105127
if tracer is None:
106-
from opentelemetry import trace
128+
if otel_trace_api is None:
129+
from opentelemetry import trace as otel_trace
107130

108-
tracer = trace.get_tracer(
131+
otel_trace_api = otel_trace
132+
tracer = otel_trace_api.get_tracer(
109133
options.tracer_name,
110134
tracer_provider=options.tracer_provider,
111135
)
112136
attributes = _start_attributes(context)
113-
try:
137+
if parent_context is None:
114138
span = tracer.start_span("base_cli.run", attributes=attributes)
115-
except TypeError:
116-
# Small test/demonstration tracers may only accept a span name.
117-
span = tracer.start_span("base_cli.run")
139+
else:
140+
try:
141+
span = tracer.start_span("base_cli.run", attributes=attributes, context=parent_context)
142+
except TypeError:
143+
# Small test/demonstration tracers may only accept attributes.
144+
span = tracer.start_span("base_cli.run", attributes=attributes)
118145
if span is None:
119146
return None
120147
_safe_span_call(span, "add_event", "base_cli.run.started", attributes=attributes)
121-
return TelemetrySession(span=span, started_monotonic_ns=time.monotonic_ns())
148+
context_token = None
149+
if otel_context_api is not None and otel_trace_api is not None and hasattr(span, "get_span_context"):
150+
try:
151+
active_context = otel_trace_api.set_span_in_context(span, parent_context)
152+
context_token = otel_context_api.attach(active_context)
153+
except Exception:
154+
context_token = None
155+
return TelemetrySession(
156+
span=span,
157+
started_monotonic_ns=time.monotonic_ns(),
158+
context_api=otel_context_api,
159+
context_token=context_token,
160+
)
122161
except Exception as exc: # pragma: no cover - optional package/runtime dependent
123162
_debug_integration_failure(context, "OpenTelemetry start failed", exc)
124163
return None
@@ -164,6 +203,30 @@ def finish_telemetry(
164203
# command result even if an exporter or logger raises process-control
165204
# exceptions while the span is being closed.
166205
_debug_integration_failure(context, "OpenTelemetry finish failed", exc)
206+
finally:
207+
if session.context_api is not None and session.context_token is not None:
208+
try:
209+
session.context_api.detach(session.context_token)
210+
except Exception:
211+
pass
212+
213+
214+
def inject_trace_context(environ: dict[str, str]) -> None:
215+
"""Inject the current OpenTelemetry span into a child process environment."""
216+
217+
try:
218+
from opentelemetry import propagate
219+
220+
carrier: dict[str, str] = {}
221+
propagate.inject(carrier)
222+
for key, value in carrier.items():
223+
if key.lower() == TRACEPARENT_ENV.lower():
224+
environ[TRACEPARENT_ENV] = value
225+
elif key.lower() == TRACESTATE_ENV.lower():
226+
environ[TRACESTATE_ENV] = value
227+
except Exception:
228+
# Trace propagation is optional and must never change command behavior.
229+
pass
167230

168231

169232
def _start_attributes(context: Any) -> dict[str, Any]:
@@ -216,6 +279,7 @@ def _debug_integration_failure(context: Any, message: str, exc: BaseException) -
216279
"TelemetryOptions",
217280
"TelemetrySession",
218281
"finish_telemetry",
282+
"inject_trace_context",
219283
"start_telemetry",
220284
"try_render_rich_table",
221285
]

0 commit comments

Comments
 (0)