Files
oikos/oikos/approve.py
dtoro 2084a1583e Oikos Week 3: scheduler, drift detectors, signals, classifier, approvals
New kernel modules, all wired into `homelab` CLI + tested against live
production where reachable:

- oikos/scheduler.py — Observe stage: HTTP health probes for every
  service, disk-usage probes on hubris/strong, writes oikos/state.json
  (gitignored — regenerates every run). `homelab service <name> health`
  is now cache-first; `--live` forces a fresh probe. Deploys via
  oikos/systemd/oikos-scheduler.{timer,service} on LXC 105.

- oikos/drift.py — SOPS-recipient-vs-inventory and lifecycle-consistency
  detectors (fully local, no SSH) plus pct-list and Caddy-backend
  detectors (best-effort SSH, degrade to an info finding when
  unreachable rather than a false drift alarm). Found real, currently-
  true drift on first run: republic-laptop's age key granted on every
  secret but missing from inventory.yaml, grimmory missing from
  hello.yaml's recipients, and an undocumented pve_id 131 on hubris —
  recorded in OIKOS.md for the operator, not auto-fixed (each is a
  config_mutation/destructive decision).

- oikos/signal.py — the attention layer: raised -> acknowledged ->
  acting -> resolved|muted lifecycle, severity-based routing, dedup via
  open_signal_for(). `homelab signal list|raise|ack|resolve|mute`.

- oikos/decide.py — the Decide-stage classifier: risk class x blast
  radius x ledger-history confidence -> auto-act/escalate. Adds an
  action-alias layer (oikos/policy.py ACTION_ALIASES) and auto-infers
  service_name from the entity for per-service policy overrides.
  `homelab decide <action> <entity>`.

- oikos/approve.py — the escalate route. No dedicated Matrix bot exists
  in this homelab, so this is the repo-side half only: request/reply/
  grant lifecycle with short-TTL HMAC-signed tokens (new secret
  secrets/oikos-approval-hmac.yaml, recipients apps+hubris). Matrix
  delivery is Hermes's existing @dtoro:avispero send path (documented
  integration contract in the module docstring), not a new bot.
  `homelab restart` now mechanically refuses config_mutation/destructive
  services without a valid --approval-id, regardless of -y/interactivity.

- oikos/report.py — daily brief + weekly report from signal/approval/
  ledger state (no Prometheus yet, so point-in-time counts only).

- plans/2026-07-05-oikos-prometheus-lxc.md — Prometheus is `planned`,
  not provisioned: no pve_id is guessed here since Proxmox assigns real
  IDs at creation time, and drift already found an unclaimed ID (131) to
  investigate first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:29:39 +02:00

301 lines
12 KiB
Python

#!/usr/bin/env python3
"""oikos/approve.py — the approval engine (escalate route of the OODA loop).
Repo-side half of the Week-3 approval flow. This module owns the request/
grant lifecycle and the HMAC signing; it does NOT talk to Matrix directly.
There is no dedicated Matrix bot in this homelab — alerts already go out
as the operator's own Hermes agent posting to @dtoro:avispero (see
infrastructure/monitoring.md's homelab-health-watchdog). The integration
contract is:
1. An agent or the Week-3 scheduler calls `request()` (or the CLI
`request` subcommand) to open an approval. This prints the Matrix-
ready message text (evidence, options, reply format).
2. Hermes posts that text to Matrix using its existing send capability
(the same one homelab-health-watchdog already uses) and later reads
the operator's reply/reaction.
3. Hermes calls back `reply()` (or `oikos/approve.py reply <id>
approve|deny`) with the operator's decision. This module verifies
`requires_phrase` for destructive actions, then issues a short-TTL
HMAC-signed grant.
4. Mutating `homelab` commands call `check_grant()` before executing a
config_mutation/destructive action.
Storage: approvals/<YYYY-MM>.jsonl, same append-only-JSONL-with-latest-
state-wins convention as oikos/signal.py.
Grant signing key: secrets/oikos-approval-hmac.yaml (SOPS, recipients:
apps + hubris — see .sops.yaml). Decrypted on demand; never cached to
disk in plaintext.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parent.parent
APPROVALS_DIR = REPO / "approvals"
HMAC_SECRET = REPO / "secrets" / "oikos-approval-hmac.yaml"
AGE_KEY = Path(os.environ.get("SOPS_AGE_KEY_FILE", "/etc/age/key.txt"))
VALID_STATES = ("pending", "approved", "denied", "expired")
REQUEST_TTL_HOURS = 24
GRANT_TTL_MINUTES = 15
def _month_path(dt: datetime | None = None) -> Path:
dt = dt or datetime.now(timezone.utc)
return APPROVALS_DIR / f"{dt.strftime('%Y-%m')}.jsonl"
def _all_entries() -> list[dict]:
if not APPROVALS_DIR.exists():
return []
out = []
for path in sorted(APPROVALS_DIR.glob("*.jsonl")):
for line in path.read_text().splitlines():
if not line.strip():
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
continue
return out
def _next_id(dt: datetime | None = None) -> str:
dt = dt or datetime.now(timezone.utc)
prefix = f"appr-{dt.strftime('%Y-%m-%d')}-"
existing = [e["id"] for e in _all_entries() if e.get("id", "").startswith(prefix)]
n = 1
while f"{prefix}{n:04d}" in existing:
n += 1
return f"{prefix}{n:04d}"
def _append(entry: dict) -> dict:
APPROVALS_DIR.mkdir(exist_ok=True)
entry = {k: v for k, v in entry.items() if v is not None}
with _month_path().open("a") as f:
f.write(json.dumps(entry, sort_keys=False) + "\n")
return entry
def current(request_id: str) -> dict | None:
matches = [e for e in _all_entries() if e.get("id") == request_id]
if not matches:
return None
matches.sort(key=lambda e: e.get("ts", ""))
return matches[-1]
def list_approvals(state: str | None = None) -> list[dict]:
latest: dict[str, dict] = {}
for e in sorted(_all_entries(), key=lambda e: e.get("ts", "")):
rid = e.get("id")
if rid:
latest[rid] = e
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
out = []
for e in latest.values():
if e.get("state") == "pending" and e.get("expires_at") and e["expires_at"] < now:
e = {**e, "state": "expired"}
if state and e.get("state") != state:
continue
out.append(e)
out.sort(key=lambda e: e.get("ts", ""), reverse=True)
return out
def _matrix_message(entry: dict) -> str:
lines = [
f"[Oikos approval {entry['id']}] {entry['entity']}{entry['action']}",
f"risk: {entry['risk']}",
f"evidence: {entry['evidence']}",
]
if entry.get("verification"):
lines.append(f"verification: {entry['verification']}")
if entry.get("requires_phrase"):
lines.append(f'reply: "approve {entry["id"]} {entry["confirmation_phrase"]}" or "deny {entry["id"]}"')
else:
lines.append(f'reply: ✅ to approve, ❌ to deny (or "approve {entry["id"]}" / "deny {entry["id"]}")')
return "\n".join(lines)
def request(entity: str, action: str, risk: str, evidence: str, *,
verification: str | None = None, signal_id: str | None = None,
requires_phrase: bool = False, requested_by: str | None = None,
ttl_hours: int = REQUEST_TTL_HOURS) -> dict:
"""Open a new approval request. `requires_phrase=True` (use for
`destructive`-class actions per oikos/policy.yaml) generates a
confirmation phrase the operator must echo back — a reaction alone
can't authorize it."""
rid = _next_id()
now = datetime.now(timezone.utc)
phrase = f"{action} {entity}" if requires_phrase else None
entry = {
"id": rid,
"ts": now.isoformat(timespec="seconds"),
"entity": entity,
"action": action,
"risk": risk,
"evidence": evidence,
"verification": verification,
"signal_id": signal_id,
"requires_phrase": requires_phrase,
"confirmation_phrase": phrase,
"requested_by": requested_by or os.environ.get("HOMELAB_AGENT_ID"),
"state": "pending",
"expires_at": (now + timedelta(hours=ttl_hours)).isoformat(timespec="seconds"),
}
recorded = _append(entry)
recorded["matrix_message"] = _matrix_message(recorded)
return recorded
@lru_cache(maxsize=1)
def _hmac_key() -> str:
if not HMAC_SECRET.exists():
raise RuntimeError(f"no secret at {HMAC_SECRET} — has it been provisioned?")
env = {**os.environ}
if AGE_KEY.exists():
env["SOPS_AGE_KEY_FILE"] = str(AGE_KEY)
proc = subprocess.run(["sops", "-d", str(HMAC_SECRET)],
capture_output=True, text=True, env=env)
if proc.returncode != 0:
raise RuntimeError(
f"sops decrypt of oikos-approval-hmac failed (is this host a recipient?): "
f"{proc.stderr.strip()}")
return yaml.safe_load(proc.stdout)["hmac_key"]
def _sign(request_id: str, entity: str, action: str, expires_at: str) -> str:
key = _hmac_key().encode()
msg = f"{request_id}:{entity}:{action}:{expires_at}".encode()
return hmac.new(key, msg, hashlib.sha256).hexdigest()
def reply(request_id: str, decision: str, *, phrase: str | None = None,
decided_by: str | None = None) -> dict:
"""Record the operator's decision. On approval, issues a short-TTL
HMAC-signed grant token. Raises ValueError if a required confirmation
phrase is missing or wrong, or if the request already expired."""
if decision not in ("approve", "deny"):
raise ValueError("decision must be 'approve' or 'deny'")
entry = current(request_id)
if entry is None:
raise ValueError(f"unknown approval id: {request_id}")
if entry.get("state") != "pending":
raise ValueError(f"{request_id} is not pending (state={entry.get('state')})")
now_s = datetime.now(timezone.utc).isoformat(timespec="seconds")
if entry.get("expires_at") and entry["expires_at"] < now_s:
_append({**entry, "ts": now_s, "state": "expired"})
raise ValueError(f"{request_id} expired at {entry['expires_at']}")
if decision == "deny":
return _append({**entry, "ts": now_s, "state": "denied", "decided_by": decided_by})
if entry.get("requires_phrase"):
if phrase != entry.get("confirmation_phrase"):
raise ValueError(
"confirmation phrase missing or incorrect — a reaction alone "
"cannot approve a destructive action")
grant_expires = (datetime.now(timezone.utc) + timedelta(minutes=GRANT_TTL_MINUTES)) \
.isoformat(timespec="seconds")
grant_token = _sign(request_id, entry["entity"], entry["action"], grant_expires)
return _append({**entry, "ts": now_s, "state": "approved", "decided_by": decided_by,
"grant_token": grant_token, "grant_expires": grant_expires})
def check_grant(request_id: str, entity: str, action: str) -> tuple[bool, str]:
"""Verify a request carries a valid, unexpired, matching grant. Returns
(ok, reason). Callers (homelab CLI mutating commands) must call this
immediately before executing — grants are single-use in spirit (the
60/90-day backlog adds exact command+target binding + replay
prevention; today re-checking the same still-valid grant twice is
possible, so keep grant TTLs short)."""
entry = current(request_id)
if entry is None:
return False, "unknown approval id"
if entry.get("state") != "approved":
return False, f"not approved (state={entry.get('state')})"
if entry.get("entity") != entity or entry.get("action") != action:
return False, "grant does not match entity/action"
now_s = datetime.now(timezone.utc).isoformat(timespec="seconds")
if not entry.get("grant_expires") or entry["grant_expires"] < now_s:
return False, "grant expired"
expected = _sign(request_id, entity, action, entry["grant_expires"])
if not hmac.compare_digest(expected, entry.get("grant_token", "")):
return False, "grant signature invalid"
return True, "ok"
def main() -> int:
import argparse
p = argparse.ArgumentParser(description="oikos approval engine")
sub = p.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("request")
r.add_argument("entity")
r.add_argument("action")
r.add_argument("risk")
r.add_argument("evidence")
r.add_argument("--verification")
r.add_argument("--signal-id")
r.add_argument("--requires-phrase", action="store_true")
r.add_argument("--ttl-hours", type=int, default=REQUEST_TTL_HOURS)
ls = sub.add_parser("list")
ls.add_argument("--state", choices=VALID_STATES)
rp = sub.add_parser("reply")
rp.add_argument("id")
rp.add_argument("decision", choices=["approve", "deny"])
rp.add_argument("--phrase")
rp.add_argument("--decided-by")
ck = sub.add_parser("check")
ck.add_argument("id")
ck.add_argument("entity")
ck.add_argument("action")
args = p.parse_args()
if args.cmd == "request":
entry = request(args.entity, args.action, args.risk, args.evidence,
verification=args.verification, signal_id=args.signal_id,
requires_phrase=args.requires_phrase, ttl_hours=args.ttl_hours)
print(json.dumps({k: v for k, v in entry.items() if k != "matrix_message"}, indent=2))
print()
print("--- post this to Matrix ---")
print(entry["matrix_message"])
elif args.cmd == "list":
for e in list_approvals(state=args.state):
print(json.dumps(e))
elif args.cmd == "reply":
try:
entry = reply(args.id, args.decision, phrase=args.phrase, decided_by=args.decided_by)
except (ValueError, RuntimeError) as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(json.dumps(entry, indent=2))
elif args.cmd == "check":
ok, reason = check_grant(args.id, args.entity, args.action)
print(f"{'GRANTED' if ok else 'DENIED'}: {reason}")
return 0 if ok else 1
return 0
if __name__ == "__main__":
sys.exit(main())