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-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 |
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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.
|
# 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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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