Bind credential exec to exact owner inputs and approval digest
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
9eb07fd8fc
commit
42b48aa54f
13 changed files with 626 additions and 7 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
57
docs/evidence/2026-09-10-exec-owner-approval-exercise.json
Normal file
57
docs/evidence/2026-09-10-exec-owner-approval-exercise.json
Normal 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"
|
||||||
|
}
|
||||||
93
docs/exec-owner-binding.md
Normal file
93
docs/exec-owner-binding.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
124
src/secrets_engine/exec_owner.py
Normal file
124
src/secrets_engine/exec_owner.py
Normal 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)
|
||||||
|
|
@ -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
167
tests/test_exec_owner.py
Normal 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
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue