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>
303 lines
12 KiB
Python
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())
|