The kvb-hub is a local review hub in a container bound to 127.0.0.1: one page, JSON APIs and an MCP endpoint for the operator's inbox, asks, decisions, staged-commit reviews, the radio stream, the agent graph, the worktree tree, push-to-talk and read-aloud radio (Piper, whisper.cpp) and an optional LLM command line. The hub is only a relay. The host executors in hub/client (the runner user service, the PermissionRequest hook and the approval CLI the commit skills call) verify a WebAuthn passkey signature against keys pinned outside every sandbox, refuse replays and fail closed. scripts/hub.sh with make hub-install, hub-check, hub-pin, hub-runner and hub-hook installs and checks the hub; hub-hook writes ~/.claude/settings.json after a backup. make watch-check shows a stuck watch hook. The new kvb-git-diff skill and the commit skills route reviews through the hub; the test_hub_* suites replace test_hub.py. Security review (kvb-think-criticize, three skeptics) fixed four holes: signed rules auto-approved chained shell commands; the client and LLM openers followed redirects, the LLM one with its bearer key; the worktree scan ran git fsmonitor and filter commands from agent-writable repos; compose set no memory, pids or health limits. Documented until core requirement 093, on the operator's word: the hub page is trusted while signing and pinning, local processes can forge the unsigned operator actions, and the container reads the host /tmp. Committed on the operator's word despite the gate: the passkey path is not yet verified live. Accepted MEDUSA false positives (137 findings on 115 lines, rows in security-accepted.jsonl): EXA002 x17, MEDUSA-MM-SCAN-058 x12, B603 x8, MEDUSA-FTSC-SCAN-089 x7, MEDUSA-PRIV-SCAN-762 x6, AIC-EXFIL-005 x5, EXA006 x5, MEDUSA-FTSCAN-231 x5, WEB-SSRF x5, MEDUSA-INF-SCAN-2057 x3, RH005 x3, TC001 x3, LO002 x2, MEDUSA-AEXP-SCAN-096 x2, MEDUSA-CGS-SCAN-028 x2, MEDUSA-INF-SCAN-1350 x2, MEDUSA-INF-SCAN-2369 x2, MEDUSA-INF-SCAN-2440 x2, MEDUSA-INF-SCAN-2638 x2, MLSC-SERIAL-002 x2, PL002 x2, PQC005 x2, SAST-PATH-001 x2, TC004 x2, TC005 x2, TC006 x2, TC007 x2, AA-146, EXA007, FINE-050, GB-ENCODE-001, GB-ENCODE-003, MEDUSA-CGS-SCAN-029, MEDUSA-CGS-SCAN-103, MEDUSA-DP-SCAN-1034, MEDUSA-DP-SCAN-1916, MEDUSA-DP-SCAN-954, MEDUSA-EXF26-TOOL-004, MEDUSA-FT-TRIGGER-005, MEDUSA-INF-SCAN-2206, MEDUSA-INF-SCAN-2457, MEDUSA-INF-WEB-003, MEDUSA-INFRA-SCAN-030, MEDUSA-MP-SCAN-1135, MEDUSA-RLHF-POISON-002, MEDUSA-RT-SCAN-006, MM-ENCODE-001, MP-124, PQC001, PQC002, PQC006, RP001, TC009, TC010, WEB-FLASK-004. 🎯 Fix: 80 % 💥 Break: 4 % 💸 Cost: 129 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 60 🚦 Gate: ⛔ fix 80 % < 90; cost 129 % > 100: ask the operator before the commit ``` /home/kevinveenbirkenbach/Repositories/github.com/kevinveenbirkenbach/skills/.worktrees/feature/kvb-hub ``` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
5.6 KiB
Python
140 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
import itertools
|
|
import json
|
|
import math
|
|
import re
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
from .council import HUMAN, agent
|
|
from .events import event
|
|
from .records import read_json, write_atomic
|
|
from .transcripts import STREAM_AGE
|
|
|
|
PSEUDO = "name:"
|
|
TITLE_SCAN = 10.0
|
|
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
|
|
|
|
class Directory:
|
|
def __init__(self, entries: list[dict]) -> None:
|
|
self.by_session: dict[str, list[dict]] = {}
|
|
self.by_name: dict[str, list[str]] = {}
|
|
for entry in entries:
|
|
self.by_session.setdefault(entry["session"], []).append(entry)
|
|
holders = self.by_name.setdefault(entry["name"], [])
|
|
if entry["session"] in holders:
|
|
holders.remove(entry["session"])
|
|
holders.append(entry["session"])
|
|
|
|
def at(self, session: str, ts: float) -> str:
|
|
own = self.by_session.get(session)
|
|
if not own:
|
|
return ""
|
|
valid = [entry for entry in own if entry["first_seen"] <= ts]
|
|
return (valid[-1] if valid else own[0])["name"]
|
|
|
|
def now(self, session: str) -> str:
|
|
own = self.by_session.get(session)
|
|
return own[-1]["name"] if own else ""
|
|
|
|
def find(self, name: str, ts: float) -> str:
|
|
holders = self.by_name.get(name, [])
|
|
real = [session for session in holders if not session.startswith(PSEUDO)]
|
|
valid = [session for session in real if self.at(session, ts) == name]
|
|
chosen = valid or real or holders
|
|
return chosen[-1] if chosen else ""
|
|
|
|
def key(self, name: str, ts: float) -> str:
|
|
if not name or name == HUMAN:
|
|
return name
|
|
return self.find(name, ts) or f"{PSEUDO}{name}"
|
|
|
|
def label(self, key: str, ts: float) -> str:
|
|
return self.at(key, ts) or key.removeprefix(PSEUDO)
|
|
|
|
def names(self, key: str) -> list[str]:
|
|
return list(dict.fromkeys(entry["name"] for entry in self.by_session.get(key, [])))
|
|
|
|
def timeline(self, key: str) -> list[dict]:
|
|
return [{"name": entry["name"], "since": entry["first_seen"]} for entry in self.by_session.get(key, [])]
|
|
|
|
def aliases(self, name: str) -> set[str]:
|
|
return set(self.by_name.get(name, []))
|
|
|
|
def renames(self, begin: float, end: float) -> list[dict]:
|
|
found = []
|
|
for session, own in self.by_session.items():
|
|
for before, after in itertools.pairwise(own):
|
|
if before["name"] != after["name"] and begin <= after["first_seen"] <= end:
|
|
found.append(event({
|
|
"id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"],
|
|
"now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}",
|
|
"text": f"Quelle: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]},
|
|
}))
|
|
return found
|
|
|
|
|
|
class Names:
|
|
def __init__(self, file: Path) -> None:
|
|
self.file = file
|
|
self.lock = threading.Lock()
|
|
self.titled = -math.inf
|
|
data = read_json(file)
|
|
data = data if isinstance(data, dict) else {}
|
|
entries, observed = data.get("entries"), data.get("observed")
|
|
keys = {"session", "name", "first_seen", "source"}
|
|
self.entries = [entry for entry in entries if isinstance(entry, dict) and set(entry) == keys] if isinstance(entries, list) else []
|
|
self.observed: dict[str, str] = observed if isinstance(observed, dict) else {}
|
|
|
|
def observe(self, sightings: list[tuple[str, str, float | None, str]], now: float) -> None:
|
|
with self.lock:
|
|
changed = False
|
|
for session, name, seen, source in sightings:
|
|
key = f"{session}\x00{source}"
|
|
if not session or not name or self.observed.get(key) == name:
|
|
continue
|
|
self.observed[key] = name
|
|
changed = True
|
|
history = [entry for entry in self.entries if entry["session"] == session]
|
|
if history and history[-1]["name"] == name:
|
|
continue
|
|
floor = history[-1]["first_seen"] if history else -math.inf
|
|
first = seen if seen is not None and seen > floor else max(now, floor)
|
|
self.entries.append({"session": session, "name": name, "first_seen": first, "source": source})
|
|
if changed:
|
|
write_atomic(self.file, json.dumps({"entries": self.entries, "observed": self.observed}, sort_keys=True).encode("utf-8"))
|
|
|
|
def listed(self) -> list[dict]:
|
|
with self.lock:
|
|
return [dict(entry) for entry in self.entries]
|
|
|
|
def due(self, now: float) -> bool:
|
|
with self.lock:
|
|
if now - self.titled < TITLE_SCAN:
|
|
return False
|
|
self.titled = now
|
|
return True
|
|
|
|
|
|
def title_sightings(root: Path, claimed: set[str], now: float) -> list[tuple[str, str, float | None, str]]:
|
|
try:
|
|
paths = sorted(root.glob("*/*/custom-title.json"))
|
|
except OSError:
|
|
return []
|
|
found = []
|
|
for path in paths:
|
|
session = path.parent.name
|
|
try:
|
|
changed = path.stat().st_mtime
|
|
except OSError:
|
|
continue
|
|
if not SESSION_ID.fullmatch(session) or (session not in claimed and changed < now - STREAM_AGE):
|
|
continue
|
|
data = read_json(path)
|
|
name = agent(data.get("customTitle") if isinstance(data, dict) else None)
|
|
if name:
|
|
found.append((session, name, changed, "title"))
|
|
return found
|