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:
parent
8c265c702b
commit
e62763cba9
14 changed files with 516 additions and 13 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
153
tests/test_agt_custodian_profile.py
Normal file
153
tests/test_agt_custodian_profile.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
83
workplans/WARDEN-WP-0045-agt-custodian-issuance-profile.md
Normal file
83
workplans/WARDEN-WP-0045-agt-custodian-issuance-profile.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue