Implement exec-file delivery
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Write the selected field to a mode-0600 temp file, inject FIELD_FILE for
the child only, then overwrite and unlink on every exit path. The value
is not copied into the child environment.

Assistant: grok
Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00
This commit is contained in:
tegwick 2026-09-02 08:59:56 +02:00
parent afd1c8e593
commit ce1790f267
7 changed files with 126 additions and 19 deletions

View file

@ -202,10 +202,9 @@ secrets-engine audit <catalog-id> [--json]
secrets-engine evidence heartbeat|drain|classify
```
The implemented exec adapters are `exec-env` and `npm-config`. `read-check` is
verification, `approle-login` is auth-capability handoff metadata, and
`exec-file` remains a reserved schema name without an exec adapter.
`secrets-engine wrap` implements response-wrapped operator handoff.
The implemented exec adapters are `exec-env`, `npm-config`, and `exec-file`.
`read-check` is verification and `approle-login` is auth-capability handoff
metadata. `secrets-engine wrap` implements response-wrapped operator handoff.
## Proven Operationally
@ -222,9 +221,8 @@ verification, `approle-login` is auth-capability handoff metadata, and
## Not Implemented
- A service API, daemon, UI, queue, scheduler, or remote multi-user service.
- OpenBao JWT login and platform materialization for the implemented KeyCape
service-auth provider.
- Native `exec-file` delivery.
- Platform JWT mount/role materialization for the implemented KeyCape
service-auth / `service-jwt` provider.
- Provider-side rotation or coordinated multi-consumer rollout.
- First-class rotate, compromise, reactivate, lease-status, or audit report
commands; lifecycle operations currently execute plans without persistent

View file

@ -43,8 +43,9 @@ delivery_auth:
The current native implementation supports AppRole. An entry that declares
`exec-env`, `exec-file`, `npm-config`, `read-check`, or `wrapped` must therefore
declare delivery auth. `exec-file` remains a reserved exec schema mode.
`wrapped` operator handoff is `secrets-engine wrap`; it is not an exec adapter.
declare delivery auth. `exec-file` writes the value to a mode-0600 temp file
and injects the path (`FIELD_FILE`) for the child only. `wrapped` operator
handoff is `secrets-engine wrap`; it is not an exec adapter.
Engine-managed AppRoles may bound `token_ttl`, `token_max_ttl`,
`secret_id_ttl`, `secret_id_num_uses`, and `token_num_uses`. The admitted

View file

@ -56,7 +56,7 @@ secrets-engine provision <catalog-id> --stage <stage> --field NAME (--from-file
secrets-engine verify <catalog-id> [--field NAME] [--positive] [--negative] [--negative-token-file F]
secrets-engine handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
secrets-engine wrap <catalog-id> --out F [--ttl 15m]
secrets-engine exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
secrets-engine exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env|exec-file] -- CMD...
secrets-engine policy publication <catalog-id>
secrets-engine route <catalog-id> [--json]
secrets-engine revoke <catalog-id> [--dry-run]
@ -117,6 +117,11 @@ All three preserve externally managed auth and workload delivery. The legacy
`revoke` command is a compatibility alias for safe native deactivation, never
KV destruction.
`exec --mode exec-file` writes the selected field to a mode-0600 temp file and
sets `{FIELD}_FILE` to that path for the child only. The file is overwritten
and unlinked after the child exits. The value is not copied into the child
environment.
Exec and verification AppRole logins are scoped sessions. The issued token
self-revokes on every exit path before exec starts (or when verification ends),
and evidence stores only a short accessor fingerprint plus cleanup outcome.

View file

@ -10,7 +10,7 @@ Command surface (FR7):
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
wrap <catalog-id> --out F [--ttl 15m]
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env] -- CMD...
exec --catalog <catalog-id> [--field NAME] [--mode auto|npm-config|exec-env|exec-file] -- CMD...
route <catalog-id> [--json]
revoke <catalog-id>
session revoke --accessor-file F [--stage stage]
@ -840,7 +840,7 @@ def build_parser() -> argparse.ArgumentParser:
ex = sub.add_parser("exec", help="run a command with the secret injected for the child only")
ex.add_argument("--catalog", required=True)
ex.add_argument("--field", default=None)
ex.add_argument("--mode", default="auto", choices=("auto", "npm-config", "exec-env"))
ex.add_argument("--mode", default="auto", choices=("auto", "npm-config", "exec-env", "exec-file"))
add_token_arg(ex)
ex.add_argument("command", nargs=argparse.REMAINDER,
help="command after '--'")

View file

@ -8,6 +8,7 @@ Supported here:
- npm-config: write a temporary .npmrc with the auth token and point the child
at it via NPM_CONFIG_USERCONFIG. Preferred for `npm publish`.
- exec-env: inject the value as an environment variable for the child only.
- exec-file: write the value to a mode-0600 temp file and inject its path.
The parent shell never sees the value; the value is never logged. Child stdout/
stderr is streamed through a redactor as a backstop.
@ -119,10 +120,47 @@ def _npm_userconfig(registry: str, scope: str, token_env: str) -> Iterator[Path]
fh.write(f"{authkey}:_authToken=${{{token_env}}}\n")
yield path
finally:
_unlink_secret_file(path)
def _unlink_secret_file(path: Path) -> None:
"""Overwrite then unlink a secret file. Best-effort; never raises."""
try:
if path.is_file():
size = path.stat().st_size
with path.open("r+b") as fh:
fh.write(b"\0" * max(size, 1))
fh.flush()
os.fsync(fh.fileno())
path.unlink()
except FileNotFoundError:
return
except OSError:
try:
path.unlink()
except FileNotFoundError:
pass
except OSError:
return
@contextmanager
def _secret_file(value: str) -> Iterator[Path]:
"""Write the value to a mode-0600 temp file; overwrite and unlink on exit."""
fd, name = tempfile.mkstemp(prefix="se-exec-", suffix=".tmp")
path = Path(name)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fd = -1
fh.write(value)
fh.write("\n")
yield path
finally:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
_unlink_secret_file(path)
def _stream_redacted(proc: subprocess.Popen, secret: str) -> None:
@ -156,9 +194,14 @@ def exec_with_secret(
declared = set(entry.delivery_modes)
if mode == "auto":
mode = "npm-config" if "npm-config" in declared else (
"exec-env" if "exec-env" in declared else ""
)
if "npm-config" in declared:
mode = "npm-config"
elif "exec-env" in declared:
mode = "exec-env"
elif "exec-file" in declared:
mode = "exec-file"
else:
mode = ""
if not mode:
raise DeliveryError(
f"lane '{entry.id}' declares no exec-capable delivery mode "
@ -200,6 +243,12 @@ def exec_with_secret(
child_env[env_name] = value
return _spawn(command, child_env, value)
if mode == "exec-file":
env_name = f"{field.upper()}_FILE"
with _secret_file(value) as secret_path:
child_env[env_name] = str(secret_path)
return _spawn(command, child_env, value)
raise DeliveryError(f"unsupported delivery mode '{mode}'")

View file

@ -63,6 +63,59 @@ def test_exec_env_injects_only_selected_declared_field(monkeypatch):
)
def test_exec_file_injects_path_not_value_and_unlinks(monkeypatch, tmp_path):
data = copy.deepcopy(VALID)
data["delivery_modes"] = ["exec-file"]
entry = validate_entry(data)
seen = {}
def fake_fetch(_client, _entry, field):
assert field == "api_token"
return "test-secret-value"
def fake_spawn(command, env, secret):
path = env["API_TOKEN_FILE"]
seen["path"] = path
seen["exists_during"] = __import__("pathlib").Path(path).is_file()
seen["mode"] = __import__("pathlib").Path(path).stat().st_mode & 0o777
seen["contents"] = __import__("pathlib").Path(path).read_text()
seen["has_value_env"] = "API_TOKEN" in env
assert command == ["probe"]
assert secret == "test-secret-value"
return 0
monkeypatch.setattr("secrets_engine.exec_delivery._fetch_value", fake_fetch)
monkeypatch.setattr("secrets_engine.exec_delivery._spawn", fake_spawn)
assert exec_with_secret(object(), entry, "api_token", ["probe"], mode="exec-file") == 0
assert seen["exists_during"] is True
assert seen["mode"] == 0o600
assert seen["contents"].strip() == "test-secret-value"
assert seen["has_value_env"] is False
assert not __import__("pathlib").Path(seen["path"]).exists()
def test_exec_file_unlinks_after_child_failure(monkeypatch):
data = copy.deepcopy(VALID)
data["delivery_modes"] = ["exec-file"]
entry = validate_entry(data)
seen = {}
monkeypatch.setattr(
"secrets_engine.exec_delivery._fetch_value",
lambda *_args, **_kwargs: "test-secret-value",
)
def fake_spawn(command, env, secret):
seen["path"] = env["API_TOKEN_FILE"]
raise RuntimeError("child exploded")
monkeypatch.setattr("secrets_engine.exec_delivery._spawn", fake_spawn)
with pytest.raises(RuntimeError, match="child exploded"):
exec_with_secret(object(), entry, "api_token", ["probe"], mode="exec-file")
assert seen["path"]
assert not __import__("pathlib").Path(seen["path"]).exists()
def test_exec_rejects_undeclared_field_before_fetch(monkeypatch):
entry = validate_entry(VALID)
monkeypatch.setattr(

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: custodian
created: "2026-08-21"
updated: "2026-08-21"
updated: "2026-09-02"
state_hub_workstream_id: "31f7f8ea-7f73-516c-8877-f03a13f1db82"
---
@ -49,7 +49,8 @@ The current implementation is generic at the catalog level for KV
`mount`/`path`/`fields`, and `secrets-engine exec` supports `exec-env` plus the
npm-specific `npm-config` adapter. The remaining delivery-mode names are not all
exec adapters: `read-check` is verification, `approle-login` is auth-capability
handoff, and `exec-file`/`wrapped` are not yet implemented by `exec`.
handoff. `exec-file` is implemented by `exec`; `wrapped` operator handoff is
`secrets-engine wrap`, not an exec adapter.
## Design constraints