Initial Commit

This commit is contained in:
2026-03-28 00:45:43 +00:00
parent a436a7569d
commit 5ae6b988aa
23 changed files with 2400 additions and 0 deletions

3
src/warden/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""OpsWarden — SSH CA and certificate lifecycle manager."""
__version__ = "0.1.0"

164
src/warden/ca.py Normal file
View File

@@ -0,0 +1,164 @@
"""CA backends for OpsWarden: LocalCA (ssh-keygen) and abstract base."""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from warden.models import CertRecord, CertSpec
class CAError(Exception):
"""Raised when a CA operation fails."""
class CABackend(ABC):
@abstractmethod
def sign(self, spec: CertSpec) -> CertRecord:
"""Sign the public key in spec and return a CertRecord."""
...
def parse_cert_metadata(cert_path: Path) -> dict:
"""Parse ssh-keygen -L output into identity, valid_before, and principals.
Note: ssh-keygen displays timestamps without explicit timezone; we treat them
as UTC, consistent with how ssh-keygen internally stores certificate validity.
"""
result = subprocess.run(
["ssh-keygen", "-L", "-f", str(cert_path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise CAError(f"ssh-keygen -L failed: {result.stderr.strip()}")
identity: Optional[str] = None
valid_before: Optional[datetime] = None
principals: List[str] = []
in_principals = False
for line in result.stdout.splitlines():
stripped = line.strip()
if stripped.startswith("Key ID:"):
# Key ID: "agt-state-hub-bridge"
raw = stripped.split(":", 1)[1].strip()
identity = raw.strip('"')
elif stripped.startswith("Valid:"):
# Valid: from 2026-03-28T10:00:00 to 2026-03-29T10:00:00
parts = stripped.split(" to ", 1)
if len(parts) == 2:
ts_str = parts[1].strip()
try:
dt = datetime.fromisoformat(ts_str)
valid_before = dt.replace(tzinfo=timezone.utc)
except ValueError:
pass
elif stripped == "Principals:":
in_principals = True
elif in_principals:
if stripped and not stripped.endswith(":") and stripped != "(none)":
principals.append(stripped)
else:
in_principals = False
if valid_before is None:
raise CAError(
f"Could not parse valid_before from cert at {cert_path}. "
f"Ensure the cert has a valid TTL."
)
return {
"identity": identity or "",
"valid_before": valid_before,
"principals": principals,
}
class LocalCA(CABackend):
"""File-based CA using ssh-keygen. Requires the CA private key on disk."""
def __init__(self, ca_key: Path, state_dir: Path) -> None:
self._ca_key = Path(os.path.expanduser(str(ca_key)))
self._state_dir = Path(os.path.expanduser(str(state_dir)))
def sign(self, spec: CertSpec) -> CertRecord:
"""Sign the public key in spec. Returns a CertRecord; cert saved to state_dir."""
pubkey = Path(os.path.expanduser(str(spec.pubkey_path)))
if not pubkey.exists():
raise CAError(f"Public key not found: {pubkey}")
if not self._ca_key.exists():
raise CAError(f"CA key not found: {self._ca_key}")
principals_str = ",".join(spec.principals)
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
pubkey_copy = tmpdir_path / "key.pub"
shutil.copy2(pubkey, pubkey_copy)
# ssh-keygen -s writes cert to <input_stem>-cert.pub
cert_path_tmp = tmpdir_path / "key-cert.pub"
cmd = [
"ssh-keygen",
"-s", str(self._ca_key),
"-I", spec.identity,
"-n", principals_str,
"-V", f"+{spec.ttl_hours}h",
str(pubkey_copy),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise CAError(f"Signing failed: {result.stderr.strip()}")
if not cert_path_tmp.exists():
raise CAError(
f"Expected cert not written after signing: {cert_path_tmp}. "
f"ssh-keygen stderr: {result.stderr.strip()}"
)
meta = parse_cert_metadata(cert_path_tmp)
self._state_dir.mkdir(parents=True, exist_ok=True)
dest = self._state_dir / f"{spec.actor_name}-cert.pub"
shutil.copy2(cert_path_tmp, dest)
return CertRecord(
identity=meta["identity"] or spec.identity,
valid_before=meta["valid_before"],
cert_path=dest,
signed_at=datetime.now(timezone.utc),
principals=meta["principals"],
actor_name=spec.actor_name,
)
def generate_keypair(self, actor_name: str) -> tuple[Path, Path]:
"""Generate an ed25519 keypair for an actor.
Returns (privkey_path, pubkey_path). Overwrites existing files.
"""
key_dir = self._state_dir / "keys"
key_dir.mkdir(parents=True, exist_ok=True)
privkey = key_dir / f"{actor_name}_ed25519"
pubkey = key_dir / f"{actor_name}_ed25519.pub"
for p in (privkey, pubkey):
if p.exists():
p.unlink()
cmd = [
"ssh-keygen", "-t", "ed25519",
"-f", str(privkey),
"-N", "", # no passphrase
"-C", actor_name,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise CAError(f"Key generation failed: {result.stderr.strip()}")
return privkey, pubkey

397
src/warden/cli.py Normal file
View File

@@ -0,0 +1,397 @@
"""OpsWarden CLI."""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, List, Optional
import typer
from rich.console import Console
from rich.table import Table
from warden.ca import CAError, LocalCA, parse_cert_metadata
from warden.config import ConfigError, WardenConfig, load_config
from warden.inventory import ActorEntry, InventoryError, PrincipalsInventory, load_inventory, save_inventory
from warden.models import ActorType, CertSpec, DEFAULT_TTL_HOURS, validate_actor_name
from warden.scorecard import run_scorecard
app = typer.Typer(
help="OpsWarden — SSH CA and certificate lifecycle manager",
no_args_is_help=True,
)
inventory_app = typer.Typer(help="Manage principals inventory", no_args_is_help=True)
app.add_typer(inventory_app, name="inventory")
console = Console()
err = Console(stderr=True)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _load_cfg() -> WardenConfig:
try:
return load_config()
except ConfigError as e:
err.print(f"[red]Config error:[/red] {e}")
raise typer.Exit(1)
def _load_inventory(cfg: WardenConfig) -> PrincipalsInventory:
try:
return load_inventory(cfg.inventory_path)
except InventoryError as e:
err.print(f"[red]Inventory error:[/red] {e}")
raise typer.Exit(1)
def _get_ca(cfg: WardenConfig):
if cfg.backend == "vault":
from warden.vault import VaultCA
return VaultCA(cfg.vault, cfg.state_dir)
return LocalCA(cfg.ca_key, cfg.state_dir)
# ---------------------------------------------------------------------------
# warden sign
# ---------------------------------------------------------------------------
@app.command()
def sign(
actor_name: Annotated[str, typer.Argument(help="Actor name (e.g. agt-state-hub-bridge)")],
pubkey: Annotated[Path, typer.Option("--pubkey", help="Path to actor's public key file")],
ttl: Annotated[Optional[int], typer.Option("--ttl", help="Override TTL in hours")] = None,
) -> None:
"""Sign a public key for the given actor. Writes cert text to stdout.
This is the cert_command interface: ops-bridge calls this and uses stdout
as the certificate passed to SSH alongside the private key.
"""
cfg = _load_cfg()
inventory = _load_inventory(cfg)
entry = inventory.actors.get(actor_name)
if entry is None:
err.print(
f"[red]Actor {actor_name!r} not found in inventory.[/red] "
f"Add it with: warden inventory add"
)
raise typer.Exit(1)
spec = CertSpec(
actor_name=actor_name,
actor_type=entry.actor_type,
pubkey_path=pubkey,
ttl_hours=ttl or entry.ttl_hours,
principals=entry.principals,
identity=actor_name,
)
ca = _get_ca(cfg)
try:
record = ca.sign(spec)
except CAError as e:
err.print(f"[red]Signing failed:[/red] {e}")
raise typer.Exit(1)
# cert_command interface: write cert text to stdout only
print(record.cert_path.read_text().strip())
# ---------------------------------------------------------------------------
# warden issue
# ---------------------------------------------------------------------------
@app.command()
def issue(
actor_name: Annotated[str, typer.Argument(help="Actor name")],
ttl: Annotated[Optional[int], typer.Option("--ttl", help="Override TTL in hours")] = None,
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Generate a new keypair and sign it for the given actor.
Only supported with the local backend. Outputs keypair + cert paths and metadata.
"""
cfg = _load_cfg()
if cfg.backend != "local":
err.print("[red]warden issue is only supported with the local backend.[/red]")
raise typer.Exit(1)
inventory = _load_inventory(cfg)
entry = inventory.actors.get(actor_name)
if entry is None:
err.print(f"[red]Actor {actor_name!r} not found in inventory.[/red]")
raise typer.Exit(1)
ca = LocalCA(cfg.ca_key, cfg.state_dir)
try:
privkey_path, pubkey_path = ca.generate_keypair(actor_name)
except CAError as e:
err.print(f"[red]Key generation failed:[/red] {e}")
raise typer.Exit(1)
spec = CertSpec(
actor_name=actor_name,
actor_type=entry.actor_type,
pubkey_path=pubkey_path,
ttl_hours=ttl or entry.ttl_hours,
principals=entry.principals,
identity=actor_name,
)
try:
record = ca.sign(spec)
except CAError as e:
err.print(f"[red]Signing failed:[/red] {e}")
raise typer.Exit(1)
result = {
"actor": actor_name,
"privkey": str(privkey_path),
"cert": str(record.cert_path),
"identity": record.identity,
"principals": record.principals,
"valid_before": record.valid_before.isoformat(),
"signed_at": record.signed_at.isoformat(),
}
if output_json:
print(json.dumps(result, indent=2))
else:
console.print(f"[green]Issued credentials for {actor_name}[/green]")
for k, v in result.items():
console.print(f" {k}: {v}")
# ---------------------------------------------------------------------------
# warden status
# ---------------------------------------------------------------------------
@app.command()
def status(
actor_name: Annotated[Optional[str], typer.Argument(help="Actor name (omit for all)")] = None,
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Show certificate status. Exits 1 if any cert is expired."""
cfg = _load_cfg()
now = datetime.now(timezone.utc)
if actor_name:
cert_path = cfg.state_dir / f"{actor_name}-cert.pub"
paths = [cert_path] if cert_path.exists() else []
else:
paths = sorted(cfg.state_dir.glob("*-cert.pub")) if cfg.state_dir.exists() else []
if not paths:
msg = (
f"No certificate found for {actor_name!r} (static key / no cert)"
if actor_name
else "No certificates in state dir."
)
console.print(msg)
return
rows = []
for cert_path in paths:
name = cert_path.stem.replace("-cert", "")
try:
meta = parse_cert_metadata(cert_path)
valid_before = meta["valid_before"]
remaining = valid_before - now
secs = remaining.total_seconds()
if secs > 0:
h, rem = divmod(int(secs), 3600)
m = rem // 60
remaining_str = f"{h}h {m}m"
expired = False
else:
remaining_str = "EXPIRED"
expired = True
rows.append({
"actor": name,
"identity": meta["identity"],
"principals": ", ".join(meta["principals"]),
"valid_before": valid_before.isoformat(),
"remaining": remaining_str,
"expired": expired,
})
except Exception as e:
rows.append({"actor": name, "error": str(e), "expired": False})
if output_json:
print(json.dumps(rows, indent=2))
else:
table = Table(title="Certificate Status")
table.add_column("Actor")
table.add_column("Identity")
table.add_column("Principals")
table.add_column("Valid Before (UTC)")
table.add_column("Remaining")
for row in rows:
if "error" in row:
table.add_row(row["actor"], "[red]parse error[/red]", "", "", row["error"])
else:
rem_styled = (
f"[red]{row['remaining']}[/red]" if row["expired"] else row["remaining"]
)
table.add_row(
row["actor"],
row["identity"],
row["principals"],
row["valid_before"],
rem_styled,
)
console.print(table)
if any(r.get("expired") for r in rows):
raise typer.Exit(1)
# ---------------------------------------------------------------------------
# warden scorecard
# ---------------------------------------------------------------------------
@app.command()
def scorecard(
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Run compliance scorecard checks (AccessManagementDirective §5, cert-side)."""
cfg = _load_cfg()
inventory = _load_inventory(cfg)
results = run_scorecard(cfg.state_dir, inventory)
passed = sum(1 for r in results if r.passed)
total = len(results)
if output_json:
print(json.dumps(
[{"check": r.name, "passed": r.passed, "detail": r.detail} for r in results],
indent=2,
))
else:
table = Table(title=f"OpsWarden Scorecard ({passed}/{total})")
table.add_column("Check")
table.add_column("Status")
table.add_column("Detail")
for r in results:
status_str = "[green]PASS[/green]" if r.passed else "[red]FAIL[/red]"
table.add_row(r.name, status_str, r.detail)
console.print(table)
console.print(
f"\nScore: {passed}/{total} "
+ ("[green]Operational[/green]" if passed == total else "[yellow]Needs attention[/yellow]")
)
if passed < total:
raise typer.Exit(1)
# ---------------------------------------------------------------------------
# warden inventory
# ---------------------------------------------------------------------------
@inventory_app.command("list")
def inventory_list(
output_json: Annotated[bool, typer.Option("--json")] = False,
) -> None:
"""List all actors in the principals inventory."""
cfg = _load_cfg()
inventory = _load_inventory(cfg)
if not inventory.actors:
console.print("No actors in inventory.")
return
if output_json:
print(json.dumps({
name: {
"type": e.actor_type.value,
"principals": e.principals,
"ttl_hours": e.ttl_hours,
"description": e.description,
}
for name, e in inventory.actors.items()
}, indent=2))
return
table = Table(title=f"Principals Inventory ({cfg.inventory_path})")
table.add_column("Actor")
table.add_column("Type")
table.add_column("Principals")
table.add_column("TTL (h)")
table.add_column("Description")
for name, e in inventory.actors.items():
table.add_row(
name,
e.actor_type.value,
", ".join(e.principals),
str(e.ttl_hours),
e.description,
)
console.print(table)
@inventory_app.command("add")
def inventory_add(
actor_name: Annotated[str, typer.Argument(help="Actor name (e.g. agt-state-hub-bridge)")],
actor_type: Annotated[ActorType, typer.Option("--type", "-t", help="adm | agt | atm")],
principals: Annotated[
Optional[List[str]],
typer.Option("--principal", "-p", help="Principal (repeat for multiple)"),
] = None,
ttl: Annotated[Optional[int], typer.Option("--ttl", help="TTL in hours")] = None,
description: Annotated[str, typer.Option("--description", "-d")] = "",
) -> None:
"""Add an actor to the principals inventory."""
cfg = _load_cfg()
try:
validate_actor_name(actor_name, actor_type)
except ValueError as e:
err.print(f"[red]{e}[/red]")
raise typer.Exit(1)
resolved_principals: List[str] = principals or [actor_name]
inventory = _load_inventory(cfg)
inventory.actors[actor_name] = ActorEntry(
name=actor_name,
actor_type=actor_type,
principals=resolved_principals,
ttl_hours=ttl or DEFAULT_TTL_HOURS[actor_type],
description=description,
)
try:
save_inventory(inventory, cfg.inventory_path)
except Exception as e:
err.print(f"[red]Failed to save inventory:[/red] {e}")
raise typer.Exit(1)
console.print(
f"[green]Added[/green] {actor_name} "
f"(type={actor_type.value}, principals={resolved_principals}, ttl={ttl or DEFAULT_TTL_HOURS[actor_type]}h)"
)
@inventory_app.command("remove")
def inventory_remove(
actor_name: Annotated[str, typer.Argument(help="Actor name to remove")],
) -> None:
"""Remove an actor from the principals inventory."""
cfg = _load_cfg()
inventory = _load_inventory(cfg)
if actor_name not in inventory.actors:
err.print(f"[red]Actor {actor_name!r} not in inventory.[/red]")
raise typer.Exit(1)
del inventory.actors[actor_name]
try:
save_inventory(inventory, cfg.inventory_path)
except Exception as e:
err.print(f"[red]Failed to save inventory:[/red] {e}")
raise typer.Exit(1)
console.print(f"[green]Removed[/green] {actor_name}")

114
src/warden/config.py Normal file
View File

@@ -0,0 +1,114 @@
"""Config loading for OpsWarden."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Optional
import yaml
class ConfigError(Exception):
"""Raised when config is invalid or missing."""
@dataclass
class VaultConfig:
addr: str
role_map: Dict[str, str] # ActorType.value -> vault role name
token_env: str = "VAULT_TOKEN" # env var holding the Vault token
mount: str = "ssh" # Vault secrets engine mount path
@dataclass
class WardenConfig:
backend: str # "local" or "vault"
ca_key: Optional[Path] = None # required for local backend
vault: Optional[VaultConfig] = None # required for vault backend
inventory_path: Path = field(
default_factory=lambda: Path.home() / ".config" / "warden" / "inventory.yaml"
)
state_dir: Path = field(
default_factory=lambda: Path.home() / ".local" / "state" / "warden"
)
def _default_config_path() -> Path:
return Path.home() / ".config" / "warden" / "warden.yaml"
def load_config(path: Optional[Path] = None) -> WardenConfig:
"""Load and validate warden.yaml. Respects WARDEN_CONFIG env var."""
config_path = path or Path(
os.environ.get("WARDEN_CONFIG", str(_default_config_path()))
)
if not config_path.exists():
raise ConfigError(f"Config not found: {config_path}")
try:
with config_path.open() as f:
raw = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ConfigError(f"Invalid YAML in {config_path}: {e}") from e
if not isinstance(raw, dict):
raise ConfigError("Config must be a YAML mapping")
backend = str(raw.get("backend", "local"))
if backend not in ("local", "vault"):
raise ConfigError(
f"backend must be 'local' or 'vault', got: {backend!r}"
)
ca_key = None
if "ca_key" in raw and raw["ca_key"]:
ca_key = Path(os.path.expanduser(str(raw["ca_key"])))
vault_cfg = None
if backend == "vault":
v = raw.get("vault") or {}
if "addr" not in v:
raise ConfigError("vault backend requires vault.addr")
role_map = v.get("role_map") or {
"adm": "adm-role",
"agt": "agt-role",
"atm": "atm-role",
}
vault_cfg = VaultConfig(
addr=str(v["addr"]),
role_map=dict(role_map),
token_env=str(v.get("token_env", "VAULT_TOKEN")),
mount=str(v.get("mount", "ssh")),
)
elif backend == "local" and ca_key is None:
raise ConfigError("local backend requires ca_key")
inventory_path = Path(
os.path.expanduser(
str(
raw.get(
"inventory_path",
str(Path.home() / ".config" / "warden" / "inventory.yaml"),
)
)
)
)
state_dir = Path(
os.path.expanduser(
str(
raw.get(
"state_dir",
str(Path.home() / ".local" / "state" / "warden"),
)
)
)
)
return WardenConfig(
backend=backend,
ca_key=ca_key,
vault=vault_cfg,
inventory_path=inventory_path,
state_dir=state_dir,
)

108
src/warden/inventory.py Normal file
View File

@@ -0,0 +1,108 @@
"""Principals inventory — actor registry with type, principals, and TTL policy."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List
import yaml
from warden.models import ActorType, DEFAULT_TTL_HOURS, validate_actor_name
class InventoryError(Exception):
"""Raised when inventory is invalid."""
@dataclass
class ActorEntry:
name: str
actor_type: ActorType
principals: List[str]
ttl_hours: int
description: str = ""
@dataclass
class HostEntry:
name: str
allowed_principals: Dict[str, List[str]] # actor_type.value -> [principal, ...]
@dataclass
class PrincipalsInventory:
actors: Dict[str, ActorEntry] = field(default_factory=dict)
hosts: Dict[str, HostEntry] = field(default_factory=dict)
def load_inventory(path: Path) -> PrincipalsInventory:
"""Load inventory.yaml. Returns empty inventory if path does not exist."""
if not path.exists():
return PrincipalsInventory()
try:
with path.open() as f:
raw = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
raise InventoryError(f"Invalid YAML in {path}: {e}") from e
actors: Dict[str, ActorEntry] = {}
for name, data in (raw.get("actors") or {}).items():
if not isinstance(data, dict):
raise InventoryError(f"Actor {name!r} must be a mapping")
type_raw = str(data.get("type", ""))
try:
actor_type = ActorType(type_raw)
except ValueError:
raise InventoryError(
f"Actor {name!r} has invalid type: {type_raw!r}. "
f"Must be one of: adm, agt, atm"
)
try:
validate_actor_name(name, actor_type)
except ValueError as e:
raise InventoryError(str(e)) from e
ttl = int(data.get("ttl_hours", DEFAULT_TTL_HOURS[actor_type]))
principals = list(data.get("principals") or [name])
actors[name] = ActorEntry(
name=name,
actor_type=actor_type,
principals=principals,
ttl_hours=ttl,
description=str(data.get("description", "")),
)
hosts: Dict[str, HostEntry] = {}
for hostname, data in (raw.get("hosts") or {}).items():
if not isinstance(data, dict):
raise InventoryError(f"Host {hostname!r} must be a mapping")
hosts[hostname] = HostEntry(
name=hostname,
allowed_principals=dict(data.get("allowed_principals") or {}),
)
return PrincipalsInventory(actors=actors, hosts=hosts)
def save_inventory(inventory: PrincipalsInventory, path: Path) -> None:
"""Write inventory to path, creating parent directories as needed."""
path.parent.mkdir(parents=True, exist_ok=True)
raw: dict = {
"actors": {
name: {
"type": e.actor_type.value,
"principals": e.principals,
"ttl_hours": e.ttl_hours,
**({"description": e.description} if e.description else {}),
}
for name, e in inventory.actors.items()
},
}
if inventory.hosts:
raw["hosts"] = {
name: {"allowed_principals": h.allowed_principals}
for name, h in inventory.hosts.items()
}
with path.open("w") as f:
yaml.dump(raw, f, default_flow_style=False, sort_keys=False)

67
src/warden/models.py Normal file
View File

@@ -0,0 +1,67 @@
"""Domain models for OpsWarden."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import List
class ActorType(str, Enum):
ADM = "adm" # human operator
AGT = "agt" # LLM-powered autonomous agent
ATM = "atm" # deterministic script / pipeline
# Default certificate TTLs per ActorType (AccessManagementDirective §2)
DEFAULT_TTL_HOURS: dict[ActorType, int] = {
ActorType.ADM: 48,
ActorType.AGT: 24,
ActorType.ATM: 8,
}
# Required name prefixes per ActorType (directive §2 naming convention)
ACTOR_PREFIX: dict[ActorType, str] = {
ActorType.ADM: "adm-",
ActorType.AGT: "agt-",
ActorType.ATM: "atm-",
}
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]
if not name.startswith(prefix):
raise ValueError(
f"Actor name {name!r} must start with {prefix!r} for type {actor_type.value!r}. "
f"(AccessManagementDirective §2 naming convention)"
)
@dataclass
class CertSpec:
"""Signing request passed to a CABackend."""
actor_name: str
actor_type: ActorType
pubkey_path: Path
ttl_hours: int
principals: List[str]
identity: str = "" # defaults to actor_name if empty
def __post_init__(self) -> None:
if not self.identity:
self.identity = self.actor_name
@dataclass
class CertRecord:
"""Result returned by a CABackend after signing."""
identity: str
valid_before: datetime
cert_path: Path
signed_at: datetime
principals: List[str] = field(default_factory=list)
actor_name: str = ""

98
src/warden/scorecard.py Normal file
View File

@@ -0,0 +1,98 @@
"""Compliance scorecard — cert-side checks (AccessManagementDirective §5)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import List
from warden.ca import CAError, parse_cert_metadata
from warden.inventory import PrincipalsInventory
from warden.models import ACTOR_PREFIX, ActorType
@dataclass
class CheckResult:
name: str
passed: bool
detail: str = ""
def check_actor_name_prefixes(inventory: PrincipalsInventory) -> CheckResult:
"""All actor names must carry the prefix matching their type."""
violations = []
for name, entry in inventory.actors.items():
expected = ACTOR_PREFIX[entry.actor_type]
if not name.startswith(expected):
violations.append(f"{name!r} should start with {expected!r}")
return CheckResult(
name="actor_name_prefixes",
passed=len(violations) == 0,
detail=(
"; ".join(violations) if violations else "all actor names match prefix convention"
),
)
def check_all_actors_have_principals(inventory: PrincipalsInventory) -> CheckResult:
"""Every actor in inventory must have at least one principal."""
missing = [name for name, e in inventory.actors.items() if not e.principals]
return CheckResult(
name="actors_have_principals",
passed=len(missing) == 0,
detail=f"missing principals: {missing}" if missing else "all actors have principals",
)
def check_no_expired_certs(state_dir: Path) -> CheckResult:
"""No cert in state_dir should be currently expired."""
if not state_dir.exists():
return CheckResult("no_expired_certs", passed=True, detail="no state dir")
now = datetime.now(timezone.utc)
expired = []
for cert_path in state_dir.glob("*-cert.pub"):
try:
meta = parse_cert_metadata(cert_path)
except CAError:
continue
if meta["valid_before"] < now:
expired.append(cert_path.stem.replace("-cert", ""))
return CheckResult(
name="no_expired_certs",
passed=len(expired) == 0,
detail=f"expired: {expired}" if expired else "no expired certs",
)
def check_no_stale_certs(state_dir: Path) -> CheckResult:
"""Certs expired by more than 5 minutes should have been cleaned up."""
if not state_dir.exists():
return CheckResult("no_stale_certs", passed=True, detail="no state dir")
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
stale = []
for cert_path in state_dir.glob("*-cert.pub"):
try:
meta = parse_cert_metadata(cert_path)
except CAError:
continue
if meta["valid_before"] < cutoff:
stale.append(cert_path.name)
return CheckResult(
name="no_stale_certs",
passed=len(stale) == 0,
detail=f"stale certs present: {stale}" if stale else "no stale certs",
)
def run_scorecard(state_dir: Path, inventory: PrincipalsInventory) -> List[CheckResult]:
"""Run all cert-side scorecard checks. Returns list of CheckResult."""
return [
check_actor_name_prefixes(inventory),
check_all_actors_have_principals(inventory),
check_no_expired_certs(state_dir),
check_no_stale_certs(state_dir),
]

View File

View File

@@ -0,0 +1,82 @@
"""ops-ssh-wrapper — acquire a warden cert and exec the given SSH command.
Usage:
WARDEN_ACTOR=agt-my-agent SSH_PUBKEY=~/.ssh/agt-my-agent_ed25519.pub \\
ops-ssh-wrapper ssh -R 8001:127.0.0.1:8000 agt-my-agent@host
Environment:
WARDEN_ACTOR Actor name in the warden inventory (e.g. agt-state-hub-bridge)
SSH_PUBKEY Path to the actor's SSH public key file
The wrapper requests a fresh cert from warden on every invocation, loads it into
ssh-agent, then execs the given command. Equivalent to the pattern in
AccessManagementDirective §4.1, hardened for production use.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
from pathlib import Path
def main() -> None:
actor = os.environ.get("WARDEN_ACTOR")
pubkey = os.environ.get("SSH_PUBKEY")
if not actor:
print("ops-ssh-wrapper: WARDEN_ACTOR not set", file=sys.stderr)
sys.exit(1)
if not pubkey:
print("ops-ssh-wrapper: SSH_PUBKEY not set", file=sys.stderr)
sys.exit(1)
pubkey_path = Path(os.path.expanduser(pubkey))
if not pubkey_path.exists():
print(f"ops-ssh-wrapper: SSH_PUBKEY not found: {pubkey_path}", file=sys.stderr)
sys.exit(1)
try:
cert_text = subprocess.check_output(
["warden", "sign", actor, "--pubkey", str(pubkey_path)],
text=True,
).strip()
except subprocess.CalledProcessError as e:
print(
f"ops-ssh-wrapper: warden sign failed (exit {e.returncode})", file=sys.stderr
)
sys.exit(1)
except FileNotFoundError:
print(
"ops-ssh-wrapper: 'warden' not found in PATH. "
"Install ops-warden: uv tool install ops-warden",
file=sys.stderr,
)
sys.exit(1)
with tempfile.NamedTemporaryFile(
suffix="-cert.pub", mode="w", delete=False, prefix=f"{actor}-"
) as f:
f.write(cert_text + "\n")
cert_path = f.name
try:
result = subprocess.run(
["ssh-add", cert_path], capture_output=True, text=True
)
if result.returncode != 0:
print(
f"ops-ssh-wrapper: ssh-add warning: {result.stderr.strip()} "
f"(ssh-agent may not be running — continuing anyway)",
file=sys.stderr,
)
finally:
os.unlink(cert_path)
if len(sys.argv) > 1:
os.execvp(sys.argv[1], sys.argv[1:])
if __name__ == "__main__":
main()

97
src/warden/vault.py Normal file
View File

@@ -0,0 +1,97 @@
"""VaultCA backend — HashiCorp Vault SSH engine."""
from __future__ import annotations
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import httpx
from warden.ca import CABackend, CAError, parse_cert_metadata
from warden.config import VaultConfig
from warden.models import CertRecord, CertSpec
class VaultCA(CABackend):
"""CA backend that signs via HashiCorp Vault SSH secrets engine."""
def __init__(self, vault_cfg: VaultConfig, state_dir: Path) -> None:
self._cfg = vault_cfg
self._state_dir = Path(os.path.expanduser(str(state_dir)))
def _token(self) -> str:
token = os.environ.get(self._cfg.token_env, "")
if not token:
raise CAError(
f"Vault token not found. Set the {self._cfg.token_env!r} "
f"environment variable, or run: vault login"
)
return token
def sign(self, spec: CertSpec) -> CertRecord:
"""Sign the public key via Vault SSH engine. Returns a CertRecord."""
pubkey_path = Path(os.path.expanduser(str(spec.pubkey_path)))
if not pubkey_path.exists():
raise CAError(f"Public key not found: {pubkey_path}")
pubkey_text = pubkey_path.read_text().strip()
role = self._cfg.role_map.get(spec.actor_type.value)
if not role:
raise CAError(
f"No Vault role mapped for actor type {spec.actor_type.value!r}. "
f"Add it to vault.role_map in warden.yaml."
)
url = f"{self._cfg.addr}/v1/{self._cfg.mount}/sign/{role}"
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,
},
headers={"X-Vault-Token": self._token()},
timeout=10.0,
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
raise CAError(
f"Vault signing failed (HTTP {e.response.status_code}): "
f"{e.response.text}"
) from e
except httpx.RequestError as e:
raise CAError(
f"Vault unreachable at {self._cfg.addr}. "
f"Is Vault running? Consider --backend local as a fallback.\n{e}"
) from e
cert_text = response.json()["data"]["signed_key"].strip()
self._state_dir.mkdir(parents=True, exist_ok=True)
dest = self._state_dir / f"{spec.actor_name}-cert.pub"
dest.write_text(cert_text + "\n")
# Parse metadata by writing to a tempfile and running ssh-keygen -L
with tempfile.NamedTemporaryFile(
suffix="-cert.pub", mode="w", delete=False
) as f:
f.write(cert_text + "\n")
tmp_cert = Path(f.name)
try:
meta = parse_cert_metadata(tmp_cert)
finally:
tmp_cert.unlink(missing_ok=True)
return CertRecord(
identity=meta["identity"] or spec.identity,
valid_before=meta["valid_before"],
cert_path=dest,
signed_at=datetime.now(timezone.utc),
principals=meta["principals"],
actor_name=spec.actor_name,
)