Files
oikos/oikos/approve.py
dtoro 8a6422bd7d docs: move narrative wiki under knowledge/wiki/ (phase 3)
Problem: node and cross-cutting narratives lived at the repo root
(containers/, vms/, infrastructure/, host .md files), interleaved with the
machine-readable substrate.

Change:
- Move containers/ -> knowledge/wiki/containers/, vms/ -> knowledge/wiki/vms/,
  infrastructure/ -> knowledge/wiki/infrastructure/, hosts/{hubris,strong}.md ->
  knowledge/wiki/hosts/, infrastructure/references/ -> knowledge/sources/references/,
  GLOSSARY.md -> knowledge/GLOSSARY.md.
- Add knowledge/{index.md,log.md,sources/index.md} scaffolding.
- Rewrite all relative links repo-wide via a path-resolving mapper (inbound +
  outbound + between-moved-files), including .hermes/, runbooks, operations,
  investigations, plans, README, AGENTS.
- Repoint inventory.yaml doc_page fields and regenerate hosts/*.yaml (which
  embed doc_page); update oikos/gen-topology.py output path, candidate doc
  paths, and footer links; update code-comment doc paths.

Substrate untouched in place: inventory.yaml, hosts/*.yaml (regenerated,
idempotent), oikos/ code, mcp/, secrets/, bin/.

Verification:
- Logical broken-link set identical to pre-move baseline (net 128 -> 127; the
  topology regen fixed one, introduced none). Remaining are pre-existing refs
  to destroyed/archived nodes, out of scope for this move.
- gen-topology.py --check exit 0 (in sync); cards carry knowledge/wiki/ doc paths.
- build_host_files.py idempotent; all inventory doc_page targets resolve.
- MCP contract verified: get_page/search_docs/get_changelog resolve moved pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:35:23 +02:00

303 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
knowledge/wiki/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", "executed")
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, AND
consume it — a grant is exact-bound (this exact request id + entity +
action) and single-use: this call both checks and marks it "executed"
in the same step, so a second call for the same request_id fails with
"not approved" even if the grant's TTL hasn't expired yet. Callers
(homelab CLI mutating commands) must call this immediately before
executing, exactly once."""
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"
_append({**entry, "ts": now_s, "state": "executed"})
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())