From 42b48aa54f7ca61cf7e4100d2ae04d4d863583df Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 10 Sep 2026 09:29:38 +0200 Subject: [PATCH] Bind credential exec to exact owner inputs and approval digest Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- catalog/glas-claude-agent-dev-anthropic.yaml | 8 + ...26-09-10-exec-owner-approval-exercise.json | 57 ++++++ docs/exec-owner-binding.md | 93 ++++++++++ docs/glas-claude-delivery.md | 9 +- src/secrets_engine/authorization.py | 6 + src/secrets_engine/catalog.py | 10 ++ src/secrets_engine/cli.py | 5 + src/secrets_engine/exec_delivery.py | 19 +- src/secrets_engine/exec_owner.py | 124 +++++++++++++ src/secrets_engine/routing.py | 15 +- tests/test_exec_owner.py | 167 ++++++++++++++++++ tools/exercise_approval_identity.py | 88 ++++++++- ...ETS-WP-0009-glas-claude-native-delivery.md | 32 +++- 13 files changed, 626 insertions(+), 7 deletions(-) create mode 100644 docs/evidence/2026-09-10-exec-owner-approval-exercise.json create mode 100644 docs/exec-owner-binding.md create mode 100644 src/secrets_engine/exec_owner.py create mode 100644 tests/test_exec_owner.py diff --git a/catalog/glas-claude-agent-dev-anthropic.yaml b/catalog/glas-claude-agent-dev-anthropic.yaml index 149009a..133a9e7 100644 --- a/catalog/glas-claude-agent-dev-anthropic.yaml +++ b/catalog/glas-claude-agent-dev-anthropic.yaml @@ -17,6 +17,14 @@ consumers: purpose: Owner-admitted glas-harness agt run through the reviewed local profile; no caller-facing key fetch workload_delivery: [] +delivery_config: + exec_owner: + status: pending + owner: rein-aharness MessagesOwner with sand-boxer runtime boundary + reason: Exact installed metered-once runtime is proved; native holder review, + immutable owner configuration and private state/profile/service admission + remain SECRETS-WP-0009-T03 and HFACT-WP-0001-T03/T04. No arbitrary child may + receive this key while the owner binding is pending. delivery_modes: - exec-env - read-check diff --git a/docs/evidence/2026-09-10-exec-owner-approval-exercise.json b/docs/evidence/2026-09-10-exec-owner-approval-exercise.json new file mode 100644 index 0000000..3b65ec2 --- /dev/null +++ b/docs/evidence/2026-09-10-exec-owner-approval-exercise.json @@ -0,0 +1,57 @@ +{ + "schema_version": 1, + "target": "disposable local processes; synthetic credentials", + "started_at": "2026-09-10T07:15:53.047975+00:00", + "keycape_image": "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611", + "approval_engine_commit": "62233c7c52447a6065055933a9c7cc1cfe3059eb", + "flex_auth_commit": "88b354377c8e26b162f1234e673072f1c06dcd89", + "keycape_contract_commit": "324b5e056dcfd5c6336750cd7a7b7f8c5ca2255f", + "consumer_source_sha256": { + "approval_auth.py": "6f3b033e7928e1c527bc19f5a1e01bf243540769a8646bd8cd47268da25637b5", + "approval_consume.py": "9ccc6997fd4c1c82af8cf3dd440daf7b695d6272bb302eb083b73a2fe3674345", + "approval_claim.py": "54dd469e4e3ac9472c4e21222d5a08f70723a63f01904a35922fcc0a07a41541", + "authorization.py": "0656589832b7218cc1603890e93a3e09513af8b59694f279defa764a1edb30c9", + "config.py": "2f2f1b60664d89bbb806aed0c768378923062077740e1f96e4c359238eb06955", + "service_auth.py": "653723ef5babce2157771416d93cb15cdbb7caca3c45509c3730520c55e4515c", + "cli.py": "1147d0e60850cfc38ee21e8564f51656d364ddc3cd8d6353a0c2dd6a0b78b8ec", + "catalog.py": "7e77c89a2543e3759f1322aca9b9167b904bb30e1aec867c4415dfde4bddd5fa", + "exec_owner.py": "10c21e7e37dd3d0aad41c4bb15df8f4a69c22d9046105a981a9613a91cd4c145", + "exec_delivery.py": "b142b60d875acf3dea1ba3ae99182a7ed7328d955dee5be2d12c977fc5646e8e", + "routing.py": "9d5050d7494a207394ce8251d8aac574ca0c08e3252639b5f05b373d6e35a076" + }, + "limitations": [ + "standalone Flex Auth source, not deployed pin", + "local Approval Engine source, not deployed image", + "no live custody or client-side read grant", + "no OpenBao effect or model execution" + ], + "checks": { + "operator_issued_and_approved_via_verified_jwt": true, + "wrong_action_refused_before_consume": true, + "producer_origin_join_with_carried_claim": true, + "actual_consumer_claim_check_consume": true, + "same_digest_retry_idempotent": true, + "different_digest_refused": true, + "spent_claim_refused": true, + "operator_consume_scope_denied_by_issuer": true, + "wrong_secret_refused": true, + "no_access_token_file_created": true, + "registry_override_accepts_exact_submission": true, + "same_enriched_result_different_submission_refused": true, + "real_dual_control_denial_then_claim_check_consume": true, + "exec_owner_substitution_refused_before_consume_backend": true, + "exec_owner_changed_environment_replay_refused_by_real_pdp_join": true, + "exec_owner_real_cli_consumes_before_fixture_backend": true, + "exec_owner_actual_child_excludes_parent_credentials": true, + "exec_owner_digest_preserved_by_real_evaluator": true + }, + "flex_auth_binary_sha256": "c7a1f35aa2cd7cf2733272923ace7fc4b4e55171db4ef8f3d0d6629183dcf1fa", + "producer_input_sha256": { + "policy_package.md": "657fa9312c9dcabee059ec31c89ee13396fe9aa54d852edc9e6559a74bbc139a", + "registry_snapshot.json": "37fba44ec0e0fd9b1e17a05ad1b7e4f9bb6ec9e0fc0bb3133e8af2ac6f832d7c" + }, + "exec_owner_scope": "Synthetic recipient/backend with real KeyCape, Approval Engine, Flex Auth and Secrets Engine CLI; not native custody or human approval proof", + "status": "passed", + "cleanup_complete": true, + "finished_at": "2026-09-10T07:16:09.750288+00:00" +} diff --git a/docs/exec-owner-binding.md b/docs/exec-owner-binding.md new file mode 100644 index 0000000..2cfeb3f --- /dev/null +++ b/docs/exec-owner-binding.md @@ -0,0 +1,93 @@ +# Bind native exec delivery to its reviewed recipient + +SECRETS-WP-0009-T03. Implemented 2026-09-10. This constrains delivery; it is not +an approval or proof of native credential access. + +Previously `exec` consumed approval for a catalog/action/field, then accepted an +arbitrary child command and copied the invoking engine's full environment. The +factory recipient is now a pinned metered owner outside the sandbox. A different +command or inherited engine credential would violate that boundary. + +Catalog `delivery_config.exec_owner` is optional for existing lanes. The Claude +factory lane explicitly requires it and currently declares `status: pending`, +`owner` and `reason`. Pending means no exec: refusal precedes approval consumption, +backend opening and secret retrieval. Routing stays unready even if custody exists. + +A reviewed binding uses exactly these keys: + +| Key | Required content | +| --- | --- | +| `status` | `configured`; this describes configuration, not authorization | +| `owner` | Named trusted recipient | +| `command` | Exact argv; absolute executable, no caller-selected additional arguments | +| `cwd` | Exact private owner directory | +| `environment` | Complete non-secret environment; no inheritance from the engine | +| `files` | Absolute path → `{sha256: <64 lowercase hex>, private: }` | + +The executable and every absolute file argument require pins. Include the exact +owner JSON and all immutable files it references, such as the spend-policy and +accepted profile/descriptor configuration. Mutable ledgers are private state, +not immutable file pins. The runtime owner retains whole-artifact and operational +profile verification; hashing an interpreter alone does not verify its packages. + +File checks refuse symlinks, nonregular inputs, untrusted owners, group/world +writable paths and ancestors, changed bytes and exposed private inputs. Root-owned +sticky temporary ancestors are allowed for contained proof. The working directory +is private. The engine and configured owner UID remain trusted: this does not +protect against that same trusted UID replacing files between checks and launch. +Runtime and native service admission must preserve this trust boundary. + +Only exec-env is allowed with this binding. The child receives the fixed environment +plus the selected declared field; loader overrides and engine/provider auth +variables cannot be supplied as fixed environment configuration. No values belong +in the catalog. Queue/service credentials, if needed by the owner, require their +separate admitted custody path. Child stdin is closed; stdout/stderr retain the +existing value redactor. An arbitrary encoding by a malicious recipient is not +made safe by redaction; the recipient itself must be trusted and admitted. + +SHA-256 of the complete canonical binding is carried as +`context.exec_owner_sha256` on the `exec` CheckRequest. The existing producer digest +and approval join bind it without introducing another decision type. Changing +command, file pins, environment, cwd or owner changes the request and requires new +exact-action approval. Local checks run before approval/backend, again before +retrieval, and after retrieval before launch; the admitted digest must still match. +Configured bindings still pass existing approval, consume, lane-state and scoped +backend checks. Other actions and lanes without this opt-in retain their contracts. + +## Factory activation input + +Use the installed artifact and paths in +`prj-helixforge-factory/evidence/2026-09-10-runtime-placement.json`. The intended argv: + +```text +/bin/python3 -I -B -m rein_aharness.cli metered-once --owner-config +``` + +Do not use the rolling CLI alias or a persistent claim loop. Complete and pin the +private owner configuration, profile and spend inputs under HFACT-WP-0001-T01/T04; +bind the required worker environment explicitly. Then review the MessagesOwner +holder under this task and the existing native action/reader gates. The provider +key stays in the metered owner; the workload receives the opaque local route token. +The historical sandbox-helper child-key route is not this holder admission. + +## Verification + +The full suite passed 361 tests, including a real child with a synthetic key, +fixed environment, closed stdin, redaction and substituted/config-changed refusal. +The disposable integration exercise passed 18 checks using the pinned KeyCape +image, actual Approval Engine and compiled Flex Auth, including five new owner +checks: substituted argv refusal before consume/backend, changed-environment +replay refusal, consume-before-backend, isolated child environment and preserved +recipient digest. OpenBao and recipient are fixtures in this exercise. The +historical combined operator identity is a synthetic test issuer fixture, not a +request to restore CCR-2026-0020 or evidence of real human approval. + +```bash +uv run --extra dev --with 'PyJWT[crypto]>=2.7,<3' python tools/exercise_approval_identity.py \ + --keycape-source ../key-cape --approval-engine-source ../approval-engine \ + --flex-auth-source ../flex-auth --exec-owner --receipt /tmp/.json +``` + +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. diff --git a/docs/glas-claude-delivery.md b/docs/glas-claude-delivery.md index 2876967..a11ee67 100644 --- a/docs/glas-claude-delivery.md +++ b/docs/glas-claude-delivery.md @@ -5,6 +5,13 @@ railiance-platform CCR-2026-0016; implementation/activation record SECRETS-WP-00 KV custody is already confirmed at version 2. Do not provision or rotate it as part of native read-lane adoption. +2026-09-10: the factory continuation uses a metered MessagesOwner outside the +sandbox. Its exact runtime is installed and synthetically proved on Railiance. +The catalog now blocks exec with an explicit pending recipient binding until the +native holder and immutable configuration are admitted. See +[exec owner binding](exec-owner-binding.md). The older transport description +below records the original child-key route; it cannot admit the metered holder. + The generated plan checks existing mount `platform`, creates policy and AppRole `se-prod-glas-claude-agent-dev-anthropic`, and grants read only on `platform/data/workloads/glas-harness/claude-agent-dev`. Field ANTHROPIC_API_KEY @@ -37,7 +44,7 @@ catalog and service-jwt selection. No real value was requested. Activation depends on SECRETS-WP-0007-T04 (exact production actions) and SECRETS-WP-0008-T02/T06 (decision consumption and service authority). Require -canonical ActionAuthorization for each protected action, successful consume, +canonical validated DecisionEnvelope for each protected action, successful consume, and exact scoped backend authority. This draft cannot authorize itself; an operator browser token or unsafe-demo flag is not a runtime substitute. diff --git a/src/secrets_engine/authorization.py b/src/secrets_engine/authorization.py index 273f247..557c292 100644 --- a/src/secrets_engine/authorization.py +++ b/src/secrets_engine/authorization.py @@ -105,6 +105,12 @@ def build_action_request( "context": {"purpose": purpose}, } ) + if action == "exec": + from secrets_engine.exec_owner import owner_digest + + digest = owner_digest(entry) + if digest is not None: + request["context"]["exec_owner_sha256"] = digest return request diff --git a/src/secrets_engine/catalog.py b/src/secrets_engine/catalog.py index 0b063e9..70c6403 100644 --- a/src/secrets_engine/catalog.py +++ b/src/secrets_engine/catalog.py @@ -360,6 +360,16 @@ def validate_entry(data: dict[str, Any], *, source: str = "") -> Catalog f"{source}: high-risk lanes require {lifecycle_name}.owner" ) + delivery_config = data.get("delivery_config", {}) + if not isinstance(delivery_config, dict): + raise CatalogError(f"{source}: delivery_config must be a mapping") + if "exec_owner" in delivery_config: + from secrets_engine.exec_owner import validate_exec_owner + + validate_exec_owner(delivery_config["exec_owner"]) + 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") + # 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: diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index 9858a87..1b3ff26 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -542,9 +542,12 @@ def cmd_wrap(cfg: Config, args) -> int: def cmd_exec(cfg: Config, args) -> int: from secrets_engine.exec_delivery import exec_with_secret + from secrets_engine.exec_owner import 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] = {} + # Refuse a substituted recipient before consuming approval or opening Bao. + owner_digest = validate_delivery_target(entry, field, args.command, args.mode) command_name = args.command[0] if args.command else "" with _privileged_evidence( cfg, @@ -555,6 +558,7 @@ def cmd_exec(cfg: Config, args) -> int: "mode": args.mode, "field": field, "session": session_detail, + "exec_owner_sha256": owner_digest, }, ) as evidence: # require approval + readiness before running. @@ -575,6 +579,7 @@ def cmd_exec(cfg: Config, args) -> int: args.command, mode=args.mode, session_evidence=session_detail, + expected_owner_digest=owner_digest, ) evidence.finish(f"exit-{rc}") return rc diff --git a/src/secrets_engine/exec_delivery.py b/src/secrets_engine/exec_delivery.py index 6bc6665..1dcc444 100644 --- a/src/secrets_engine/exec_delivery.py +++ b/src/secrets_engine/exec_delivery.py @@ -27,6 +27,7 @@ from typing import Iterator from secrets_engine.catalog import CatalogEntry from secrets_engine.errors import DeliveryError +from secrets_engine.exec_owner import owner_binding, validate_delivery_target from secrets_engine.openbao import OpenBaoClient from secrets_engine.publication_policy import PublicationPolicy, resolve from secrets_engine.redact import redact_text @@ -180,6 +181,7 @@ def exec_with_secret( mode: str = "auto", policy_dir=None, session_evidence: dict[str, object] | None = None, + expected_owner_digest: str | None = None, ) -> int: """Run `command` with the lane's secret injected for the child only. @@ -192,6 +194,10 @@ def exec_with_secret( f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}" ) + binding_digest = validate_delivery_target(entry, field, command, mode) + if expected_owner_digest is not None and binding_digest != expected_owner_digest: + raise DeliveryError("exec owner binding changed after action admission") + declared = set(entry.delivery_modes) if mode == "auto": if "npm-config" in declared: @@ -219,7 +225,12 @@ def exec_with_secret( value = _fetch_value( client, entry, field, session_evidence=session_evidence ) - child_env = dict(os.environ) + # 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: + raise DeliveryError("exec owner binding changed during credential retrieval") + binding = owner_binding(entry) + child_env = dict(binding["environment"]) if binding is not None else dict(os.environ) if mode == "npm-config": npm = entry.npm @@ -241,6 +252,8 @@ def exec_with_secret( # Inject under a conventional name derived from the field. env_name = field.upper() child_env[env_name] = value + if binding is not None: + return _spawn(command, child_env, value, cwd=binding["cwd"]) return _spawn(command, child_env, value) if mode == "exec-file": @@ -252,7 +265,7 @@ def exec_with_secret( raise DeliveryError(f"unsupported delivery mode '{mode}'") -def _spawn(command: list[str], env: dict[str, str], secret: str) -> int: +def _spawn(command: list[str], env: dict[str, str], secret: str, *, cwd: str | None = None) -> int: """Spawn the child, stream redacted output, propagate signals, ensure cleanup.""" try: proc = subprocess.Popen( @@ -261,6 +274,8 @@ def _spawn(command: list[str], env: dict[str, str], secret: str) -> int: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + cwd=cwd, + stdin=subprocess.DEVNULL if cwd is not None else None, ) except FileNotFoundError as e: raise DeliveryError(f"command not found: {command[0]}") from e diff --git a/src/secrets_engine/exec_owner.py b/src/secrets_engine/exec_owner.py new file mode 100644 index 0000000..6e02f0a --- /dev/null +++ b/src/secrets_engine/exec_owner.py @@ -0,0 +1,124 @@ +"""Catalog-bound child delivery. Configuration constrains a grant; it grants none. + +The engine and configured owner UID remain trusted. These checks do not fence a +malicious process with that same UID or replace runtime/profile verification. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import re +import stat +from typing import Any + +from secrets_engine.errors import CatalogError, DeliveryError + + +def _text(value: object) -> bool: + return isinstance(value, str) and bool(value.strip()) and "\0" not in value + + +def _absolute(value: object) -> bool: + return _text(value) and Path(value).is_absolute() and str(Path(value)) == value and ".." not in Path(value).parts + + +def validate_exec_owner(value: object) -> dict[str, Any]: + if not isinstance(value, dict) or not _text(value.get("owner")): + raise CatalogError("delivery_config.exec_owner requires a named owner") + if value.get("status") == "pending": + if set(value) != {"status", "owner", "reason"} or not _text(value.get("reason")): + 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: + 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]): + raise CatalogError("exec_owner command must be an exact argv with an absolute executable") + if not _absolute(value["cwd"]): + raise CatalogError("exec_owner cwd must be an absolute canonical directory") + env = value["environment"] + if not isinstance(env, dict) or not all( + isinstance(k, str) and re.fullmatch(r"[A-Z_][A-Z0-9_]*", k) + and isinstance(v, str) and "\0" not in v for k, v in env.items() + ): + raise CatalogError("exec_owner environment must be an explicit string mapping") + # The fixed environment is non-secret source configuration, not a way to + # carry the invoking engine's credentials or loader injection into the child. + if any(k.startswith(("LD_", "DYLD_", "PYTHON", "BAO_", "VAULT_", "SECRETS_ENGINE_", "ANTHROPIC_", "CLAUDE_")) for k in env): + raise CatalogError("exec_owner environment contains a forbidden credential/loader variable") + files = value["files"] + if not isinstance(files, dict) or command[0] not in files: + raise CatalogError("exec_owner files must pin the executable") + for path, spec in files.items(): + if not _absolute(path) or not isinstance(spec, dict) or set(spec) != {"sha256", "private"}: + raise CatalogError("exec_owner files require canonical paths and sha256/private") + if not isinstance(spec["sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", spec["sha256"]) or type(spec["private"]) is not bool: + raise CatalogError("exec_owner file pin must have an exact SHA-256 and boolean private") + for arg in command[1:]: + if arg.startswith("/") and arg not in files: + raise CatalogError("exec_owner absolute file arguments must have file pins") + return value + + +def owner_binding(entry) -> dict[str, Any] | None: + config = entry.delivery_config + if "exec_owner" not in config: + return None + return validate_exec_owner(config["exec_owner"]) + + +def owner_digest(entry) -> str | None: + binding = owner_binding(entry) + if binding is None: + return None + return hashlib.sha256(json.dumps(binding, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() + + +def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None: + """No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe.""" + for candidate in [*reversed(path.parents), path]: + info = candidate.lstat() + leaf = candidate == path + is_dir = not leaf or directory + if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)): + raise DeliveryError("exec owner path is not a regular file/directory") + if info.st_uid not in {0, os.getuid()}: + raise DeliveryError("exec owner path has an untrusted owner") + sticky_root = not leaf and info.st_uid == 0 and bool(info.st_mode & stat.S_ISVTX) + if info.st_mode & 0o022 and not sticky_root: + raise DeliveryError("exec owner path is writable by another identity") + if leaf and private and info.st_mode & 0o077: + raise DeliveryError("exec owner private path is accessible by another identity") + + +def validate_delivery_target(entry, field: str, command: list[str], mode: str) -> str | None: + binding = owner_binding(entry) + if binding is None: + return None + if binding["status"] != "configured": + 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"]: + 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") + try: + _check_path(Path(binding["cwd"]), directory=True, private=True) + for name, pin in binding["files"].items(): + path = Path(name) + _check_path(path, private=pin["private"]) + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as source: + if not stat.S_ISREG(os.fstat(source.fileno()).st_mode): + raise DeliveryError("exec owner file changed type") + if hashlib.file_digest(source, "sha256").hexdigest() != pin["sha256"]: + raise DeliveryError("exec owner file digest differs from catalog") + if not os.access(command[0], os.X_OK): + raise DeliveryError("exec owner executable is not executable") + except OSError as exc: + raise DeliveryError("exec owner path is unavailable or unsafe") from exc + return owner_digest(entry) diff --git a/src/secrets_engine/routing.py b/src/secrets_engine/routing.py index c0d2b31..24e56cf 100644 --- a/src/secrets_engine/routing.py +++ b/src/secrets_engine/routing.py @@ -8,6 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, asdict from pathlib import Path +import shlex from typing import Any from secrets_engine.catalog import CatalogEntry @@ -78,8 +79,17 @@ def route_lane( approved = not entry.approval_required() or (decision is not None and decision.is_approved()) ready = approved and metadata_applied and value_present + from secrets_engine.exec_owner import owner_binding - if not approved: + binding = owner_binding(entry) + owner_pending = binding is not None and binding["status"] == "pending" + if owner_pending: + ready = False + + if owner_pending: + missing = "configured exec owner command, private inputs and environment" + next_command = f"secrets-engine catalog show {entry.id}" + elif not approved: missing = f"approved decision for '{decision_ref}'" next_command = f"secrets-engine decision inspect {decision_ref or entry.id}" elif not metadata_applied: @@ -112,7 +122,8 @@ def route_lane( else: missing = "" if {"exec-env", "npm-config"}.intersection(entry.delivery_modes): - next_command = f"secrets-engine exec --catalog {entry.id} -- " + command = shlex.join(binding["command"]) if binding is not None else "" + next_command = f"secrets-engine exec --catalog {entry.id} -- {command}" else: next_command = ( f"secrets-engine verify {entry.id} --positive --negative " diff --git a/tests/test_exec_owner.py b/tests/test_exec_owner.py new file mode 100644 index 0000000..f11523f --- /dev/null +++ b/tests/test_exec_owner.py @@ -0,0 +1,167 @@ +import copy +import hashlib +import os +from pathlib import Path +import shutil +from types import SimpleNamespace + +import pytest + +from secrets_engine import cli, exec_delivery +from secrets_engine.authorization import build_action_request, request_digest, validate_decision_envelope +from secrets_engine.catalog import load_entry, validate_entry +from secrets_engine.errors import CatalogError, DecisionError, DeliveryError +from secrets_engine.exec_owner import owner_digest +from tests.test_action_authorization import _envelope +from tests.test_catalog import VALID +from tests.test_lane_state import _cfg + + +@pytest.fixture +def bound(tmp_path): + private = tmp_path / "owner" + private.mkdir(mode=0o700) + executable = private / "dash" + shutil.copyfile("/bin/dash", executable) + executable.chmod(0o700) + script = private / "owner.sh" + script.write_text('''test "$API_TOKEN" = synthetic-owner-value || exit 10 +test -z "$PARENT_CREDENTIAL" || exit 11 +test -z "$BAO_TOKEN" || exit 12 +test -z "$SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE" || exit 13 +test -z "$PYTHONPATH" || exit 14 +read ignored && exit 15 +printf '%s\\n' "$API_TOKEN" +printf 'owner-child-ok\\n' +pwd +''') + script.chmod(0o600) + data = copy.deepcopy(VALID) + data["delivery_config"] = {"exec_owner": { + "status": "configured", "owner": "fixture-owner", + "command": [str(executable), str(script)], "cwd": str(private), + "environment": {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"}, + "files": {str(f): {"sha256": hashlib.sha256(f.read_bytes()).hexdigest(), "private": True} for f in [executable, script]}, + }} + return data, private, script + + +def test_actual_bound_child_gets_only_selected_secret_and_fixed_environment(bound, monkeypatch, capsys): + data, private, _ = bound + entry = validate_entry(data) + for name in ["PARENT_CREDENTIAL", "BAO_TOKEN", "SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", "PYTHONPATH"]: + monkeypatch.setenv(name, "parent-sensitive-fixture") + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: "synthetic-owner-value") + assert exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"], mode="exec-env", expected_owner_digest=owner_digest(entry)) == 0 + output = capsys.readouterr().out + assert "owner-child-ok" in output and str(private) in output + assert "synthetic-owner-value" not in output and "parent-sensitive-fixture" not in output + assert os.environ["BAO_TOKEN"] == "parent-sensitive-fixture" + + +@pytest.mark.parametrize("change", ["command", "extra_arg", "config", "symlink", "private_mode", "parent_mode", "mode", "field"]) +def test_bound_refusals_happen_before_fetch(bound, monkeypatch, change): + data, private, script = bound + entry = validate_entry(data) + command = list(data["delivery_config"]["exec_owner"]["command"]) + mode, field = "exec-env", "api_token" + if change == "command": command[0] = "/bin/sh" + elif change == "extra_arg": command.append("--other") + elif change == "config": script.write_text("changed") + elif change == "symlink": + saved = script.with_suffix(".saved") + script.rename(saved); script.symlink_to(saved) + elif change == "private_mode": script.chmod(0o640) + elif change == "parent_mode": private.chmod(0o770) + elif change == "mode": mode = "exec-file" + elif change == "field": field = "wrong" + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("must refuse before read")) + with pytest.raises(DeliveryError): + exec_delivery.exec_with_secret(object(), entry, field, command, mode=mode) + + +def test_config_changed_during_fetch_does_not_launch(bound, monkeypatch): + data, _, script = bound + entry = validate_entry(data) + def fetch(*a, **k): + script.write_text("changed during fetch") + 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="digest"): + exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"]) + + +def test_binding_changed_after_approval_refuses_before_fetch(bound, monkeypatch): + data, _, _ = bound + entry = validate_entry(data) + expected = owner_digest(entry) + entry.delivery_config["exec_owner"]["environment"]["LANG"] = "C" + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("must not read")) + with pytest.raises(DeliveryError, match="after action admission"): + exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"], expected_owner_digest=expected) + + +def test_pending_real_catalog_refuses_before_approval_and_backend(tmp_path, monkeypatch): + entry = load_entry(Path(__file__).resolve().parents[1] / "catalog/glas-claude-agent-dev-anthropic.yaml") + monkeypatch.setattr(cli, "get_entry", lambda *a: entry) + for name in ["_require_lane_approval", "_open_backend"]: + monkeypatch.setattr(cli, name, lambda *a, **k: pytest.fail("no approval consume or backend")) + with pytest.raises(DeliveryError, match="pending"): + cli.cmd_exec(_cfg(tmp_path), SimpleNamespace(field=None, catalog=entry.id, command=["/bin/echo"], mode="exec-env")) + + +def test_configured_command_refusal_precedes_approval_and_backend(bound, monkeypatch, tmp_path): + data, _, _ = bound + entry = validate_entry(data) + monkeypatch.setattr(cli, "get_entry", lambda *a: entry) + for name in ["_require_lane_approval", "_open_backend"]: + monkeypatch.setattr(cli, name, lambda *a, **k: pytest.fail("no approval consume or backend")) + with pytest.raises(DeliveryError, match="catalog-bound"): + cli.cmd_exec(_cfg(tmp_path), SimpleNamespace(field=None, catalog=entry.id, command=["/bin/echo"], mode="exec-env")) + + +@pytest.mark.parametrize("changed", ["command", "cwd", "environment", "files", "owner"]) +def test_changed_recipient_cannot_replay_prior_decision(bound, changed): + data, private, script = bound + entry = validate_entry(data) + def request(e): return build_action_request(e, "exec", subject_id="agent:fixture", subject_type="Agent", purpose="owner-proof", fields=["api_token"], request_id="fixture-owner-proof") + before = request(entry) + envelope = _envelope(before) + other = copy.deepcopy(data) + binding = other["delivery_config"]["exec_owner"] + if changed == "command": binding["command"].append("--other") + elif changed == "cwd": binding["cwd"] = str(private / "other") + elif changed == "environment": binding["environment"]["LANG"] = "C" + elif changed == "files": binding["files"][str(script)]["sha256"] = "1" * 64 + else: binding["owner"] = "another-owner" + after = request(validate_entry(other)) + assert request_digest(before) != request_digest(after) + with pytest.raises(DecisionError): + validate_decision_envelope(envelope, after, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}) + assert "exec_owner_sha256" not in request(validate_entry(VALID))["context"] + + +@pytest.mark.parametrize("change", ["null", "unknown", "relative", "missing_pin", "null_private", "preload", "credential", "other_mode"]) +def test_invalid_binding_is_not_a_catalog_fallback(bound, change): + data, _, _ = bound + binding = data["delivery_config"]["exec_owner"] + if change == "null": data["delivery_config"]["exec_owner"] = None + elif change == "unknown": binding["extra"] = True + elif change == "relative": binding["command"][0] = "python3" + elif change == "missing_pin": binding["files"] = {} + elif change == "null_private": next(iter(binding["files"].values()))["private"] = None + elif change == "preload": binding["environment"]["LD_PRELOAD"] = "/tmp/loader" + elif change == "credential": binding["environment"]["SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE"] = "/tmp/credential" + else: data["delivery_modes"].append("exec-file") + with pytest.raises(CatalogError): validate_entry(data) + + +def test_pending_owner_never_advertises_ready(tmp_path, monkeypatch): + from secrets_engine import routing + entry = load_entry(Path(__file__).resolve().parents[1] / "catalog/glas-claude-agent-dev-anthropic.yaml") + monkeypatch.setattr(routing, "resolve_decision", lambda **k: SimpleNamespace(status="approved", review_url="", is_approved=lambda: True)) + client = SimpleNamespace(is_reachable=lambda: True, read_policy=lambda p: "policy", approle_exists=lambda r: True, kv_fields_present=lambda *a: {"ANTHROPIC_API_KEY": True}) + result = routing.route_lane(entry, hub_url="", repo_root=tmp_path, client=client) + assert not result.ready and "exec owner" in result.missing + assert "