Per-actor issuance profile in the inventory contract: optional `extensions` allow-list (LocalCA -O clear + -O <ext>; Vault explicit extensions map) and `max_ttl_hours` ceiling enforced against --ttl overrides. Actors without these fields keep backend defaults. agt-custodian: principal agt-custodian, 12h TTL/ceiling, permit-pty only (no agent/port/X11 forwarding, no user-rc), no source-address (unstable workstation egress). Ceremony routing lane agt-custodian-ssh-cert: supervisor issues from the operator account; the agent cannot self-issue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 605711@bnt-lap001 Assistant-Session: 1f5e2256-181f-49ed-b7b1-a1c34bf37a61
153 lines
5.1 KiB
Python
153 lines
5.1 KiB
Python
"""Issuance profile for agt-custodian (CUST-WP-0073-T02, WARDEN-WP-0045).
|
|
|
|
No real signing: ssh-keygen and Vault are mocked; no CA key is read.
|
|
"""
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from warden.ca import CAError, LocalCA, _enforce_ttl, extension_options
|
|
from warden.inventory import InventoryError, load_inventory
|
|
from warden.models import ActorType, CertSpec, MAX_TTL_HOURS
|
|
from warden.routing.catalog import load_catalog
|
|
from warden.vault import VaultCA
|
|
from warden.config import VaultConfig
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SEED = ROOT / "examples" / "inventory.seed.yaml"
|
|
CATALOG = ROOT / "registry" / "routing" / "catalog.yaml"
|
|
|
|
|
|
@pytest.fixture
|
|
def entry():
|
|
return load_inventory(SEED).actors["agt-custodian"]
|
|
|
|
|
|
def _spec(entry, pubkey: Path, ttl=None) -> CertSpec:
|
|
return CertSpec(
|
|
actor_name=entry.name,
|
|
actor_type=entry.actor_type,
|
|
pubkey_path=pubkey,
|
|
ttl_hours=ttl or entry.ttl_hours,
|
|
principals=entry.principals,
|
|
extensions=entry.extensions,
|
|
max_ttl_hours=entry.max_ttl_hours,
|
|
)
|
|
|
|
|
|
def test_profile_principal_and_type(entry):
|
|
assert entry.actor_type is ActorType.AGT
|
|
assert entry.principals == ["agt-custodian"]
|
|
|
|
|
|
def test_profile_ttl_bound(entry):
|
|
assert entry.ttl_hours == 12
|
|
assert entry.max_ttl_hours == 12
|
|
assert entry.max_ttl_hours < MAX_TTL_HOURS[ActorType.AGT]
|
|
|
|
|
|
def test_profile_extensions_pty_only(entry):
|
|
assert entry.extensions == ["permit-pty"]
|
|
for forbidden in (
|
|
"permit-agent-forwarding",
|
|
"permit-port-forwarding",
|
|
"permit-X11-forwarding",
|
|
"permit-user-rc",
|
|
):
|
|
assert forbidden not in entry.extensions
|
|
|
|
|
|
def test_ttl_override_cannot_exceed_profile(entry, tmp_path):
|
|
_enforce_ttl(_spec(entry, tmp_path / "k.pub"))
|
|
with pytest.raises(CAError, match="profile maximum 12h"):
|
|
_enforce_ttl(_spec(entry, tmp_path / "k.pub", ttl=13))
|
|
with pytest.raises(CAError):
|
|
_enforce_ttl(_spec(entry, tmp_path / "k.pub", ttl=24))
|
|
|
|
|
|
def test_local_ca_clears_default_extensions(entry, tmp_path):
|
|
pubkey = tmp_path / "agent.pub"
|
|
pubkey.write_text("ssh-ed25519 AAAA agent\n")
|
|
ca_key = tmp_path / "ca" # placeholder path; ssh-keygen is mocked
|
|
ca_key.write_text("not-a-key")
|
|
captured = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
if "-s" in cmd:
|
|
captured["cmd"] = cmd
|
|
Path(cmd[-1]).with_name("key-cert.pub").write_text("cert\n")
|
|
return MagicMock(returncode=0, stdout="", stderr="")
|
|
return MagicMock(
|
|
returncode=0,
|
|
stdout=(
|
|
'Key ID: "agt-custodian"\n'
|
|
"Valid: from 2026-10-20T08:00:00 to 2026-10-20T20:00:00\n"
|
|
"Principals:\n agt-custodian\n"
|
|
"Critical Options: (none)\n"
|
|
),
|
|
stderr="",
|
|
)
|
|
|
|
with patch("warden.ca.subprocess.run", side_effect=fake_run):
|
|
record = LocalCA(ca_key, tmp_path / "state").sign(_spec(entry, pubkey))
|
|
|
|
cmd = captured["cmd"]
|
|
assert cmd[cmd.index("-n") + 1] == "agt-custodian"
|
|
assert cmd[cmd.index("-V") + 1] == "+12h"
|
|
opts = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-O"]
|
|
assert opts == ["clear", "permit-pty"]
|
|
assert record.principals == ["agt-custodian"]
|
|
|
|
|
|
def test_vault_payload_carries_explicit_extensions(entry, tmp_path):
|
|
pubkey = tmp_path / "agent.pub"
|
|
pubkey.write_text("ssh-ed25519 AAAA agent\n")
|
|
ca = VaultCA(
|
|
VaultConfig(addr="http://vault.invalid", role_map={"agt": "agt-role"}),
|
|
tmp_path / "state",
|
|
)
|
|
with patch.dict("os.environ", {"VAULT_TOKEN": "test"}), patch(
|
|
"warden.vault.httpx.post", side_effect=RuntimeError("stop")
|
|
) as post:
|
|
with pytest.raises(RuntimeError):
|
|
ca.sign(_spec(entry, pubkey))
|
|
payload = post.call_args.kwargs["json"]
|
|
assert payload["valid_principals"] == "agt-custodian"
|
|
assert payload["ttl"] == "12h"
|
|
assert payload["extensions"] == {"permit-pty": ""}
|
|
|
|
|
|
def test_default_actor_keeps_backend_extensions(tmp_path):
|
|
spec = CertSpec(
|
|
actor_name="agt-state-hub-bridge",
|
|
actor_type=ActorType.AGT,
|
|
pubkey_path=tmp_path / "k.pub",
|
|
ttl_hours=24,
|
|
principals=["agt-task-bridge"],
|
|
)
|
|
assert extension_options(spec) == []
|
|
|
|
|
|
def test_inventory_rejects_unknown_extension(tmp_path):
|
|
inv = tmp_path / "inv.yaml"
|
|
inv.write_text(yaml.dump({"actors": {"agt-x": {
|
|
"type": "agt", "extensions": ["permit-everything"]}}}))
|
|
with pytest.raises(InventoryError, match="unknown certificate extension"):
|
|
load_inventory(inv)
|
|
|
|
|
|
def test_inventory_rejects_ttl_above_profile_max(tmp_path):
|
|
inv = tmp_path / "inv.yaml"
|
|
inv.write_text(yaml.dump({"actors": {"agt-x": {
|
|
"type": "agt", "ttl_hours": 20, "max_ttl_hours": 12}}}))
|
|
with pytest.raises(InventoryError, match="exceeds its max_ttl_hours"):
|
|
load_inventory(inv)
|
|
|
|
|
|
def test_catalog_lane_is_attended_not_executable():
|
|
lane = load_catalog(CATALOG).get("agt-custodian-ssh-cert")
|
|
assert lane is not None
|
|
assert lane.lane == "ceremony"
|
|
assert lane.warden_executes is False
|