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

@ -114,6 +114,10 @@ operation or produce OpenBao audit-request correlation.
exact-value redaction.
- Undeclared fields and undeclared/unsupported exec modes are rejected before
value fetch.
- A configured exec owner may also receive companion lanes. Each companion lane
must consent with `companion_of`, is gated through its own approval and
consume, and is read through its own AppRole. A refusal on any lane starts no
child (`docs/exec-owner-binding.md`).
The OpenBao KV response is parsed in the parent process, so every field stored
at the path crosses that process boundary even though only the selected field is

View file

@ -91,3 +91,36 @@ uv run --extra dev --with 'PyJWT[crypto]>=2.7,<3' python tools/exercise_approval
Receipt: `docs/evidence/2026-09-10-exec-owner-approval-exercise.json`.
Native custody, exact operator group/file delivery, real human/audit/service path,
accepted factory configuration and paid execution remain open.
## Companion lanes (SECRETS-WP-0011)
A configured owner may list extra lanes to deliver alongside the primary field:
```yaml
exec_owner:
status: configured
# command, cwd, environment, files as above
companions:
- {catalog: <kv-lane-id>, field: <declared-field>, env: <ENV_NAME>}
```
The companion lane must consent in its own catalog entry with
`delivery_config.companion_of: [<primary-lane-id>]`. It must be a `kv` lane in
the same stage, declare the field and `exec-env`, and bind no exec owner of its
own. Env names must be unique, must not match the fixed environment or the
primary field's name, and must not use loader or engine credential prefixes.
Pending owners cannot list companions.
Companions are part of the owner binding, so changing a companion's lane, field
or env name changes the owner digest and invalidates earlier decisions.
At exec time, companions are resolved before any approval is consumed. Each
lane then gets its own privileged evidence record, stance, approval and
consume for action `exec`. No lane's decision covers another lane. After every
gate passes, each value is read through its own lane's AppRole session. A
failure on any lane starts no child. The binding is checked again after the
reads, and all values are injected together and redacted from the output.
Proof: `tests/test_exec_owner_companions.py`, plus
`tests/test_integration_companions.py` on a throwaway OpenBao (two lanes, one
owner, and the primary AppRole denied the companion path).

View file

@ -385,6 +385,19 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
if "exec-env" not in modes or set(modes) - {"exec-env", "read-check"}:
raise CatalogError(f"{source}: exec_owner permits only exec-env and read-check")
companion_of = delivery_config.get("companion_of")
if companion_of is not None and (
not isinstance(companion_of, list)
or not companion_of
or not all(isinstance(x, str) and x.strip() for x in companion_of)
or len(set(companion_of)) != len(companion_of)
or data["kind"] != "kv"
or "exec-env" not in modes
):
raise CatalogError(
f"{source}: delivery_config.companion_of must list primary lane ids on a kv exec-env lane"
)
# npm-config delivery must declare WHERE it publishes (registry + scope), so
# the registry is catalog data, never hardcoded in the engine.
if "npm-config" in modes:

View file

@ -546,32 +546,61 @@ def cmd_wrap(cfg: Config, args) -> int:
def cmd_exec(cfg: Config, args) -> int:
from contextlib import ExitStack
from secrets_engine.exec_delivery import exec_with_secret
from secrets_engine.exec_owner import validate_delivery_target
from secrets_engine.exec_owner import resolve_companions, validate_delivery_target
entry = get_entry(cfg.catalog_dir, args.catalog)
field = args.field or (entry.fields[0] if entry.fields else "")
session_detail: dict[str, object] = {}
companion_sessions: dict[str, dict[str, object]] = {}
# Refuse a substituted recipient before consuming approval or opening Bao.
owner_digest = validate_delivery_target(entry, field, args.command, args.mode)
companions = resolve_companions(entry, lambda cid: get_entry(cfg.catalog_dir, cid))
command_name = args.command[0] if args.command else ""
with _privileged_evidence(
cfg,
entry,
"exec",
detail={
"command": command_name,
"mode": args.mode,
"field": field,
"session": session_detail,
"exec_owner_sha256": owner_digest,
},
) as evidence:
with ExitStack() as stack:
evidence = stack.enter_context(_privileged_evidence(
cfg,
entry,
"exec",
detail={
"command": command_name,
"mode": args.mode,
"field": field,
"session": session_detail,
"exec_owner_sha256": owner_digest,
"companions": [lane.id for lane, _, _ in companions],
},
))
# require approval + readiness before running.
decision = _require_lane_approval(
cfg, entry, "exec", evidence, fields=(field,) if field else ()
)
evidence.mark_approved(decision)
require_delivery_state(cfg.evidence_dir, entry.id, "exec")
# Each companion read is its own protected action: its own evidence,
# stance, approval and consume. No lane's decision covers another.
companion_evidence = []
for lane, lane_field, _env in companions:
lane_evidence = stack.enter_context(_privileged_evidence(
cfg,
lane,
"exec",
detail={
"command": command_name,
"mode": "exec-env",
"field": lane_field,
"companion_of": entry.id,
"session": companion_sessions.setdefault(lane.id, {}),
"exec_owner_sha256": owner_digest,
},
))
lane_decision = _require_lane_approval(
cfg, lane, "exec", lane_evidence, fields=(lane_field,)
)
lane_evidence.mark_approved(lane_decision)
require_delivery_state(cfg.evidence_dir, lane.id, "exec")
companion_evidence.append(lane_evidence)
if not args.command:
from secrets_engine.errors import DeliveryError
@ -585,7 +614,11 @@ def cmd_exec(cfg: Config, args) -> int:
mode=args.mode,
session_evidence=session_detail,
expected_owner_digest=owner_digest,
companions=companions,
companion_sessions=companion_sessions,
)
for lane_evidence in companion_evidence:
lane_evidence.finish(f"exit-{rc}")
evidence.finish(f"exit-{rc}")
return rc

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)

View file

@ -32,7 +32,7 @@ def validate_exec_owner(value: object) -> dict[str, Any]:
raise CatalogError("pending exec_owner requires only status, owner and reason")
return value
required = {"status", "owner", "command", "cwd", "environment", "files"}
if value.get("status") != "configured" or set(value) != required:
if value.get("status") != "configured" or not required <= set(value) <= required | {"companions"}:
raise CatalogError("configured exec_owner requires exact command/cwd/environment/files")
command = value["command"]
if not isinstance(command, list) or not command or not all(_text(x) for x in command) or not _absolute(command[0]):
@ -60,9 +60,66 @@ def validate_exec_owner(value: object) -> dict[str, Any]:
for arg in command[1:]:
if arg.startswith("/") and arg not in files:
raise CatalogError("exec_owner absolute file arguments must have file pins")
_validate_companions(value.get("companions", []), env)
return value
def _validate_companions(companions: object, env: dict[str, str]) -> None:
"""Structural check only; cross-lane checks need the catalog (resolve_companions)."""
if not isinstance(companions, list):
raise CatalogError("exec_owner companions must be a list")
names: set[str] = set()
lanes: set[tuple[str, str]] = set()
for spec in companions:
if not isinstance(spec, dict) or set(spec) != {"catalog", "field", "env"} or not all(
_text(spec[k]) for k in ("catalog", "field", "env")
):
raise CatalogError("exec_owner companion requires exactly catalog, field and env")
name = spec["env"]
if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) or name.startswith(
("LD_", "DYLD_", "PYTHON", "BAO_", "VAULT_", "SECRETS_ENGINE_")
):
raise CatalogError("exec_owner companion env name is invalid or forbidden")
if name in env or name in names or (spec["catalog"], spec["field"]) in lanes:
raise CatalogError("exec_owner companion env or lane field is duplicated")
names.add(name)
lanes.add((spec["catalog"], spec["field"]))
def companion_specs(entry) -> list[dict[str, str]]:
binding = owner_binding(entry)
if binding is None or binding.get("status") != "configured":
return []
return list(binding.get("companions", []))
def resolve_companions(entry, lookup) -> list[tuple[Any, str, str]]:
"""Resolve each companion to (lane, field, env). ``lookup`` maps id -> entry.
A companion lane must consent by naming this primary in its own
``delivery_config.companion_of``, so no owner can list another team's lane
by itself. Runs before any approval is consumed or OpenBao is opened.
"""
resolved = []
for spec in companion_specs(entry):
if spec["catalog"] == entry.id:
raise DeliveryError("exec owner companion must be a different lane")
try:
lane = lookup(spec["catalog"])
except Exception as exc:
raise DeliveryError(f"exec owner companion lane '{spec['catalog']}' is unavailable") from exc
if lane.kind != "kv" or lane.stage != entry.stage:
raise DeliveryError(f"companion lane '{lane.id}' must be a kv lane in stage '{entry.stage}'")
if spec["field"] not in lane.fields or "exec-env" not in lane.delivery_modes:
raise DeliveryError(f"companion lane '{lane.id}' does not declare exec-env delivery of that field")
if entry.id not in (lane.delivery_config.get("companion_of") or []):
raise DeliveryError(f"companion lane '{lane.id}' does not consent to delivery with '{entry.id}'")
if "exec_owner" in lane.delivery_config:
raise DeliveryError(f"companion lane '{lane.id}' must not bind its own exec owner")
resolved.append((lane, spec["field"], spec["env"]))
return resolved
def owner_binding(entry) -> dict[str, Any] | None:
config = entry.delivery_config
if "exec_owner" not in config:
@ -102,7 +159,9 @@ def validate_delivery_target(entry, field: str, command: list[str], mode: str) -
raise DeliveryError("exec owner binding is pending; no delivery is admitted")
if mode not in {"auto", "exec-env"} or "exec-env" not in entry.delivery_modes:
raise DeliveryError("exec owner requires exec-env delivery")
if field not in entry.fields or field.upper() in binding["environment"]:
if field not in entry.fields or field.upper() in binding["environment"] or field.upper() in {
spec["env"] for spec in binding.get("companions", [])
}:
raise DeliveryError("exec owner field is undeclared or conflicts with fixed environment")
if command != binding["command"]:
raise DeliveryError("command differs from the catalog-bound exec owner")

View file

@ -48,7 +48,7 @@ def test_exec_env_injects_only_selected_declared_field(monkeypatch):
def fake_spawn(command, env, secret):
assert command == ["probe"]
assert secret == "test-secret-value"
assert secret == ["test-secret-value"]
assert env["SELECTED_VALUE"] == "test-secret-value"
assert "PRIMARY" not in env
return 0
@ -81,7 +81,7 @@ def test_exec_file_injects_path_not_value_and_unlinks(monkeypatch, tmp_path):
seen["contents"] = __import__("pathlib").Path(path).read_text()
seen["has_value_env"] = "API_TOKEN" in env
assert command == ["probe"]
assert secret == "test-secret-value"
assert secret == ["test-secret-value"]
return 0
monkeypatch.setattr("secrets_engine.exec_delivery._fetch_value", fake_fetch)

View file

@ -0,0 +1,181 @@
import copy
from types import SimpleNamespace
import pytest
from secrets_engine import cli, exec_delivery
from secrets_engine.catalog import validate_entry
from secrets_engine.errors import CatalogError, DeliveryError
from secrets_engine.exec_owner import owner_digest, resolve_companions
from tests.test_catalog import VALID
from tests.test_exec_owner import bound # noqa: F401 (fixture)
from tests.test_lane_state import _cfg
def _companion(primary_id="test-lane", **changes):
data = copy.deepcopy(VALID)
data.update(id="test-worker", path="test/team/worker", fields=["worker_token"])
data["delivery_config"] = {"companion_of": [primary_id]}
data.update(changes)
return data
def _with_companion(data, env="WORKER_TOKEN", field="worker_token", catalog="test-worker"):
data["delivery_config"]["exec_owner"]["companions"] = [
{"catalog": catalog, "field": field, "env": env}
]
return data
def _lookup(*entries):
table = {e.id: e for e in entries}
return lambda cid: table[cid]
def test_companion_value_reaches_bound_child_and_is_redacted(bound, monkeypatch, capsys):
data, _, script = bound
script.write_text('''test "$API_TOKEN" = synthetic-owner-value || exit 10
test "$WORKER_TOKEN" = synthetic-worker-value || exit 11
read ignored && exit 15
printf '%s %s\\n' "$API_TOKEN" "$WORKER_TOKEN"
printf 'companion-child-ok\\n'
''')
import hashlib
data["delivery_config"]["exec_owner"]["files"][str(script)]["sha256"] = hashlib.sha256(script.read_bytes()).hexdigest()
entry = validate_entry(_with_companion(data))
lane = validate_entry(_companion())
companions = resolve_companions(entry, _lookup(lane))
values = {"test-lane": "synthetic-owner-value", "test-worker": "synthetic-worker-value"}
seen = []
def fetch(client, e, field, **kwargs):
seen.append((e.id, field))
return values[e.id]
monkeypatch.setattr(exec_delivery, "_fetch_value", fetch)
sessions = {}
rc = exec_delivery.exec_with_secret(
object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"],
mode="exec-env", expected_owner_digest=owner_digest(entry),
companions=companions, companion_sessions=sessions,
)
out = capsys.readouterr().out
assert rc == 0 and "companion-child-ok" in out
assert "synthetic-owner-value" not in out and "synthetic-worker-value" not in out
assert seen == [("test-lane", "api_token"), ("test-worker", "worker_token")]
assert set(sessions) == {"test-worker"}
def test_companion_fetch_failure_starts_no_child(bound, monkeypatch):
data, _, _ = bound
entry = validate_entry(_with_companion(data))
lane = validate_entry(_companion())
def fetch(client, e, field, **kwargs):
if e.id == "test-worker":
raise DeliveryError("scoped read failed for lane 'test-worker' (denied or absent)")
return "synthetic-owner-value"
monkeypatch.setattr(exec_delivery, "_fetch_value", fetch)
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("must not launch"))
with pytest.raises(DeliveryError, match="test-worker"):
exec_delivery.exec_with_secret(
object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"],
mode="exec-env", companions=resolve_companions(entry, _lookup(lane)),
)
def test_companions_are_part_of_the_owner_digest(bound):
data, _, _ = bound
before = owner_digest(validate_entry(copy.deepcopy(data)))
assert owner_digest(validate_entry(_with_companion(copy.deepcopy(data)))) != before
changed = owner_digest(validate_entry(_with_companion(copy.deepcopy(data), env="OTHER_TOKEN")))
assert changed != owner_digest(validate_entry(_with_companion(copy.deepcopy(data))))
@pytest.mark.parametrize("change", ["extra_key", "bad_env", "fixed_env", "forbidden_env", "duplicate", "not_list"])
def test_invalid_companion_spec_is_a_catalog_error(bound, change):
data, _, _ = bound
binding = _with_companion(data)["delivery_config"]["exec_owner"]
spec = binding["companions"][0]
if change == "extra_key": spec["note"] = "x"
elif change == "bad_env": spec["env"] = "worker-token"
elif change == "fixed_env": spec["env"] = "LANG"
elif change == "forbidden_env": spec["env"] = "BAO_TOKEN"
elif change == "duplicate": binding["companions"].append(dict(spec))
else: binding["companions"] = {"catalog": "test-worker"}
with pytest.raises(CatalogError):
validate_entry(data)
def test_primary_field_env_cannot_collide_with_companion(bound, monkeypatch):
data, _, _ = bound
entry = validate_entry(_with_companion(data, env="API_TOKEN"))
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("must refuse before read"))
with pytest.raises(DeliveryError, match="conflicts"):
exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"], mode="exec-env")
@pytest.mark.parametrize("change", ["no_consent", "other_primary", "stage", "field", "mode", "own_owner", "self"])
def test_companion_resolution_refuses(bound, change):
data, _, _ = bound
lane_data = _companion()
catalog = "test-worker"
if change == "no_consent": lane_data["delivery_config"] = {}
elif change == "other_primary": lane_data["delivery_config"] = {"companion_of": ["another-lane"]}
elif change == "stage": lane_data.update(stage="build", path="build/team/worker")
elif change == "field": lane_data["fields"] = ["other"]
elif change == "mode": lane_data["delivery_modes"] = ["read-check"]; lane_data["delivery_config"] = {}
elif change == "own_owner": lane_data["delivery_config"]["exec_owner"] = {"status": "pending", "owner": "x", "reason": "y"}
else: catalog = "test-lane"
entry = validate_entry(_with_companion(data, catalog=catalog))
lane = validate_entry(lane_data)
with pytest.raises(DeliveryError):
resolve_companions(entry, _lookup(lane, entry))
@pytest.mark.parametrize("value", [[], "test-lane", ["test-lane", "test-lane"], [""]])
def test_companion_of_must_be_a_nonempty_unique_list(value):
lane_data = _companion()
lane_data["delivery_config"] = {"companion_of": value}
with pytest.raises(CatalogError):
validate_entry(lane_data)
def test_companion_of_requires_exec_env():
lane_data = _companion()
lane_data["delivery_modes"] = ["read-check"]
with pytest.raises(CatalogError):
validate_entry(lane_data)
def test_each_lane_is_gated_separately_before_backend(bound, monkeypatch, tmp_path):
data, _, _ = bound
entry = validate_entry(_with_companion(data))
lane = validate_entry(_companion())
table = {entry.id: entry, lane.id: lane}
monkeypatch.setattr(cli, "get_entry", lambda _dir, cid: table[cid])
gated = []
def gate(cfg, e, action, evidence, *, fields=()):
gated.append((e.id, action, fields))
if e.id == "test-worker":
raise DeliveryError("companion lane denied")
return SimpleNamespace(status="approved")
monkeypatch.setattr(cli, "_require_lane_approval", gate)
monkeypatch.setattr(cli, "require_delivery_state", lambda *a: None)
monkeypatch.setattr(cli, "_open_backend", lambda *a, **k: pytest.fail("no backend after a lane refusal"))
command = data["delivery_config"]["exec_owner"]["command"]
with pytest.raises(DeliveryError, match="companion lane denied"):
cli.cmd_exec(_cfg(tmp_path), SimpleNamespace(field=None, catalog=entry.id, command=command, mode="exec-env"))
assert gated == [("test-lane", "exec", ("api_token",)), ("test-worker", "exec", ("worker_token",))]
def test_unresolvable_companion_refuses_before_any_gate(bound, monkeypatch, tmp_path):
data, _, _ = bound
entry = validate_entry(_with_companion(data))
def get(_dir, cid):
if cid == entry.id:
return entry
raise KeyError(cid)
monkeypatch.setattr(cli, "get_entry", get)
monkeypatch.setattr(cli, "_require_lane_approval", lambda *a, **k: pytest.fail("no gate"))
monkeypatch.setattr(cli, "_open_backend", lambda *a, **k: pytest.fail("no backend"))
command = data["delivery_config"]["exec_owner"]["command"]
with pytest.raises(DeliveryError, match="unavailable"):
cli.cmd_exec(_cfg(tmp_path), SimpleNamespace(field=None, catalog=entry.id, command=command, mode="exec-env"))

View file

@ -0,0 +1,67 @@
"""Two cataloged KV lanes delivered to one bound owner on a throwaway OpenBao.
Each lane gets its own policy and AppRole, and each value is read through that
lane's own AppRole session. The child sees both values; the output does not.
"""
import copy
import hashlib
import os
from secrets_engine.apply import apply_plan
from secrets_engine.catalog import validate_entry
from secrets_engine.exec_delivery import exec_with_secret
from secrets_engine.exec_owner import owner_digest, resolve_companions
from secrets_engine.plan import build_plan
from secrets_engine.provision import provision_from_file
from tests.test_exec_owner import bound # noqa: F401 (fixture)
from tests.test_exec_owner_companions import _companion, _lookup, _with_companion
from tests.test_integration_bao import bao_dev, pytestmark # noqa: F401
def _provision(client, entry, field, value, tmp_path):
source = tmp_path / f"{entry.id}.value"
source.write_text(value)
os.chmod(source, 0o600)
apply_plan(client, entry, build_plan(entry, entry.stage, decision_id="test"))
provision_from_file(client, entry, field, source)
def test_two_lanes_reach_one_bound_owner(bao_dev, bound, tmp_path, capsys):
data, _, script = bound
script.write_text('''test "$API_TOKEN" = primary-integration-value || exit 10
test "$WORKER_TOKEN" = worker-integration-value || exit 11
test -z "$BAO_TOKEN" || exit 12
read ignored && exit 15
printf '%s %s\\n' "$API_TOKEN" "$WORKER_TOKEN"
printf 'two-lane-child-ok\\n'
''')
data["delivery_config"]["exec_owner"]["files"][str(script)]["sha256"] = hashlib.sha256(
script.read_bytes()
).hexdigest()
entry = validate_entry(_with_companion(copy.deepcopy(data)))
lane = validate_entry(_companion())
_provision(bao_dev, entry, "api_token", "primary-integration-value", tmp_path)
_provision(bao_dev, lane, "worker_token", "worker-integration-value", tmp_path)
sessions, primary_session = {}, {}
rc = exec_with_secret(
bao_dev, entry, "api_token", data["delivery_config"]["exec_owner"]["command"],
mode="exec-env", session_evidence=primary_session,
expected_owner_digest=owner_digest(entry),
companions=resolve_companions(entry, _lookup(lane)), companion_sessions=sessions,
)
out = capsys.readouterr().out
assert rc == 0 and "two-lane-child-ok" in out
assert "primary-integration-value" not in out and "worker-integration-value" not in out
assert primary_session.get("established") and sessions["test-worker"].get("established")
def test_primary_approle_cannot_read_companion_path(bao_dev, bound, tmp_path):
data, _, _ = bound
entry = validate_entry(_with_companion(copy.deepcopy(data)))
lane = validate_entry(_companion())
_provision(bao_dev, entry, "api_token", "primary-integration-value", tmp_path)
_provision(bao_dev, lane, "worker_token", "worker-integration-value", tmp_path)
with bao_dev.approle_session(entry.role_name) as session:
proc = session.client._run(["kv", "get", "-format=json", f"{lane.mount}/{lane.path}"])
assert proc.returncode != 0

View file

@ -4,7 +4,7 @@ type: workplan
title: "Multi-lane exec-owner delivery"
domain: infotech
repo: secrets-engine
status: ready
status: active
flavor: implementation
owner: claude-code
topic_slug: netkingdom
@ -13,6 +13,7 @@ updated: "2026-09-23"
related_workplans:
- SECRETS-WP-0009
- HFACT-WP-0001
state_hub_workstream_id: "ecb0643d-bad7-5d63-b340-e77ab9579b0a"
---
Demand: SECRETS-WP-0009-T03. A configured `exec_owner` child today receives
@ -45,8 +46,9 @@ Invariants carried over unchanged:
```task
id: SECRETS-WP-0011-T01
status: todo
status: done
priority: high
state_hub_task_id: "820514c9-f54d-530e-a45c-9a04486831c2"
```
Extend `exec_owner` with `companions: [{catalog, field, env}]`. Validate at
@ -60,8 +62,9 @@ exec-time delivery section.
```task
id: SECRETS-WP-0011-T02
status: todo
status: done
priority: high
state_hub_task_id: "33d0fc37-5f23-53a6-9456-f8bd1cc984f9"
```
Run the existing approval-claim / CheckRequest / consume chain once per lane,
@ -74,8 +77,9 @@ session cleanup. Any failure starts no child and reports which lane refused.
```task
id: SECRETS-WP-0011-T03
status: todo
status: done
priority: high
state_hub_task_id: "52a0ff40-81d6-589a-b05f-3e5f55f81a81"
```
Unit tests: validation, digest coverage, collision, stage mismatch, partial
@ -90,6 +94,7 @@ id: SECRETS-WP-0011-T04
status: wait
priority: high
blocking_reason: "Needs activity-core owner assent to source ACTIVITY_CORE_WORKER_TOKEN from OpenBao (ESO) instead of a hand-generated Kubernetes Secret."
state_hub_task_id: "cf465065-de7a-5d9c-bc80-fee16ffef70d"
```
Request activity-core to move the worker token into OpenBao custody, synced to
@ -98,3 +103,15 @@ catalog a read lane for the metered owner and add it as the Glas lane's
companion. This coordinates with activity-core's multi-worker request (hub
message `914853d9`): a dedicated metered worker token would use the same lane
shape.
## Implementation return — 2026-09-23
T01–T03 done. `exec_owner.companions` and `delivery_config.companion_of` are
validated at catalog load. `resolve_companions` runs before any gate, and
`cmd_exec` gates each lane separately (its own evidence, stance, approval and
consume) before opening the backend. `exec_with_secret` reads each lane through
its own AppRole, starts no child if any lane fails, re-checks the binding, and
redacts every value. Suite: 465 passed. Integration on a throwaway OpenBao: 2
passed (two lanes to one owner; the primary AppRole is denied the companion
path). Contract: `docs/exec-owner-binding.md` § Companion lanes. T04 waits on
activity-core (hub message `6e694682`).