Implement exec-file delivery
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:
parent
afd1c8e593
commit
ce1790f267
7 changed files with 126 additions and 19 deletions
12
SCOPE.md
12
SCOPE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 '--'")
|
||||
|
|
|
|||
|
|
@ -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}'")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue