Add response-wrapped operator handoff
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

secrets-engine wrap writes a single-use OpenBao wrap token to a mode-0600
out-of-repo file and never prints it. KV reads and AppRole secret_ids are
wrapped with a 15m TTL cap. Unwrapped secret payloads fail closed.
Production wrap remains fail-closed.

Assistant: grok
Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00
This commit is contained in:
tegwick 2026-09-02 08:12:42 +02:00
parent 2278cefbb3
commit afd1c8e593
12 changed files with 387 additions and 8 deletions

View file

@ -204,7 +204,8 @@ secrets-engine evidence heartbeat|drain|classify
The implemented exec adapters are `exec-env` and `npm-config`. `read-check` is The implemented exec adapters are `exec-env` and `npm-config`. `read-check` is
verification, `approle-login` is auth-capability handoff metadata, and verification, `approle-login` is auth-capability handoff metadata, and
`exec-file`/`wrapped` are reserved schema names without executable adapters. `exec-file` remains a reserved schema name without an exec adapter.
`secrets-engine wrap` implements response-wrapped operator handoff.
## Proven Operationally ## Proven Operationally
@ -223,7 +224,7 @@ verification, `approle-login` is auth-capability handoff metadata, and
- A service API, daemon, UI, queue, scheduler, or remote multi-user service. - A service API, daemon, UI, queue, scheduler, or remote multi-user service.
- OpenBao JWT login and platform materialization for the implemented KeyCape - OpenBao JWT login and platform materialization for the implemented KeyCape
service-auth provider. service-auth provider.
- Native `exec-file` or response-wrapped delivery. - Native `exec-file` delivery.
- Provider-side rotation or coordinated multi-consumer rollout. - Provider-side rotation or coordinated multi-consumer rollout.
- First-class rotate, compromise, reactivate, lease-status, or audit report - First-class rotate, compromise, reactivate, lease-status, or audit report
commands; lifecycle operations currently execute plans without persistent commands; lifecycle operations currently execute plans without persistent

View file

@ -43,8 +43,8 @@ delivery_auth:
The current native implementation supports AppRole. An entry that declares The current native implementation supports AppRole. An entry that declares
`exec-env`, `exec-file`, `npm-config`, `read-check`, or `wrapped` must therefore `exec-env`, `exec-file`, `npm-config`, `read-check`, or `wrapped` must therefore
declare delivery auth. `exec-file` and `wrapped` remain reserved schema modes; declare delivery auth. `exec-file` remains a reserved exec schema mode.
they are not implemented by `secrets-engine exec` yet. `wrapped` operator handoff is `secrets-engine wrap`; it is not an exec adapter.
Engine-managed AppRoles may bound `token_ttl`, `token_max_ttl`, Engine-managed AppRoles may bound `token_ttl`, `token_max_ttl`,
`secret_id_ttl`, `secret_id_num_uses`, and `token_num_uses`. The admitted `secret_id_ttl`, `secret_id_num_uses`, and `token_num_uses`. The admitted

View file

@ -55,6 +55,7 @@ secrets-engine apply <ref> --stage <stage> [--dry-run] [--bootstrap-token-file F
secrets-engine provision <catalog-id> --stage <stage> --field NAME (--from-file F | --generate) secrets-engine provision <catalog-id> --stage <stage> --field NAME (--from-file F | --generate)
secrets-engine verify <catalog-id> [--field NAME] [--positive] [--negative] [--negative-token-file F] 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 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] -- CMD...
secrets-engine policy publication <catalog-id> secrets-engine policy publication <catalog-id>
secrets-engine route <catalog-id> [--json] secrets-engine route <catalog-id> [--json]
@ -80,6 +81,10 @@ local decision is accepted for a prod-labeled lane only with
`SECRETS_ENGINE_UNSAFE_DEMO=1`, an empty Hub URL, and loopback OpenBao; the demo `SECRETS_ENGINE_UNSAFE_DEMO=1`, an empty Hub URL, and loopback OpenBao; the demo
scripts set those three conditions themselves. scripts set those three conditions themselves.
`wrap` writes a single-use OpenBao response-wrap token to `--out` (mode 0600,
outside Git) and never prints it. KV lanes wrap a path read; auth-capability
lanes wrap a secret_id. TTL max 15m. Production live wrap stays fail-closed.
`handoff` is for `kind: auth-capability` lanes such as `warden-sign`. It mints a `handoff` is for `kind: auth-capability` lanes such as `warden-sign`. It mints a
fresh AppRole `secret_id` and writes `role_id` plus `secret_id` to caller-chosen fresh AppRole `secret_id` and writes `role_id` plus `secret_id` to caller-chosen
mode-0600 files outside Git worktrees. It never prints the `secret_id`; use the mode-0600 files outside Git worktrees. It never prints the `secret_id`; use the

View file

@ -46,8 +46,12 @@ contents in this repo.
## H2 — Response-wrapped handoff ## H2 — Response-wrapped handoff
- Add a `wrapped` delivery mode using OpenBao response wrapping for operator - Implemented: `secrets-engine wrap <catalog-id> --out F [--ttl 15m]` writes a
handoff flows where exec-time injection does not fit. single-use wrap token to a mode-0600 out-of-repo file. KV lanes wrap a read;
auth-capability lanes wrap a secret_id. The wrap token is never printed.
Evidence is wrap-handle fingerprint, ttl, and path only. TTL max 15m.
Unwrapped secret payloads fail closed. Production remains fail-closed.
- `exec --mode wrapped` is still not an exec adapter; this is operator handoff.
## H3 — Production dual-control ## H3 — Production dual-control

View file

@ -45,7 +45,7 @@ rules:
- id: production-control-mutation - id: production-control-mutation
kind: load-bearing kind: load-bearing
actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision, session-revoke] actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision, session-revoke, wrap]
stages: [prod] stages: [prod]
emission: local-outbox emission: local-outbox
note: >- note: >-

View file

@ -34,6 +34,7 @@ owned_tooling:
- "bao policy/auth/kv subprocess adapter" - "bao policy/auth/kv subprocess adapter"
- "CAS-aware KV create/patch via JSON input files, never argv values" - "CAS-aware KV create/patch via JSON input files, never argv values"
- "JWT login via JSON input files; issued engine tokens self-revoke" - "JWT login via JSON input files; issued engine tokens self-revoke"
- "response wrapping via -wrap-ttl; wrap token never in argv evidence"
note: >- note: >-
This is the owned Lifecycle contact, not a Staff §5 shape. A new direct This is the owned Lifecycle contact, not a Staff §5 shape. A new direct
OpenBao client outside the listed modules is a finding. OpenBao client outside the listed modules is a finding.
@ -58,6 +59,7 @@ protected_actions:
- lifecycle-deactivate - lifecycle-deactivate
- lifecycle-destroy - lifecycle-destroy
- session-revoke - session-revoke
- wrap
# §13 proposed capabilities. Owner status is proposed, not assented, until # §13 proposed capabilities. Owner status is proposed, not assented, until
# the surface exists in this repository's own contract. # the surface exists in this repository's own contract.

View file

@ -9,6 +9,7 @@ Command surface (FR7):
provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F] verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-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] -- CMD...
route <catalog-id> [--json] route <catalog-id> [--json]
revoke <catalog-id> revoke <catalog-id>
@ -435,6 +436,37 @@ def cmd_handoff(cfg: Config, args) -> int:
return 0 return 0
def cmd_wrap(cfg: Config, args) -> int:
from secrets_engine.wrap import write_wrapped_handoff
entry = get_entry(cfg.catalog_dir, args.catalog_id)
with _privileged_evidence(
cfg, entry, "wrap", detail={"ttl": args.ttl, "out_file": args.out}
) as evidence:
decision = _require_lane_approval(cfg, entry, "wrap", evidence)
evidence.mark_approved(decision)
with _open_backend(cfg, args, evidence) as client:
result = write_wrapped_handoff(
client, entry, out_file=Path(args.out), ttl=args.ttl
)
print(
f"wrote wrap token for lane '{result.catalog_id}' — token not displayed"
)
print(f" out: {result.out_file}")
print(f" ttl: {result.ttl}")
print(f" handle: {result.wrap_handle or '-'}")
evidence.finish(
"wrap-token-written",
detail={
"out_file": result.out_file,
"ttl": result.ttl,
"wrap_handle": result.wrap_handle,
"creation_path": result.creation_path,
},
)
return 0
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
entry = get_entry(cfg.catalog_dir, args.catalog) entry = get_entry(cfg.catalog_dir, args.catalog)
@ -795,6 +827,16 @@ def build_parser() -> argparse.ArgumentParser:
add_token_arg(ha) add_token_arg(ha)
ha.set_defaults(func=cmd_handoff) ha.set_defaults(func=cmd_handoff)
wr = sub.add_parser(
"wrap",
help="write a single-use OpenBao wrap token to a file (never printed)",
)
wr.add_argument("catalog_id")
wr.add_argument("--out", required=True, help="mode-0600 wrap-token file outside Git")
wr.add_argument("--ttl", default="15m", help="wrap TTL, max 15m (default 15m)")
add_token_arg(wr)
wr.set_defaults(func=cmd_wrap)
ex = sub.add_parser("exec", help="run a command with the secret injected for the child only") 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("--catalog", required=True)
ex.add_argument("--field", default=None) ex.add_argument("--field", default=None)

View file

@ -44,6 +44,7 @@ SHIPPED_RULES = (
"lifecycle-deactivate", "lifecycle-deactivate",
"provision", "provision",
"session-revoke", "session-revoke",
"wrap",
), ),
"stages": ("prod",), "stages": ("prod",),
}, },

View file

@ -21,7 +21,7 @@ import stat
import subprocess import subprocess
import tempfile import tempfile
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from secrets_engine.errors import BackendError, ProvisioningError from secrets_engine.errors import BackendError, ProvisioningError
@ -61,6 +61,24 @@ def accessor_fingerprint(accessor: str) -> str:
return hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12] return hashlib.sha256(accessor.encode("utf-8")).hexdigest()[:12]
@dataclass(frozen=True)
class WrappedResponse:
"""One OpenBao response-wrapped payload. The wrap token is secret."""
wrap_token: str = field(repr=False)
accessor_fingerprint: str
ttl: str
creation_path: str
def evidence(self) -> dict[str, object]:
payload: dict[str, object] = {
"wrap_handle": self.accessor_fingerprint,
"wrap_ttl": self.ttl,
"creation_path": self.creation_path,
}
return payload
@dataclass @dataclass
class ScopedTokenSession: class ScopedTokenSession:
"""One AppRole login token that revokes itself on close.""" """One AppRole login token that revokes itself on close."""
@ -335,6 +353,44 @@ class OpenBaoClient:
raise BackendError("token accessor is missing or invalid") raise BackendError("token accessor is missing or invalid")
self._run_ok(["token", "revoke", "-accessor", accessor]) self._run_ok(["token", "revoke", "-accessor", accessor])
def _parse_wrap_response(self, stdout: str, *, ttl: str, creation_path: str) -> WrappedResponse:
try:
payload = json.loads(stdout)
except json.JSONDecodeError as exc:
raise BackendError("wrapped response is not JSON") from exc
if not isinstance(payload, dict):
raise BackendError("wrapped response is invalid")
wrap = payload.get("wrap_info")
if not isinstance(wrap, dict) or not wrap.get("token"):
raise BackendError("response wrapping was not applied")
token = str(wrap["token"])
accessor = str(wrap.get("accessor") or "")
wrap_ttl = wrap.get("ttl")
return WrappedResponse(
wrap_token=token,
accessor_fingerprint=accessor_fingerprint(accessor) if accessor else "",
ttl=str(wrap_ttl if wrap_ttl is not None else ttl),
creation_path=creation_path,
)
def wrap_kv_get(self, mount: str, path: str, *, ttl: str) -> WrappedResponse:
"""Wrap a KV read. Fail if OpenBao returns the secret unwrapped."""
target = f"{mount}/{path}"
proc = self._run(["kv", "get", f"-wrap-ttl={ttl}", "-format=json", target])
if proc.returncode != 0:
raise BackendError("wrapped KV read failed")
return self._parse_wrap_response(proc.stdout, ttl=ttl, creation_path=target)
def wrap_approle_secret_id(self, role_name: str, *, ttl: str) -> WrappedResponse:
"""Wrap a single-use AppRole secret_id. Fail if returned unwrapped."""
target = f"auth/approle/role/{role_name}/secret-id"
proc = self._run(
["write", f"-wrap-ttl={ttl}", "-format=json", "-force", target]
)
if proc.returncode != 0:
raise BackendError("wrapped AppRole secret-id mint failed")
return self._parse_wrap_response(proc.stdout, ttl=ttl, creation_path=target)
# -- KV v2 ------------------------------------------------------------- # -- KV v2 -------------------------------------------------------------
def kv_mount_exists(self, mount: str) -> bool: def kv_mount_exists(self, mount: str) -> bool:

106
src/secrets_engine/wrap.py Normal file
View file

@ -0,0 +1,106 @@
"""Response-wrapped operator handoff.
Writes a single-use wrap token to a mode-0600 file outside Git. The wrap token
is secret material and is never printed or recorded in evidence.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from pathlib import Path
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import ProvisioningError
from secrets_engine.openbao import OpenBaoClient, WrappedResponse
from secrets_engine.safe_paths import containing_git_worktree
_TTL_RE = re.compile(r"^([1-9][0-9]*)([smh])$")
MAX_WRAP_SECONDS = 15 * 60
@dataclass(frozen=True)
class WrapHandoffResult:
catalog_id: str
kind: str
out_file: str
ttl: str
wrap_handle: str
creation_path: str
def normalize_wrap_ttl(value: str) -> str:
text = (value or "").strip()
match = _TTL_RE.fullmatch(text)
if not match:
raise ProvisioningError("wrap ttl must be like 60s or 15m")
amount = int(match.group(1))
unit = match.group(2)
seconds = amount * {"s": 1, "m": 60, "h": 3600}[unit]
if seconds > MAX_WRAP_SECONDS:
raise ProvisioningError("wrap ttl must not exceed 15m")
return text
def _validate_output_path(path: Path) -> Path:
resolved = path.expanduser().resolve()
worktree = containing_git_worktree(resolved)
if worktree is not None:
raise ProvisioningError(
f"wrap file {resolved} is inside a Git worktree ({worktree}); "
"keep wrap tokens outside repos"
)
if resolved.exists() and resolved.stat().st_mode & 0o077:
raise ProvisioningError(
f"wrap file {resolved} is group/other-accessible "
f"(mode {oct(resolved.stat().st_mode & 0o777)}); must be 0600"
)
return resolved
def _write_mode_0600(path: Path, value: str) -> None:
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
fd: int | None = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fd = None
fh.write(value)
fh.write("\n")
finally:
if fd is not None:
os.close(fd)
def write_wrapped_handoff(
client: OpenBaoClient,
entry: CatalogEntry,
*,
out_file: Path,
ttl: str,
) -> WrapHandoffResult:
"""Mint a wrap token and write it without printing it."""
ttl = normalize_wrap_ttl(ttl)
out_path = _validate_output_path(out_file)
wrapped: WrappedResponse
if entry.kind == "auth-capability":
wrapped = client.wrap_approle_secret_id(entry.role_name, ttl=ttl)
elif entry.stores_kv_value():
wrapped = client.wrap_kv_get(entry.mount, entry.path, ttl=ttl)
else:
raise ProvisioningError(
f"lane '{entry.id}' kind {entry.kind} has no wrapped handoff"
)
token = wrapped.wrap_token
try:
_write_mode_0600(out_path, token)
finally:
token = ""
return WrapHandoffResult(
catalog_id=entry.id,
kind=entry.kind,
out_file=str(out_path),
ttl=ttl,
wrap_handle=wrapped.accessor_fingerprint,
creation_path=wrapped.creation_path,
)

View file

@ -172,12 +172,14 @@ def test_classify_does_not_grant_permission():
apply_prod = classify("apply", "prod") apply_prod = classify("apply", "prod")
heartbeat = classify("evidence-heartbeat", "prod") heartbeat = classify("evidence-heartbeat", "prod")
session_revoke = classify("session-revoke", "prod") session_revoke = classify("session-revoke", "prod")
wrap = classify("wrap", "prod")
assert prod_provision.kind == "load-bearing" assert prod_provision.kind == "load-bearing"
assert test_provision.kind == "attributive" assert test_provision.kind == "attributive"
assert destroy.kind == "load-bearing" assert destroy.kind == "load-bearing"
assert apply_prod.kind == "attributive" assert apply_prod.kind == "attributive"
assert heartbeat.kind == "heartbeat" assert heartbeat.kind == "heartbeat"
assert session_revoke.kind == "load-bearing" assert session_revoke.kind == "load-bearing"
assert wrap.kind == "load-bearing"
assert prod_provision.completeness_claimed is False assert prod_provision.completeness_claimed is False
assert CLASSIFICATION.exists() assert CLASSIFICATION.exists()

160
tests/test_wrap.py Normal file
View file

@ -0,0 +1,160 @@
import copy
import json
from types import SimpleNamespace
import pytest
from secrets_engine.catalog import validate_entry
from secrets_engine.config import Config
from secrets_engine.errors import BackendError, DecisionError, ProvisioningError
from secrets_engine.openbao import OpenBaoClient
from secrets_engine.wrap import normalize_wrap_ttl, write_wrapped_handoff
from tests.test_catalog import VALID
WRAP_TOKEN = "wrap-token-SUPER-SECRET"
WRAP_ACCESSOR = "wrap-accessor-value"
class FakeWrapClient:
def __init__(self):
self.calls = []
def wrap_kv_get(self, mount, path, *, ttl):
from secrets_engine.openbao import WrappedResponse, accessor_fingerprint
self.calls.append(("kv", mount, path, ttl))
return WrappedResponse(
wrap_token=WRAP_TOKEN,
accessor_fingerprint=accessor_fingerprint(WRAP_ACCESSOR),
ttl=ttl,
creation_path=f"{mount}/{path}",
)
def wrap_approle_secret_id(self, role_name, *, ttl):
from secrets_engine.openbao import WrappedResponse, accessor_fingerprint
self.calls.append(("approle", role_name, ttl))
return WrappedResponse(
wrap_token=WRAP_TOKEN,
accessor_fingerprint=accessor_fingerprint(WRAP_ACCESSOR),
ttl=ttl,
creation_path=f"auth/approle/role/{role_name}/secret-id",
)
def test_normalize_wrap_ttl_bounds():
assert normalize_wrap_ttl("15m") == "15m"
assert normalize_wrap_ttl("60s") == "60s"
with pytest.raises(ProvisioningError, match="15m"):
normalize_wrap_ttl("16m")
with pytest.raises(ProvisioningError, match="like"):
normalize_wrap_ttl("15")
def test_wrap_kv_writes_0600_file_and_omits_token_from_result(tmp_path):
out = tmp_path / "wrap.token"
entry = validate_entry(copy.deepcopy(VALID))
result = write_wrapped_handoff(
FakeWrapClient(), entry, out_file=out, ttl="15m"
)
assert out.read_text().strip() == WRAP_TOKEN
assert (out.stat().st_mode & 0o077) == 0
assert result.wrap_handle
assert WRAP_TOKEN not in result.wrap_handle
assert WRAP_TOKEN not in result.out_file
assert WRAP_ACCESSOR not in result.wrap_handle
def test_wrap_rejects_repo_paths():
entry = validate_entry(copy.deepcopy(VALID))
with pytest.raises(ProvisioningError, match="Git worktree"):
write_wrapped_handoff(
FakeWrapClient(),
entry,
out_file=__import__("pathlib").Path("wrap.token"),
ttl="15m",
)
def test_parse_wrap_rejects_unwrapped_secret_payload():
client = OpenBaoClient(addr="http://example.invalid", token="t", bao_bin="bao")
secret_json = json.dumps(
{"data": {"data": {"api_token": "npm_SHOULDNEVERPARSE"}}}
)
with pytest.raises(BackendError, match="was not applied") as raised:
client._parse_wrap_response(
secret_json, ttl="15m", creation_path="secret/x"
)
assert "npm_SHOULDNEVERPARSE" not in str(raised.value)
def test_parse_wrap_accepts_wrap_info_only():
client = OpenBaoClient(addr="http://example.invalid", token="t", bao_bin="bao")
payload = json.dumps(
{
"wrap_info": {
"token": WRAP_TOKEN,
"accessor": WRAP_ACCESSOR,
"ttl": 900,
}
}
)
wrapped = client._parse_wrap_response(
payload, ttl="15m", creation_path="secret/x"
)
assert wrapped.wrap_token == WRAP_TOKEN
assert WRAP_ACCESSOR not in wrapped.accessor_fingerprint
assert WRAP_TOKEN not in repr(wrapped)
def test_wrap_kv_get_uses_wrap_ttl_flag(monkeypatch):
client = OpenBaoClient(addr="http://example.invalid", token="t", bao_bin="bao")
seen = {}
def fake_run(args, stdin=None):
seen["args"] = list(args)
return SimpleNamespace(
returncode=0,
stdout=json.dumps(
{"wrap_info": {"token": WRAP_TOKEN, "accessor": WRAP_ACCESSOR}}
),
stderr="",
)
monkeypatch.setattr(client, "_run", fake_run)
wrapped = client.wrap_kv_get("secret", "test/team/thing", ttl="15m")
assert "-wrap-ttl=15m" in seen["args"]
assert WRAP_TOKEN not in " ".join(seen["args"])
assert wrapped.wrap_token == WRAP_TOKEN
def test_production_wrap_fails_closed(tmp_path, monkeypatch):
from secrets_engine import cli
data = copy.deepcopy(VALID)
data.update(stage="prod", approval={"model": "decision", "decision_ref": "x"})
entry = validate_entry(data)
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
monkeypatch.setattr(
cli.OpenBaoClient,
"resolve",
lambda *_args, **_kwargs: pytest.fail("backend must not be reached"),
)
cfg = Config(
catalog_dir=tmp_path,
policy_dir=tmp_path,
evidence_dir=tmp_path / "evidence",
hub_url="http://127.0.0.1:8000",
bao_addr="http://127.0.0.1:8200",
topic_id="test-topic",
)
args = SimpleNamespace(
catalog_id=entry.id,
out=str(tmp_path / "wrap.token"),
ttl="15m",
bootstrap_token_file=None,
auth="auto",
)
with pytest.raises(DecisionError, match="production action 'wrap'"):
cli.cmd_wrap(cfg, args)