Add companion lanes to catalog-bound exec owners (SECRETS-WP-0011 T01-T03)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

A configured exec owner may receive fields from other consenting kv lanes.
Each lane is gated, consumed and read through its own AppRole; any refusal
starts no child. Companions are part of the owner digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 226514@bnt-lap001
Assistant-Session: 26ba103d-05fe-45a1-9cd7-9475bf239df6
This commit is contained in:
tegwick 2026-09-23 17:27:09 +02:00
parent 452203b19b
commit bc58184d71
10 changed files with 449 additions and 29 deletions

View file

@ -164,11 +164,11 @@ def _secret_file(value: str) -> Iterator[Path]:
_unlink_secret_file(path)
def _stream_redacted(proc: subprocess.Popen, secret: str) -> None:
def _stream_redacted(proc: subprocess.Popen, secrets: list[str]) -> None:
"""Stream child output through the redactor (backstop)."""
assert proc.stdout is not None
for line in proc.stdout:
sys.stdout.write(redact_text(line, extra=[secret]))
sys.stdout.write(redact_text(line, extra=secrets))
sys.stdout.flush()
@ -182,6 +182,8 @@ def exec_with_secret(
policy_dir=None,
session_evidence: dict[str, object] | None = None,
expected_owner_digest: str | None = None,
companions: list[tuple[CatalogEntry, str, str]] = (),
companion_sessions: dict[str, dict[str, object]] | None = None,
) -> int:
"""Run `command` with the lane's secret injected for the child only.
@ -225,6 +227,16 @@ def exec_with_secret(
value = _fetch_value(
client, entry, field, session_evidence=session_evidence
)
if companions and (binding_digest is None or mode != "exec-env"):
raise DeliveryError("companion delivery requires a configured exec owner and exec-env")
# Every lane is read through its own AppRole session; any failure raises
# before a child exists, and the values already read go out of scope.
extra: dict[str, str] = {}
for lane, lane_field, env_name in companions:
lane_session: dict[str, object] = (
companion_sessions.setdefault(lane.id, {}) if companion_sessions is not None else {}
)
extra[env_name] = _fetch_value(client, lane, lane_field, session_evidence=lane_session)
# Recheck after retrieval too: a config changed during auth/read cannot be
# launched with a value authorized for the previous recipient.
if validate_delivery_target(entry, field, command, mode) != binding_digest:
@ -245,27 +257,28 @@ def exec_with_secret(
with _npm_userconfig(registry, scope, token_env) as npmrc:
child_env["NPM_CONFIG_USERCONFIG"] = str(npmrc)
child_env[token_env] = value
rc = _spawn(command, child_env, value)
rc = _spawn(command, child_env, [value])
return rc
if mode == "exec-env":
# Inject under a conventional name derived from the field.
env_name = field.upper()
child_env[env_name] = value
child_env.update(extra)
if binding is not None:
return _spawn(command, child_env, value, cwd=binding["cwd"])
return _spawn(command, child_env, value)
return _spawn(command, child_env, [value, *extra.values()], cwd=binding["cwd"])
return _spawn(command, child_env, [value])
if mode == "exec-file":
env_name = f"{field.upper()}_FILE"
with _secret_file(value) as secret_path:
child_env[env_name] = str(secret_path)
return _spawn(command, child_env, value)
return _spawn(command, child_env, [value])
raise DeliveryError(f"unsupported delivery mode '{mode}'")
def _spawn(command: list[str], env: dict[str, str], secret: str, *, cwd: str | None = None) -> int:
def _spawn(command: list[str], env: dict[str, str], secrets: list[str], *, cwd: str | None = None) -> int:
"""Spawn the child, stream redacted output, propagate signals, ensure cleanup."""
try:
proc = subprocess.Popen(
@ -286,7 +299,7 @@ def _spawn(command: list[str], env: dict[str, str], secret: str, *, cwd: str | N
old_int = signal.signal(signal.SIGINT, _forward)
old_term = signal.signal(signal.SIGTERM, _forward)
try:
_stream_redacted(proc, secret)
_stream_redacted(proc, secrets)
return proc.wait()
finally:
signal.signal(signal.SIGINT, old_int)