diff --git a/SCOPE.md b/SCOPE.md index 0f19b85..c010b2a 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -202,10 +202,9 @@ secrets-engine audit [--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 diff --git a/docs/catalog-admission.md b/docs/catalog-admission.md index cd22cf8..e91c1d5 100644 --- a/docs/catalog-admission.md +++ b/docs/catalog-admission.md @@ -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 diff --git a/docs/cli.md b/docs/cli.md index c14456e..ccf8ae0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -56,7 +56,7 @@ secrets-engine provision --stage --field NAME (--from-file secrets-engine verify [--field NAME] [--positive] [--negative] [--negative-token-file F] secrets-engine handoff --stage --role-id-file F --secret-id-file F secrets-engine wrap --out F [--ttl 15m] -secrets-engine exec --catalog [--field NAME] [--mode auto|npm-config|exec-env] -- CMD... +secrets-engine exec --catalog [--field NAME] [--mode auto|npm-config|exec-env|exec-file] -- CMD... secrets-engine policy publication secrets-engine route [--json] secrets-engine revoke [--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. diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index 4ff39d0..10f7d91 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -10,7 +10,7 @@ Command surface (FR7): verify [--positive] [--negative] [--field NAME] [--negative-token-file F] handoff --stage --role-id-file F --secret-id-file F wrap --out F [--ttl 15m] - exec --catalog [--field NAME] [--mode auto|npm-config|exec-env] -- CMD... + exec --catalog [--field NAME] [--mode auto|npm-config|exec-env|exec-file] -- CMD... route [--json] revoke 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 '--'") diff --git a/src/secrets_engine/exec_delivery.py b/src/secrets_engine/exec_delivery.py index 3a601e7..6bc6665 100644 --- a/src/secrets_engine/exec_delivery.py +++ b/src/secrets_engine/exec_delivery.py @@ -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}'") diff --git a/tests/test_exec_delivery.py b/tests/test_exec_delivery.py index 60899f6..b14e1f8 100644 --- a/tests/test_exec_delivery.py +++ b/tests/test_exec_delivery.py @@ -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( diff --git a/workplans/SECRETS-WP-0006-catalog-lane-adoption.md b/workplans/SECRETS-WP-0006-catalog-lane-adoption.md index 7f2aa8c..827c293 100644 --- a/workplans/SECRETS-WP-0006-catalog-lane-adoption.md +++ b/workplans/SECRETS-WP-0006-catalog-lane-adoption.md @@ -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