Bind credential exec to exact owner inputs and approval digest
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-10 09:29:38 +02:00
parent 9eb07fd8fc
commit 42b48aa54f
13 changed files with 626 additions and 7 deletions

View file

@ -17,6 +17,14 @@ consumers:
purpose: Owner-admitted glas-harness agt run through the reviewed local profile; purpose: Owner-admitted glas-harness agt run through the reviewed local profile;
no caller-facing key fetch no caller-facing key fetch
workload_delivery: [] 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: delivery_modes:
- exec-env - exec-env
- read-check - read-check

View file

@ -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"
}

View file

@ -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: <boolean>}` |
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
<protected-runtime>/bin/python3 -I -B -m rein_aharness.cli metered-once --owner-config <private-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/<new-receipt>.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.

View file

@ -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 KV custody is already confirmed at version 2. Do not provision or rotate it as
part of native read-lane adoption. 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 The generated plan checks existing mount `platform`, creates policy and AppRole
`se-prod-glas-claude-agent-dev-anthropic`, and grants read only on `se-prod-glas-claude-agent-dev-anthropic`, and grants read only on
`platform/data/workloads/glas-harness/claude-agent-dev`. Field ANTHROPIC_API_KEY `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 Activation depends on SECRETS-WP-0007-T04 (exact production actions) and
SECRETS-WP-0008-T02/T06 (decision consumption and service authority). Require 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 and exact scoped backend authority. This draft cannot authorize itself; an
operator browser token or unsafe-demo flag is not a runtime substitute. operator browser token or unsafe-demo flag is not a runtime substitute.

View file

@ -105,6 +105,12 @@ def build_action_request(
"context": {"purpose": purpose}, "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 return request

View file

@ -360,6 +360,16 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
f"{source}: high-risk lanes require {lifecycle_name}.owner" 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 # npm-config delivery must declare WHERE it publishes (registry + scope), so
# the registry is catalog data, never hardcoded in the engine. # the registry is catalog data, never hardcoded in the engine.
if "npm-config" in modes: if "npm-config" in modes:

View file

@ -542,9 +542,12 @@ def cmd_wrap(cfg: Config, args) -> int:
def cmd_exec(cfg: Config, args) -> int: def cmd_exec(cfg: Config, args) -> int:
from secrets_engine.exec_delivery import exec_with_secret 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) entry = get_entry(cfg.catalog_dir, args.catalog)
field = args.field or (entry.fields[0] if entry.fields else "") field = args.field or (entry.fields[0] if entry.fields else "")
session_detail: dict[str, object] = {} 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 "" command_name = args.command[0] if args.command else ""
with _privileged_evidence( with _privileged_evidence(
cfg, cfg,
@ -555,6 +558,7 @@ def cmd_exec(cfg: Config, args) -> int:
"mode": args.mode, "mode": args.mode,
"field": field, "field": field,
"session": session_detail, "session": session_detail,
"exec_owner_sha256": owner_digest,
}, },
) as evidence: ) as evidence:
# require approval + readiness before running. # require approval + readiness before running.
@ -575,6 +579,7 @@ def cmd_exec(cfg: Config, args) -> int:
args.command, args.command,
mode=args.mode, mode=args.mode,
session_evidence=session_detail, session_evidence=session_detail,
expected_owner_digest=owner_digest,
) )
evidence.finish(f"exit-{rc}") evidence.finish(f"exit-{rc}")
return rc return rc

View file

@ -27,6 +27,7 @@ from typing import Iterator
from secrets_engine.catalog import CatalogEntry from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import DeliveryError 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.openbao import OpenBaoClient
from secrets_engine.publication_policy import PublicationPolicy, resolve from secrets_engine.publication_policy import PublicationPolicy, resolve
from secrets_engine.redact import redact_text from secrets_engine.redact import redact_text
@ -180,6 +181,7 @@ def exec_with_secret(
mode: str = "auto", mode: str = "auto",
policy_dir=None, policy_dir=None,
session_evidence: dict[str, object] | None = None, session_evidence: dict[str, object] | None = None,
expected_owner_digest: str | None = None,
) -> int: ) -> int:
"""Run `command` with the lane's secret injected for the child only. """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}" 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) declared = set(entry.delivery_modes)
if mode == "auto": if mode == "auto":
if "npm-config" in declared: if "npm-config" in declared:
@ -219,7 +225,12 @@ def exec_with_secret(
value = _fetch_value( value = _fetch_value(
client, entry, field, session_evidence=session_evidence 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": if mode == "npm-config":
npm = entry.npm npm = entry.npm
@ -241,6 +252,8 @@ def exec_with_secret(
# Inject under a conventional name derived from the field. # Inject under a conventional name derived from the field.
env_name = field.upper() env_name = field.upper()
child_env[env_name] = value 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) return _spawn(command, child_env, value)
if mode == "exec-file": if mode == "exec-file":
@ -252,7 +265,7 @@ def exec_with_secret(
raise DeliveryError(f"unsupported delivery mode '{mode}'") 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.""" """Spawn the child, stream redacted output, propagate signals, ensure cleanup."""
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
@ -261,6 +274,8 @@ def _spawn(command: list[str], env: dict[str, str], secret: str) -> int:
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
cwd=cwd,
stdin=subprocess.DEVNULL if cwd is not None else None,
) )
except FileNotFoundError as e: except FileNotFoundError as e:
raise DeliveryError(f"command not found: {command[0]}") from e raise DeliveryError(f"command not found: {command[0]}") from e

View file

@ -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)

View file

@ -8,6 +8,7 @@ from __future__ import annotations
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from pathlib import Path from pathlib import Path
import shlex
from typing import Any from typing import Any
from secrets_engine.catalog import CatalogEntry 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()) approved = not entry.approval_required() or (decision is not None and decision.is_approved())
ready = approved and metadata_applied and value_present 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}'" missing = f"approved decision for '{decision_ref}'"
next_command = f"secrets-engine decision inspect {decision_ref or entry.id}" next_command = f"secrets-engine decision inspect {decision_ref or entry.id}"
elif not metadata_applied: elif not metadata_applied:
@ -112,7 +122,8 @@ def route_lane(
else: else:
missing = "" missing = ""
if {"exec-env", "npm-config"}.intersection(entry.delivery_modes): if {"exec-env", "npm-config"}.intersection(entry.delivery_modes):
next_command = f"secrets-engine exec --catalog {entry.id} -- <command...>" command = shlex.join(binding["command"]) if binding is not None else "<command...>"
next_command = f"secrets-engine exec --catalog {entry.id} -- {command}"
else: else:
next_command = ( next_command = (
f"secrets-engine verify {entry.id} --positive --negative " f"secrets-engine verify {entry.id} --positive --negative "

167
tests/test_exec_owner.py Normal file
View file

@ -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 "<command" not in result.next_command

View file

@ -12,11 +12,13 @@ import base64
import copy import copy
import hashlib import hashlib
import http.server import http.server
import io
import ipaddress import ipaddress
import json import json
import os import os
from pathlib import Path from pathlib import Path
import secrets import secrets
import shutil
import socket import socket
import ssl import ssl
import subprocess import subprocess
@ -24,6 +26,7 @@ import sys
import tempfile import tempfile
import threading import threading
import time import time
from contextlib import contextmanager, redirect_stdout
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.parse import urlencode from urllib.parse import urlencode
@ -83,7 +86,7 @@ def exercise(args):
"keycape_contract_commit": commit(args.keycape_source), "keycape_contract_commit": commit(args.keycape_source),
"consumer_source_sha256": { "consumer_source_sha256": {
name: hashlib.sha256((ROOT / "src/secrets_engine" / name).read_bytes()).hexdigest() name: hashlib.sha256((ROOT / "src/secrets_engine" / name).read_bytes()).hexdigest()
for name in ("approval_auth.py", "approval_consume.py", "approval_claim.py", "authorization.py", "config.py", "service_auth.py", "cli.py") for name in ("approval_auth.py", "approval_consume.py", "approval_claim.py", "authorization.py", "config.py", "service_auth.py", "cli.py", "catalog.py", "exec_owner.py", "exec_delivery.py", "routing.py")
}, },
"limitations": ["standalone Flex Auth source, not deployed pin", "local Approval Engine source, not deployed image", "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"], "no live custody or client-side read grant", "no OpenBao effect or model execution"],
@ -327,6 +330,88 @@ def exercise(args):
cli._require_lane_approval(cfg, destroy_entry, "destroy") cli._require_lane_approval(cfg, destroy_entry, "destroy")
assert engine.claim("synthetic-destroy")["consumed"] assert engine.claim("synthetic-destroy")["consumed"]
receipt["checks"]["real_dual_control_denial_then_claim_check_consume"] = True receipt["checks"]["real_dual_control_denial_then_claim_check_consume"] = True
if args.exec_owner:
from secrets_engine.errors import DeliveryError
from secrets_engine.exec_owner import owner_digest
from types import SimpleNamespace
# Synthetic recipient and backend, real engine CLI, exact
# producer digest, JWT claim/consume, spawn and environment.
owner_dir = root / "exec-owner"
owner_dir.mkdir(mode=0o700)
executable = owner_dir / "dash"
shutil.copyfile("/bin/dash", executable)
executable.chmod(0o700)
script = owner_dir / "owner.sh"
private_write(script, 'test "$API_TOKEN" = synthetic-owner-value || exit 10\n'
'test -z "$SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE" || exit 11\n'
'test -z "$BAO_TOKEN" || exit 12\n'
'printf "owner-delivery-ok\\n"\n')
raw["approval"]["authorization_id"] = "synthetic-owner-delivery"
raw["delivery_config"] = {"exec_owner": {
"status": "configured", "owner": "synthetic-metered-owner",
"command": [str(executable), str(script)], "cwd": str(owner_dir),
"environment": {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"},
"files": {str(p): {"sha256": hashlib.sha256(p.read_bytes()).hexdigest(), "private": True}
for p in [executable, script]},
}}
owner_entry = validate_entry(raw)
req = _expected_request(cfg, owner_entry, "exec", fields=("api_token",))
owner_request_digest = pdp.bind_request(req)
operator_request("/v1/approvals", {
"id": "synthetic-owner-delivery", "binding": {"action": "secrets.exec", "target": {"id": owner_entry.id, "stage": "prod"},
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
"pdp_digest": owner_request_digest, "pdp_path": True}, "approval:create")
operator_request("/v1/approvals/synthetic-owner-delivery/entries", {}, "approval:approve")
backend_calls = []
class Backend:
@contextmanager
def approle_session(self, role):
assert role == owner_entry.role_name
yield SimpleNamespace(client=self)
def _run(self, command):
assert command == ["kv", "get", "-format=json", f"{owner_entry.mount}/{owner_entry.path}"]
return SimpleNamespace(returncode=0, stdout=json.dumps({"data": {"data": {"api_token": "synthetic-owner-value"}}}))
@contextmanager
def open_fixture_backend(*_args):
assert engine.claim("synthetic-owner-delivery")["consumed"]
backend_calls.append("after-consume")
yield Backend()
command = raw["delivery_config"]["exec_owner"]["command"]
cli_args = SimpleNamespace(catalog=owner_entry.id, field="api_token", mode="exec-env", command=command)
with patch.object(cli, "get_entry", return_value=owner_entry), patch.object(cli, "_open_backend", open_fixture_backend):
substituted = SimpleNamespace(**vars(cli_args))
substituted.command = ["/bin/echo", "substitute"]
try:
cli.cmd_exec(cfg, substituted)
except DeliveryError:
assert not engine.claim("synthetic-owner-delivery")["consumed"] and not backend_calls
else:
raise AssertionError("substitute recipient accepted")
original_env = raw["delivery_config"]["exec_owner"]["environment"]["LANG"]
raw["delivery_config"]["exec_owner"]["environment"]["LANG"] = "C"
try:
cli.cmd_exec(cfg, cli_args)
except DecisionError:
assert not engine.claim("synthetic-owner-delivery")["consumed"] and not backend_calls
else:
raise AssertionError("changed owner replay accepted")
raw["delivery_config"]["exec_owner"]["environment"]["LANG"] = original_env
output = io.StringIO()
with redirect_stdout(output), patch.dict(os.environ, {"BAO_TOKEN": "synthetic-parent-only", "SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE": str(root / "approval.secret")}):
assert cli.cmd_exec(cfg, cli_args) == 0
assert "owner-delivery-ok" in output.getvalue() and "synthetic-parent-only" not in output.getvalue()
assert backend_calls == ["after-consume"]
assert pdp.last_decision["binding"]["context"]["exec_owner_sha256"] == owner_digest(owner_entry)
receipt["checks"].update({
"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,
})
receipt["exec_owner_scope"] = "Synthetic recipient/backend with real KeyCape, Approval Engine, Flex Auth and Secrets Engine CLI; not native custody or human approval proof"
finally: finally:
if api_server: if api_server:
api_server.shutdown() api_server.shutdown()
@ -350,6 +435,7 @@ def main():
parser.add_argument("--approval-engine-source", required=True, type=Path) parser.add_argument("--approval-engine-source", required=True, type=Path)
parser.add_argument("--flex-auth-source", required=True, type=Path) parser.add_argument("--flex-auth-source", required=True, type=Path)
parser.add_argument("--receipt", required=True, type=Path) parser.add_argument("--receipt", required=True, type=Path)
parser.add_argument("--exec-owner", action="store_true", help="also prove catalog-bound child delivery against the actual approval/PDP chain")
args = parser.parse_args() args = parser.parse_args()
if args.receipt.exists(): if args.receipt.exists():
raise SystemExit("receipt path must be new") raise SystemExit("receipt path must be new")

View file

@ -7,7 +7,7 @@ repo: secrets-engine
status: blocked status: blocked
owner: codex owner: codex
created: "2026-09-05" created: "2026-09-05"
updated: "2026-09-09" updated: "2026-09-10"
state_hub_workstream_id: "40ccc3b4-d046-5a58-8649-e7935f45c974" state_hub_workstream_id: "40ccc3b4-d046-5a58-8649-e7935f45c974"
--- ---
@ -49,6 +49,7 @@ synthetic exec-env transport proof passed; no real secret was read.
id: SECRETS-WP-0009-T03 id: SECRETS-WP-0009-T03
status: wait status: wait
priority: high priority: high
blocking_reason: "Recipient binding now implemented and proved with the real local approval/PDP chain. Complete exact private owner configuration and native MessagesOwner holder admission, CCR-2026-0019 operator reader, human/audit/service path and scoped live delivery. CCR-2026-0020 was cancelled by its owner."
state_hub_task_id: "f8069c8a-ad6b-5d0b-9a36-c2326699437d" state_hub_task_id: "f8069c8a-ad6b-5d0b-9a36-c2326699437d"
``` ```
@ -196,3 +197,32 @@ receipt: docs/evidence/2026-09-09-replay-contract-exercise.json. The former PDP
double limitation is resolved for component conformance. T03 remains wait for double limitation is resolved for component conformance. T03 remains wait for
current deployed PDP adoption, separately admitted client-side/audit custody, current deployed PDP adoption, separately admitted client-side/audit custody,
actual Approval Engine deployment, and the scoped native OpenBao delivery proof. actual Approval Engine deployment, and the scoped native OpenBao delivery proof.
### 2026-09-10 exact recipient enforcement
The factory runtime is now installed on workstation and Railiance at digest
`5371156d2027dde6e8f140cc0a1833c4e90b8c75b3bec862e05f06a957f5fd34`;
consume project evidence `2026-09-10-runtime-placement.json`. Native delivery
review exposed an unclosed local boundary: exec accepted any argv and inherited
all parent environment after catalog/action approval.
Implemented optional catalog exec_owner with exact command, private cwd, fixed
non-secret environment and file pins. The factory catalog is explicitly pending
and refuses before approval/backend. The complete binding digest travels in the
existing CheckRequest context and producer/approval join. Changes before or during
retrieval refuse launch; the actual child has closed stdin and receives no inherited
engine auth environment. Existing action/consume/state/backend gates remain.
361 tests passed. The extended disposable actual KeyCape/Approval Engine/Flex Auth
exercise passed all 18 checks, including changed-recipient replay refusal and
consume-before-synthetic-backend followed by actual child execution. Receipt:
`docs/evidence/2026-09-10-exec-owner-approval-exercise.json`; contract:
`docs/exec-owner-binding.md`. Synthetic backend/key/recipient and test operator
identity only; no native secret read, human approval or paid execution.
T03 remains wait for configured/pinned owner inputs and holder admission, wanted
CCR-2026-0019 operator-group/file-delivery return, INFD-WP-0001-T07/T08 real human
approval, audit custody and deployed services/PDP/native delivery. CCR-2026-0020
was cancelled on owner withdrawal; do not wait for or recreate its presenter.
No runtime artifact rebuild is needed for this credential-engine change.