diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 8b5226a..f284f97 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/examples/inventory.seed.yaml b/examples/inventory.seed.yaml index 49ce69f..4c12450 100644 --- a/examples/inventory.seed.yaml +++ b/examples/inventory.seed.yaml @@ -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: diff --git a/pep-stance.yaml b/pep-stance.yaml index 40d69ca..f441f88 100644 --- a/pep-stance.yaml +++ b/pep-stance.yaml @@ -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" diff --git a/registry/flex-auth/production_registry_snapshot.json b/registry/flex-auth/production_registry_snapshot.json index 7ebb0af..d0d6b03 100644 --- a/registry/flex-auth/production_registry_snapshot.json +++ b/registry/flex-auth/production_registry_snapshot.json @@ -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", diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 7f4735e..14f0d48 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -83,6 +83,30 @@ entries: - "Sign: `warden sign --pubkey ` — 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). diff --git a/src/warden/ca.py b/src/warden/ca.py index 68aefd8..b3aaf02 100644 --- a/src/warden/ca.py +++ b/src/warden/ca.py @@ -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) diff --git a/src/warden/cli.py b/src/warden/cli.py index d6c13d5..6987f0c 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -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) diff --git a/src/warden/inventory.py b/src/warden/inventory.py index ef60ba3..40904bd 100644 --- a/src/warden/inventory.py +++ b/src/warden/inventory.py @@ -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() }, diff --git a/src/warden/models.py b/src/warden/models.py index fcc0aec..d1878fe 100644 --- a/src/warden/models.py +++ b/src/warden/models.py @@ -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: diff --git a/src/warden/vault.py b/src/warden/vault.py index 2668688..e8cc00e 100644 --- a/src/warden/vault.py +++ b/src/warden/vault.py @@ -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, ) diff --git a/tests/test_agt_custodian_profile.py b/tests/test_agt_custodian_profile.py new file mode 100644 index 0000000..2f822af --- /dev/null +++ b/tests/test_agt_custodian_profile.py @@ -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 diff --git a/tests/test_workload_join.py b/tests/test_workload_join.py index 313be70..bbca41f 100644 --- a/tests/test_workload_join.py +++ b/tests/test_workload_join.py @@ -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", diff --git a/wiki/ActorInventoryPatterns.md b/wiki/ActorInventoryPatterns.md index 23d8c72..0ec6263 100644 --- a/wiki/ActorInventoryPatterns.md +++ b/wiki/ActorInventoryPatterns.md @@ -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 .pub > -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. diff --git a/workplans/WARDEN-WP-0045-agt-custodian-issuance-profile.md b/workplans/WARDEN-WP-0045-agt-custodian-issuance-profile.md new file mode 100644 index 0000000..4d9126a --- /dev/null +++ b/workplans/WARDEN-WP-0045-agt-custodian-issuance-profile.md @@ -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 `, 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 .pub > -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.