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-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-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-29-T01 | done | — | workplans/ADHOC-2026-06-29.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-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-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-0002 | closed | — | intakes/intakes.md |
| intake | WARDEN-IN-0003 | closed | — | intakes/intakes.md |

View file

@ -24,6 +24,24 @@ actors:
applicability: applicable
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:
type: adm
principals:

View file

@ -97,17 +97,21 @@ on_apply:
# published workload declaration, so routing unknown rose 20 -> 21; resolved unchanged.
# 2026-10-08: platform-supplier-credentials added as a not-applicable path pattern,
# 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:
measured: "2026-10-17"
measured: "2026-10-20"
attribution: self-measured
signing_targets:
resolved: 0
unknown: 3
unknown: 4
not_applicable: 1
routing_lanes:
resolved: 4
unknown: 22
not_applicable: 22
not_applicable: 23
verdict_caching: none
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"
}
},
{
"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",
"type": "ssh-certificate",
@ -210,6 +235,22 @@
"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",
"type": "Agent",
@ -257,6 +298,7 @@
"display_name": "Ops Warden Agents",
"members": [
"agt-codex-interhub-bootstrap",
"agt-custodian",
"agt-state-hub-bridge"
],
"tenant": "tenant:platform"
@ -365,6 +407,53 @@
"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",
"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)."
- "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
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).

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"{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:
@ -186,6 +205,7 @@ class LocalCA(CABackend):
"-I", spec.identity,
"-n", principals_str,
"-V", f"+{spec.ttl_hours}h",
*extension_options(spec),
str(pubkey_copy),
]
result = subprocess.run(cmd, capture_output=True, text=True)

View file

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

View file

@ -3,11 +3,17 @@ from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List
from typing import Dict, List, Optional
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):
@ -21,6 +27,9 @@ class ActorEntry:
principals: List[str]
ttl_hours: int
description: str = ""
# Optional issuance profile: certificate extension allow-list and TTL ceiling.
extensions: Optional[List[str]] = None
max_ttl_hours: Optional[int] = None
@dataclass
@ -35,6 +44,37 @@ class PrincipalsInventory:
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:
"""Load inventory.yaml. Returns empty inventory if path does not exist."""
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]))
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(
name=name,
actor_type=actor_type,
principals=principals,
ttl_hours=ttl,
description=str(data.get("description", "")),
extensions=extensions,
max_ttl_hours=max_ttl,
)
hosts: Dict[str, HostEntry] = {}
@ -95,6 +139,12 @@ def save_inventory(inventory: PrincipalsInventory, path: Path) -> None:
"principals": e.principals,
"ttl_hours": e.ttl_hours,
**({"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()
},

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:
"""Raise ValueError if name does not carry the required prefix for actor_type."""
prefix = ACTOR_PREFIX[actor_type]
@ -56,6 +68,10 @@ class CertSpec:
policy_zone: Optional[str] = None
policy_failure_mode: 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:
if not self.identity:

View file

@ -43,16 +43,20 @@ class VaultCA(CABackend):
)
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:
response = httpx.post(
url,
json={
"public_key": pubkey_text,
"valid_principals": ",".join(spec.principals),
"ttl": f"{spec.ttl_hours}h",
"cert_type": "user",
"key_id": spec.identity,
},
json=payload,
headers={"X-Vault-Token": self._token()},
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.
# 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.
assert len(report["not_applicable"]) == 22
assert len(report["not_applicable"]) == 23
assert {row["lane"] for row in report["resolved"]} == {
"ops-warden-warden-sign-token",
"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"
```
### 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.
- Principal narrower than general ops access (`agt-interhub-bootstrap` not `agt-ops-full`).
- 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.