WARDEN-WP-0045: agt-custodian issuance profile (CUST-WP-0073-T02)

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
This commit is contained in:
tegwick 2026-10-20 13:46:28 +02:00
parent 8c265c702b
commit e62763cba9
14 changed files with 516 additions and 13 deletions

View file

@ -43,6 +43,7 @@
| workplan | WARDEN-WP-0042 | finished | — | workplans/WARDEN-WP-0042-openbao-structure-survey-route.md | | workplan | WARDEN-WP-0042 | finished | — | workplans/WARDEN-WP-0042-openbao-structure-survey-route.md |
| workplan | WARDEN-WP-0043 | finished | — | workplans/WARDEN-WP-0043-platform-supplier-credentials.md | | workplan | WARDEN-WP-0043 | finished | — | workplans/WARDEN-WP-0043-platform-supplier-credentials.md |
| workplan | WARDEN-WP-0044 | active | — | workplans/WARDEN-WP-0044-security-case-follow-through.md | | workplan | WARDEN-WP-0044 | active | — | workplans/WARDEN-WP-0044-security-case-follow-through.md |
| workplan | WARDEN-WP-0045 | active | — | workplans/WARDEN-WP-0045-agt-custodian-issuance-profile.md |
| task | WARDEN-WP-ADHOC-2026-06-27-T01 | done | — | workplans/ADHOC-2026-06-27.md | | task | WARDEN-WP-ADHOC-2026-06-27-T01 | done | — | workplans/ADHOC-2026-06-27.md |
| task | WARDEN-WP-ADHOC-2026-06-29-T01 | done | — | workplans/ADHOC-2026-06-29.md | | task | WARDEN-WP-ADHOC-2026-06-29-T01 | done | — | workplans/ADHOC-2026-06-29.md |
| task | WARDEN-WP-ADHOC-2026-08-11-T01 | done | — | workplans/ADHOC-2026-08-11.md | | task | WARDEN-WP-ADHOC-2026-08-11-T01 | done | — | workplans/ADHOC-2026-08-11.md |
@ -183,6 +184,9 @@
| task | WARDEN-WP-0044-T02 | done | — | workplans/WARDEN-WP-0044-security-case-follow-through.md | | task | WARDEN-WP-0044-T02 | done | — | workplans/WARDEN-WP-0044-security-case-follow-through.md |
| task | WARDEN-WP-0044-T03 | progress | — | workplans/WARDEN-WP-0044-security-case-follow-through.md | | task | WARDEN-WP-0044-T03 | progress | — | workplans/WARDEN-WP-0044-security-case-follow-through.md |
| task | WARDEN-WP-0044-T04 | wait | — | workplans/WARDEN-WP-0044-security-case-follow-through.md | | task | WARDEN-WP-0044-T04 | wait | — | workplans/WARDEN-WP-0044-security-case-follow-through.md |
| task | WARDEN-WP-0045-T01 | done | — | workplans/WARDEN-WP-0045-agt-custodian-issuance-profile.md |
| task | WARDEN-WP-0045-T02 | done | — | workplans/WARDEN-WP-0045-agt-custodian-issuance-profile.md |
| task | WARDEN-WP-0045-T03 | wait | — | workplans/WARDEN-WP-0045-agt-custodian-issuance-profile.md |
| intake | WARDEN-IN-0001 | closed | — | intakes/intakes.md | | intake | WARDEN-IN-0001 | closed | — | intakes/intakes.md |
| intake | WARDEN-IN-0002 | closed | — | intakes/intakes.md | | intake | WARDEN-IN-0002 | closed | — | intakes/intakes.md |
| intake | WARDEN-IN-0003 | closed | — | intakes/intakes.md | | intake | WARDEN-IN-0003 | closed | — | intakes/intakes.md |

View file

@ -24,6 +24,24 @@ actors:
applicability: applicable applicability: applicable
workload_id: codex-interhub-bootstrap workload_id: codex-interhub-bootstrap
# CUST-WP-0073-T02 / WARDEN-WP-0045: supervised interactive agent account
# (Codex, Claude Code, Grok Build) on host account custodian-agt @ Railiance01.
# Issued only by the supervisor from the operator account; the agent distro
# (custodian-agents) holds no warden config, CA key or Vault token.
# No source-address: workstation egress IP is not stable (see wiki).
agt-custodian:
type: agt
principals:
- agt-custodian
ttl_hours: 12
max_ttl_hours: 12
extensions:
- permit-pty
description: "Supervised interactive custodian agent (custodian-agt on Railiance01)"
zone_subject:
applicability: applicable
workload_id: custodian-agents
adm-example: adm-example:
type: adm type: adm
principals: principals:

View file

@ -97,17 +97,21 @@ on_apply:
# published workload declaration, so routing unknown rose 20 -> 21; resolved unchanged. # published workload declaration, so routing unknown rose 20 -> 21; resolved unchanged.
# 2026-10-08: platform-supplier-credentials added as a not-applicable path pattern, # 2026-10-08: platform-supplier-credentials added as a not-applicable path pattern,
# so routing not_applicable rose 16 -> 17. # so routing not_applicable rose 16 -> 17.
# 2026-10-20: agt-custodian-ssh-cert added as a not-applicable ceremony lane
# (WARDEN-WP-0045), so routing not_applicable rose 22 -> 23; signing target
# agt-custodian names workload custodian-agents without a published resolution,
# so signing unknown rose 3 -> 4.
classification_coverage: classification_coverage:
measured: "2026-10-17" measured: "2026-10-20"
attribution: self-measured attribution: self-measured
signing_targets: signing_targets:
resolved: 0 resolved: 0
unknown: 3 unknown: 4
not_applicable: 1 not_applicable: 1
routing_lanes: routing_lanes:
resolved: 4 resolved: 4
unknown: 22 unknown: 22
not_applicable: 22 not_applicable: 23
verdict_caching: none verdict_caching: none
input_claim_caching: "registry/flex-auth/production_registry_snapshot.json, rebuilt by scripts/build_flex_auth_registry.py" input_claim_caching: "registry/flex-auth/production_registry_snapshot.json, rebuilt by scripts/build_flex_auth_registry.py"

View file

@ -108,6 +108,31 @@
"security_zone_reason": "workload_resolution_absent" "security_zone_reason": "workload_resolution_absent"
} }
}, },
{
"id": "ssh-cert:actor/agt-custodian",
"type": "ssh-certificate",
"labels": [
"ssh-signing",
"agt"
],
"owner": "team:platform-security",
"attributes": {
"actor_id": "agt-custodian",
"actor_type": "agt",
"allowed_subjects": [
"agt-custodian",
"iam:agt-custodian"
],
"allowed_principals": [
"agt-custodian"
],
"max_ttl_hours": 12,
"workload_id": "custodian-agents",
"security_zone": "unknown",
"security_zone_admission": "unknown",
"security_zone_reason": "workload_resolution_absent"
}
},
{ {
"id": "ssh-cert:actor/agt-state-hub-bridge", "id": "ssh-cert:actor/agt-state-hub-bridge",
"type": "ssh-certificate", "type": "ssh-certificate",
@ -210,6 +235,22 @@
"actor_type": "agt" "actor_type": "agt"
} }
}, },
{
"id": "agt-custodian",
"type": "Agent",
"display_name": "Supervised interactive custodian agent (custodian-agt on Railiance01)",
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
],
"groups": [
"group:ops-warden-agents"
],
"tenant": "tenant:platform",
"metadata": {
"actor_type": "agt"
}
},
{ {
"id": "agt-state-hub-bridge", "id": "agt-state-hub-bridge",
"type": "Agent", "type": "Agent",
@ -257,6 +298,7 @@
"display_name": "Ops Warden Agents", "display_name": "Ops Warden Agents",
"members": [ "members": [
"agt-codex-interhub-bootstrap", "agt-codex-interhub-bootstrap",
"agt-custodian",
"agt-state-hub-bridge" "agt-state-hub-bridge"
], ],
"tenant": "tenant:platform" "tenant": "tenant:platform"
@ -365,6 +407,53 @@
"access_path": "mediated" "access_path": "mediated"
} }
}, },
{
"id": "rel:agt-custodian-sign-agt-custodian",
"system": "ops-warden",
"subject": "group:ops-warden-agents",
"relation": "signer",
"object": "ssh-cert:actor/agt-custodian",
"tenant": "tenant:platform",
"conditions": [
"TimeLimited",
"Logged"
],
"caring": {
"id": "descriptor:ops-warden-agt-signer",
"profile": "caring-0.4.0-rc2",
"subject_type": "Group",
"organization_relation": "ServiceProvider",
"canonical_role": "Operator",
"scope": {
"level": "Resource",
"id": "ssh-cert:actor/agt-custodian",
"tenant": "tenant:platform",
"resource": "ssh-cert:actor/agt-custodian"
},
"planes": [
"Identity",
"Secret",
"Audit"
],
"capabilities": [
"Use",
"Operate",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"conditions": [
"TimeLimited",
"Logged"
],
"restrictions": [
"PrivilegeEscalationBlocked",
"SecretAccessBlocked"
],
"access_path": "mediated"
}
},
{ {
"id": "rel:agt-state-hub-bridge-sign-agt-state-hub-bridge", "id": "rel:agt-state-hub-bridge-sign-agt-state-hub-bridge",
"system": "ops-warden", "system": "ops-warden",

View file

@ -83,6 +83,30 @@ entries:
- "Sign: `warden sign <actor> --pubkey <path>` — cert is written to stdout (the cert_command contract)." - "Sign: `warden sign <actor> --pubkey <path>` — cert is written to stdout (the cert_command contract)."
- "TTL is enforced per actor type: adm 48h / agt 24h / atm 8h. No long-lived keys." - "TTL is enforced per actor type: adm 48h / agt 24h / atm 8h. No long-lived keys."
- id: agt-custodian-ssh-cert
title: Supervisor-issued SSH certificate for the custodian agent account (agt-custodian)
# Attended issuance pointer (CUST-WP-0073-T02, WARDEN-WP-0045). The cert is a
# public artifact, but issuance must stay outside the agent account: the agent
# never runs warden sign for itself, so this is a ceremony, not an SSH exec lane.
risk: standard
workload_ref:
applicability: not-applicable
reason: "Supervisor act that issues a cert for the custodian-agt host account; no workload credential is retrieved."
need_keywords: [agt-custodian, custodian-agt, custodian-agents, custodian, agent, supervised, interactive, codex, claude, grok, ssh, certificate, cert, railiance01, renew, reissue]
owner_repo: ops-warden
subsystem: ops-warden (supervisor operator account) + railiance-infra principals
warden_executes: false
wiki_ref: wiki/ActorInventoryPatterns.md#supervised-interactive-agent-account-agt-custodian
canon_ref: the-custodian/workplans/CUST-WP-0073-agent-credential-separation.md
reviewed: "2026-10-20"
status: active
delegation:
mode: native
intended_owner: ops-warden
reviewed: "2026-10-20"
verified: unverified
lane: ceremony
- id: ops-warden-warden-sign-token - id: ops-warden-warden-sign-token
title: Scoped OpenBao token for ops-warden SSH signing (warden-sign) title: Scoped OpenBao token for ops-warden SSH signing (warden-sign)
# A scoped VAULT_TOKEN is a credential in its own right. Graded on what the value is, not on whether ops-warden currently proxies it (WARDEN-WP-0032-T05). # A scoped VAULT_TOKEN is a credential in its own right. Graded on what the value is, not on whether ops-warden currently proxies it (WARDEN-WP-0032-T05).

View file

@ -33,6 +33,25 @@ def _enforce_ttl(spec: CertSpec) -> None:
f"TTL {spec.ttl_hours}h exceeds maximum {max_h}h for actor type " f"TTL {spec.ttl_hours}h exceeds maximum {max_h}h for actor type "
f"{spec.actor_type.value!r} (AccessManagementDirective §2)" f"{spec.actor_type.value!r} (AccessManagementDirective §2)"
) )
if spec.max_ttl_hours is not None and spec.ttl_hours > spec.max_ttl_hours:
raise CAError(
f"TTL {spec.ttl_hours}h exceeds actor {spec.actor_name!r} profile "
f"maximum {spec.max_ttl_hours}h"
)
def extension_options(spec: CertSpec) -> List[str]:
"""ssh-keygen -O arguments for the actor's extension allow-list.
``None`` keeps ssh-keygen defaults (all permit-* extensions). A list clears
every default extension and permits only the listed ones.
"""
if spec.extensions is None:
return []
opts = ["-O", "clear"]
for ext in spec.extensions:
opts += ["-O", ext]
return opts
def _evict_cert(actor_name: str, state_dir: Path) -> None: def _evict_cert(actor_name: str, state_dir: Path) -> None:
@ -186,6 +205,7 @@ class LocalCA(CABackend):
"-I", spec.identity, "-I", spec.identity,
"-n", principals_str, "-n", principals_str,
"-V", f"+{spec.ttl_hours}h", "-V", f"+{spec.ttl_hours}h",
*extension_options(spec),
str(pubkey_copy), str(pubkey_copy),
] ]
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)

View file

@ -165,6 +165,8 @@ def sign(
ttl_hours=ttl or entry.ttl_hours, ttl_hours=ttl or entry.ttl_hours,
principals=entry.principals, principals=entry.principals,
identity=actor_name, identity=actor_name,
extensions=entry.extensions,
max_ttl_hours=entry.max_ttl_hours,
) )
ca = _get_ca(cfg) ca = _get_ca(cfg)
@ -225,6 +227,8 @@ def issue(
ttl_hours=ttl or entry.ttl_hours, ttl_hours=ttl or entry.ttl_hours,
principals=entry.principals, principals=entry.principals,
identity=actor_name, identity=actor_name,
extensions=entry.extensions,
max_ttl_hours=entry.max_ttl_hours,
) )
try: try:
_apply_policy_gate(cfg, spec) _apply_policy_gate(cfg, spec)

View file

@ -3,11 +3,17 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List, Optional
import yaml import yaml
from warden.models import ActorType, DEFAULT_TTL_HOURS, validate_actor_name from warden.models import (
ActorType,
DEFAULT_TTL_HOURS,
MAX_TTL_HOURS,
SSH_USER_EXTENSIONS,
validate_actor_name,
)
class InventoryError(Exception): class InventoryError(Exception):
@ -21,6 +27,9 @@ class ActorEntry:
principals: List[str] principals: List[str]
ttl_hours: int ttl_hours: int
description: str = "" description: str = ""
# Optional issuance profile: certificate extension allow-list and TTL ceiling.
extensions: Optional[List[str]] = None
max_ttl_hours: Optional[int] = None
@dataclass @dataclass
@ -35,6 +44,37 @@ class PrincipalsInventory:
hosts: Dict[str, HostEntry] = field(default_factory=dict) hosts: Dict[str, HostEntry] = field(default_factory=dict)
def _parse_extensions(name: str, raw: object) -> Optional[List[str]]:
if raw is None:
return None
if not isinstance(raw, list):
raise InventoryError(f"Actor {name!r} extensions must be a list")
extensions = [str(e) for e in raw]
unknown = sorted(set(extensions) - SSH_USER_EXTENSIONS)
if unknown:
raise InventoryError(
f"Actor {name!r} has unknown certificate extension(s): {unknown}"
)
return extensions
def _parse_max_ttl(
name: str, actor_type: ActorType, ttl: int, raw: object
) -> Optional[int]:
if raw is None:
return None
max_ttl = int(raw) # type: ignore[arg-type]
if max_ttl <= 0 or max_ttl > MAX_TTL_HOURS[actor_type]:
raise InventoryError(
f"Actor {name!r} max_ttl_hours {max_ttl} must be 1..{MAX_TTL_HOURS[actor_type]}"
)
if ttl > max_ttl:
raise InventoryError(
f"Actor {name!r} ttl_hours {ttl} exceeds its max_ttl_hours {max_ttl}"
)
return max_ttl
def load_inventory(path: Path) -> PrincipalsInventory: def load_inventory(path: Path) -> PrincipalsInventory:
"""Load inventory.yaml. Returns empty inventory if path does not exist.""" """Load inventory.yaml. Returns empty inventory if path does not exist."""
if not path.exists(): if not path.exists():
@ -65,12 +105,16 @@ def load_inventory(path: Path) -> PrincipalsInventory:
ttl = int(data.get("ttl_hours", DEFAULT_TTL_HOURS[actor_type])) ttl = int(data.get("ttl_hours", DEFAULT_TTL_HOURS[actor_type]))
principals = list(data.get("principals") or [name]) principals = list(data.get("principals") or [name])
extensions = _parse_extensions(name, data.get("extensions"))
max_ttl = _parse_max_ttl(name, actor_type, ttl, data.get("max_ttl_hours"))
actors[name] = ActorEntry( actors[name] = ActorEntry(
name=name, name=name,
actor_type=actor_type, actor_type=actor_type,
principals=principals, principals=principals,
ttl_hours=ttl, ttl_hours=ttl,
description=str(data.get("description", "")), description=str(data.get("description", "")),
extensions=extensions,
max_ttl_hours=max_ttl,
) )
hosts: Dict[str, HostEntry] = {} hosts: Dict[str, HostEntry] = {}
@ -95,6 +139,12 @@ def save_inventory(inventory: PrincipalsInventory, path: Path) -> None:
"principals": e.principals, "principals": e.principals,
"ttl_hours": e.ttl_hours, "ttl_hours": e.ttl_hours,
**({"description": e.description} if e.description else {}), **({"description": e.description} if e.description else {}),
**({"extensions": e.extensions} if e.extensions is not None else {}),
**(
{"max_ttl_hours": e.max_ttl_hours}
if e.max_ttl_hours is not None
else {}
),
} }
for name, e in inventory.actors.items() for name, e in inventory.actors.items()
}, },

View file

@ -32,6 +32,18 @@ ACTOR_PREFIX: dict[ActorType, str] = {
} }
# OpenSSH user-certificate extensions (PROTOCOL.certkeys). An actor profile may
# restrict a certificate to a subset; ``None`` keeps the backend default.
SSH_USER_EXTENSIONS: frozenset[str] = frozenset({
"no-touch-required",
"permit-X11-forwarding",
"permit-agent-forwarding",
"permit-port-forwarding",
"permit-pty",
"permit-user-rc",
})
def validate_actor_name(name: str, actor_type: ActorType) -> None: def validate_actor_name(name: str, actor_type: ActorType) -> None:
"""Raise ValueError if name does not carry the required prefix for actor_type.""" """Raise ValueError if name does not carry the required prefix for actor_type."""
prefix = ACTOR_PREFIX[actor_type] prefix = ACTOR_PREFIX[actor_type]
@ -56,6 +68,10 @@ class CertSpec:
policy_zone: Optional[str] = None policy_zone: Optional[str] = None
policy_failure_mode: Optional[str] = None policy_failure_mode: Optional[str] = None
policy_outcome: Optional[str] = None policy_outcome: Optional[str] = None
# Per-actor issuance profile (CUST-WP-0073-T02): explicit extension allow-list
# (None = backend default) and a TTL ceiling tighter than the type maximum.
extensions: Optional[List[str]] = None
max_ttl_hours: Optional[int] = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.identity: if not self.identity:

View file

@ -43,16 +43,20 @@ class VaultCA(CABackend):
) )
url = f"{self._cfg.addr}/v1/{self._cfg.mount}/sign/{role}" url = f"{self._cfg.addr}/v1/{self._cfg.mount}/sign/{role}"
payload = {
"public_key": pubkey_text,
"valid_principals": ",".join(spec.principals),
"ttl": f"{spec.ttl_hours}h",
"cert_type": "user",
"key_id": spec.identity,
}
if spec.extensions is not None:
# Explicit allow-list replaces the role's default_extensions.
payload["extensions"] = {ext: "" for ext in spec.extensions}
try: try:
response = httpx.post( response = httpx.post(
url, url,
json={ json=payload,
"public_key": pubkey_text,
"valid_principals": ",".join(spec.principals),
"ttl": f"{spec.ttl_hours}h",
"cert_type": "user",
"key_id": spec.identity,
},
headers={"X-Vault-Token": self._token()}, headers={"X-Vault-Token": self._token()},
timeout=10.0, timeout=10.0,
) )

View file

@ -0,0 +1,153 @@
"""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

View file

@ -33,7 +33,7 @@ def test_repo_catalog_uses_only_explicit_workload_references():
# per-case generation lane; each case's consumer gets its own entry. # per-case generation lane; each case's consumer gets its own entry.
# not_applicable 22 since 2026-10-17: the four CATALOG-1 draft lanes # not_applicable 22 since 2026-10-17: the four CATALOG-1 draft lanes
# (nk48 1.3 owner review) are per-case lanes like the one above. # (nk48 1.3 owner review) are per-case lanes like the one above.
assert len(report["not_applicable"]) == 22 assert len(report["not_applicable"]) == 23
assert {row["lane"] for row in report["resolved"]} == { assert {row["lane"] for row in report["resolved"]} == {
"ops-warden-warden-sign-token", "ops-warden-warden-sign-token",
"issue-core-ingestion-api-key", "issue-core-ingestion-api-key",

View file

@ -58,6 +58,40 @@ agt-codex-interhub-bootstrap:
description: "Short-lived agent access for Inter-Hub bootstrap execution" description: "Short-lived agent access for Inter-Hub bootstrap execution"
``` ```
### Supervised interactive agent account (`agt-custodian`)
CUST-WP-0073-T02 / WARDEN-WP-0045. One principal for the dedicated host account
`custodian-agt` on Railiance01, shared by the Codex, Claude Code and Grok Build
harnesses running in the isolated workstation WSL distro `custodian-agents`.
```yaml
agt-custodian:
type: agt
principals: [agt-custodian]
ttl_hours: 12
max_ttl_hours: 12 # --ttl cannot raise it to the 24 h agt maximum
extensions: [permit-pty] # clears agent/port/X11 forwarding and user-rc
```
- **TTL 12 h.** Between the attended bootstrap profile (2 h) and the agt type
maximum (24 h): one supervised working session, so a leaked cert expires by the
next day and every new session needs a fresh supervisor issuance. The profile
ceiling (`max_ttl_hours`) stops a `--ttl` override from lengthening it.
- **Extensions.** `permit-pty` only — interactive harness shells need a pty.
Agent, port and X11 forwarding and `~/.ssh/rc` are not granted (`-O clear`
locally; explicit `extensions` map on the Vault role, which must allow
`permit-pty`).
- **No `source-address`.** The workstation egress IP is not stable (residential
dynamic address, WSL NAT), so a pinned address would break issuance silently.
Revisit if agent traffic moves to a fixed egress (VPN or bastion).
- **No self-issuance.** The supervisor (Bernd) signs from the operator account
with `warden sign agt-custodian --pubkey <agent-key>.pub > <agent-key>-cert.pub`
and places the cert into the `custodian-agents` distro. That distro gets no
warden config, CA key, Vault token or `warden-sign` lane credential, so the
signing path is not reachable from the agent account.
- Host mapping (`AuthorizedPrincipalsFile` for `custodian-agt`) is owned by
railiance-infra.
- Prefer **1–2 h TTL** for bootstrap; never multi-day agent certs. - Prefer **1–2 h TTL** for bootstrap; never multi-day agent certs.
- Principal narrower than general ops access (`agt-interhub-bootstrap` not `agt-ops-full`). - Principal narrower than general ops access (`agt-interhub-bootstrap` not `agt-ops-full`).
- Remove or disable actor when lane is retired. - Remove or disable actor when lane is retired.

View file

@ -0,0 +1,83 @@
---
id: WARDEN-WP-0045
type: workplan
title: "Issuance profile for the supervised custodian agent principal (agt-custodian)"
domain: infotech
repo: ops-warden
status: active
flavor: implementation
owner: claude-code
created: "2026-10-20"
updated: "2026-10-20"
depends_on: []
related_repos:
- the-custodian
- railiance-infra
- railiance-platform
---
# WARDEN-WP-0045 — Issuance profile for agt-custodian
ops-warden's share of owner task `CUST-WP-0073-T02` (the-custodian,
`workplans/CUST-WP-0073-agent-credential-separation.md`).
## Why
Founder decision 2026-10-20: a new agent principal `agt-custodian` for the
dedicated host account `custodian-agt` on Railiance01. One account and principal
serves the Codex, Claude Code and Grok Build harnesses. The harnesses run in the
isolated workstation WSL distro `custodian-agents`. Certificates are short-lived.
The supervisor (Bernd) re-issues them from his operator account and places them
in the agent distro. The agent must never be able to self-issue.
Before this plan the issuance contract had no per-actor certificate extensions.
Every cert got the backend defaults: agent, port and X11 forwarding, pty and
user-rc. A `--ttl` override could also reach the 24 h agt type maximum whatever
the actor declared.
## Tasks
```task
id: WARDEN-WP-0045-T01
status: done
priority: high
```
Extend the issuance contract. An inventory actor may now declare `extensions`,
an allow-list of OpenSSH user-cert extensions. LocalCA issues `-O clear` plus
`-O <ext>`, and Vault receives an explicit `extensions` map. An actor may also
declare `max_ttl_hours`, a ceiling below the type maximum that `_enforce_ttl`
applies to `--ttl` overrides. Actors without these fields behave as before.
```task
id: WARDEN-WP-0045-T02
status: done
priority: high
```
Add the `agt-custodian` profile to `examples/inventory.seed.yaml`: principal
`agt-custodian`, TTL 12 h with a ceiling of 12 h, and `permit-pty` only. Pty
stays because the harness shells are interactive. There is no `source-address`
because the workstation egress IP is not stable. Add the ceremony routing lane
`agt-custodian-ssh-cert` (the supervisor issues; the agent never runs
`warden sign`) and document it in `wiki/ActorInventoryPatterns.md`. Rebuild the
flex-auth snapshot, regenerate the high-risk paths and update the pep-stance
coverage counts. Tests: `tests/test_agt_custodian_profile.py`.
```task
id: WARDEN-WP-0045-T03
status: wait
priority: high
needs_human: true
blocking_reason: "Supervisor must add agt-custodian to the operator inventory, confirm the Vault agt-role allows user agt-custodian and extension permit-pty (railiance-platform), and do the first attended issuance after railiance-infra maps custodian-agt."
```
Do the operator rollout. Copy the `agt-custodian` block into the operator's
`~/.config/warden/inventory.yaml`, out of the agent's reach. railiance-platform
confirms that `ssh/roles/agt-role` has `allowed_users` ⊇ `agt-custodian` and
`allowed_extensions` ⊇ `permit-pty`. railiance-infra deploys the principal
mapping for `custodian-agt`. The supervisor then issues the first cert with
`warden sign agt-custodian --pubkey <agent-key>.pub > <agent-key>-cert.pub`,
places it in the `custodian-agents` distro, and runs `ssh-keygen -L` to check
the principal, the 12 h validity and that only `permit-pty` is present. Verify
that the agent distro holds no warden config, CA key or Vault token.