Add companion lanes to catalog-bound exec owners (SECRETS-WP-0011 T01-T03)
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:
parent
452203b19b
commit
bc58184d71
10 changed files with 449 additions and 29 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue