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>
199 lines
8.9 KiB
Python
199 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import re
|
|
import threading
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
|
|
from .council import CLASSES
|
|
from .errors import ToolError
|
|
from .fields import FIELDS, NAME_LIMIT, expect
|
|
from .records import read_json, write_atomic
|
|
|
|
THEMES = ("sky", "light", "dark", "custom")
|
|
TONES = ("mayday", "review", "direct", "radio")
|
|
COLOR = re.compile(r"#[0-9a-f]{6}")
|
|
CLOCK = re.compile(r"(?:[01][0-9]|2[0-3]):[0-5][0-9]")
|
|
MIDNIGHT = "24:00"
|
|
STREAMS_LIMIT = 20
|
|
STREAM_URL = re.compile(r"https?://[A-Za-z0-9._~:/?#\[\]@!$&()*+,;=%-]{1,2040}")
|
|
HOSTNAME = re.compile(r"(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*")
|
|
DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
SETTINGS = {
|
|
"latitude": 0.0,
|
|
"longitude": 0.0,
|
|
"theme": "sky",
|
|
"colors": {"top": "#0b1020", "bottom": "#2b3a67", "accent": "#8fadff"},
|
|
"music": {"on": False, "volume": 0.4},
|
|
"sounds": {
|
|
"mayday": {"on": True, "volume": 0.9},
|
|
"review": {"on": True, "volume": 0.6},
|
|
"direct": {"on": True, "volume": 0.5},
|
|
"radio": {"on": False, "volume": 0.25},
|
|
},
|
|
"notify": True,
|
|
"streams": [],
|
|
"speech": {"on": False, "classes": ["security", "pan-pan", "mayday"], "muted": []},
|
|
"space": {
|
|
"graph": {"axis": "none", "yaw": 30.0, "pitch": 20.0, "zoom": 1.0, "pan_x": 0.0, "pan_y": 0.0},
|
|
"sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0},
|
|
},
|
|
}
|
|
SPACE_AXES = ("none", "time", "spawn")
|
|
SPACE_LIMITS = {
|
|
"graph": {"yaw": (-180.0, 180.0), "pitch": (-90.0, 90.0), "zoom": (0.25, 4.0), "pan_x": (-2000.0, 2000.0), "pan_y": (-2000.0, 2000.0)},
|
|
"sky": {"azimuth": (0.0, 360.0), "altitude": (-30.0, 85.0), "fov": (20.0, 240.0)},
|
|
}
|
|
MUTED_LIMIT = 200
|
|
|
|
|
|
def section(value: object, name: str, keys: tuple[str, ...]) -> dict:
|
|
if not isinstance(value, dict):
|
|
raise ToolError(f"{name} must be an object")
|
|
try:
|
|
expect(value, keys)
|
|
except ToolError as error:
|
|
raise ToolError(f"{name}: {error}") from None
|
|
return value
|
|
|
|
|
|
def number_in(value: object, name: str, low: float, high: float) -> float:
|
|
if isinstance(value, bool) or not isinstance(value, int | float) or not low <= value <= high:
|
|
raise ToolError(f"{name} must be a number from {low} to {high}")
|
|
return float(value)
|
|
|
|
|
|
def flag(value: object, name: str) -> bool:
|
|
if not isinstance(value, bool):
|
|
raise ToolError(f"{name} must be true or false")
|
|
return value
|
|
|
|
|
|
def color(value: object, name: str) -> str:
|
|
if not isinstance(value, str) or not COLOR.fullmatch(value):
|
|
raise ToolError(f"{name} must be a colour like #1a2b3c")
|
|
return value
|
|
|
|
|
|
def stream_origin(url: object) -> str:
|
|
if not isinstance(url, str) or not STREAM_URL.fullmatch(url):
|
|
raise ToolError("a stream url must be an http or https URL without spaces or quotes")
|
|
parts = urlsplit(url)
|
|
try:
|
|
port = parts.port
|
|
except ValueError:
|
|
raise ToolError("a stream url needs a valid port") from None
|
|
host = parts.hostname or ""
|
|
if parts.username is not None or parts.password is not None or not HOSTNAME.fullmatch(host):
|
|
raise ToolError("a stream url needs a plain host name and no user info")
|
|
return f"{parts.scheme}://{host}" if port in (None, DEFAULT_PORTS[parts.scheme]) else f"{parts.scheme}://{host}:{port}"
|
|
|
|
|
|
def clock_minutes(value: object, name: str, end: bool) -> int:
|
|
if end and value == MIDNIGHT:
|
|
return 24 * 60
|
|
if not isinstance(value, str) or not CLOCK.fullmatch(value):
|
|
raise ToolError(f"{name} must be a time like 07:30")
|
|
hours, minutes = value.split(":")
|
|
return int(hours) * 60 + int(minutes)
|
|
|
|
|
|
def stream_entry(raw: object, index: int) -> dict:
|
|
name = f"streams[{index}]"
|
|
entry = section(raw, name, ("name", "url", "from", "to", "days", "on"))
|
|
label, days = entry["name"], entry["days"]
|
|
if not isinstance(label, str) or not FIELDS["label"][0].fullmatch(label):
|
|
raise ToolError(f"{name}.name must be one line of 1 to {NAME_LIMIT} characters")
|
|
stream_origin(entry["url"])
|
|
if clock_minutes(entry["from"], f"{name}.from", False) == clock_minutes(entry["to"], f"{name}.to", True):
|
|
raise ToolError(f"{name} must end at another time than it starts")
|
|
if not isinstance(days, list) or not days or len(set(map(str, days))) != len(days) or any(isinstance(day, bool) or not isinstance(day, int) or not 0 <= day <= 6 for day in days):
|
|
raise ToolError(f"{name}.days must list distinct weekdays from 0 (Monday) to 6 (Sunday)")
|
|
return {"name": label, "url": entry["url"], "from": entry["from"], "to": entry["to"], "days": sorted(days), "on": flag(entry["on"], f"{name}.on")}
|
|
|
|
|
|
def tone_setting(raw: object, kind_name: str) -> dict:
|
|
entry = section(raw, f"sounds.{kind_name}", ("on", "volume"))
|
|
return {"on": flag(entry["on"], f"sounds.{kind_name}.on"), "volume": number_in(entry["volume"], f"sounds.{kind_name}.volume", 0.0, 1.0)}
|
|
|
|
|
|
def distinct_texts(value: object, valid: Callable[[str], bool], limit: int) -> bool:
|
|
if not isinstance(value, list) or len(value) > limit or not all(isinstance(item, str) and valid(item) for item in value):
|
|
return False
|
|
return len(set(value)) == len(value)
|
|
|
|
|
|
def speech_setting(raw: object) -> dict:
|
|
entry = section(raw, "speech", ("on", "classes", "muted"))
|
|
classes, muted = entry["classes"], entry["muted"]
|
|
if not distinct_texts(classes, lambda name: name in CLASSES, len(CLASSES)):
|
|
raise ToolError(f"speech.classes must list distinct classes of {', '.join(CLASSES)}")
|
|
if "mayday" not in classes:
|
|
raise ToolError("speech.classes must include mayday: a MAYDAY is never quieter than anything else")
|
|
if not distinct_texts(muted, FIELDS["agent"][0].fullmatch, MUTED_LIMIT):
|
|
raise ToolError(f"speech.muted must list at most {MUTED_LIMIT} distinct agent keys, each one line of 1 to 200 characters")
|
|
return {"on": flag(entry["on"], "speech.on"), "classes": [name for name in CLASSES if name in classes], "muted": sorted(muted)}
|
|
|
|
|
|
def space_setting(raw: object) -> dict:
|
|
entry = section(raw, "space", tuple(SETTINGS["space"]))
|
|
graph = section(entry["graph"], "space.graph", tuple(SETTINGS["space"]["graph"]))
|
|
sky = section(entry["sky"], "space.sky", tuple(SETTINGS["space"]["sky"]))
|
|
if not isinstance(graph["axis"], str) or graph["axis"] not in SPACE_AXES:
|
|
raise ToolError(f"space.graph.axis must be one of {', '.join(SPACE_AXES)}")
|
|
views = {"graph": graph, "sky": sky}
|
|
numbers = {view: {key: number_in(views[view][key], f"space.{view}.{key}", low, high) for key, (low, high) in limits.items()} for view, limits in SPACE_LIMITS.items()}
|
|
return {"graph": {"axis": graph["axis"], **numbers["graph"]}, "sky": numbers["sky"]}
|
|
|
|
|
|
def valid_settings(raw: object) -> dict:
|
|
data = section(raw, "settings", tuple(SETTINGS))
|
|
colors = section(data["colors"], "colors", tuple(SETTINGS["colors"]))
|
|
music = section(data["music"], "music", ("on", "volume"))
|
|
sounds = section(data["sounds"], "sounds", TONES)
|
|
if not isinstance(data["theme"], str) or data["theme"] not in THEMES:
|
|
raise ToolError(f"theme must be one of {', '.join(THEMES)}")
|
|
if not isinstance(data["streams"], list) or len(data["streams"]) > STREAMS_LIMIT:
|
|
raise ToolError(f"streams must be a list of at most {STREAMS_LIMIT} entries")
|
|
return {
|
|
"latitude": number_in(data["latitude"], "latitude", -90.0, 90.0),
|
|
"longitude": number_in(data["longitude"], "longitude", -180.0, 180.0),
|
|
"theme": data["theme"],
|
|
"colors": {key: color(colors[key], f"colors.{key}") for key in SETTINGS["colors"]},
|
|
"music": {"on": flag(music["on"], "music.on"), "volume": number_in(music["volume"], "music.volume", 0.0, 1.0)},
|
|
"sounds": {kind_name: tone_setting(sounds[kind_name], kind_name) for kind_name in TONES},
|
|
"notify": flag(data["notify"], "notify"),
|
|
"streams": [stream_entry(entry, index) for index, entry in enumerate(data["streams"])],
|
|
"speech": speech_setting(data["speech"]),
|
|
"space": space_setting(data["space"]),
|
|
}
|
|
|
|
|
|
def media_origins(settings: dict) -> list[str]:
|
|
return sorted({stream_origin(stream["url"]) for stream in settings["streams"]})
|
|
|
|
|
|
class Settings:
|
|
def __init__(self, file: Path) -> None:
|
|
self.file = file
|
|
self.lock = threading.Lock()
|
|
stored = read_json(file)
|
|
try:
|
|
self.current = valid_settings({**SETTINGS, **stored} if isinstance(stored, dict) else stored)
|
|
except ToolError:
|
|
self.current = valid_settings(SETTINGS)
|
|
|
|
def get(self) -> dict:
|
|
with self.lock:
|
|
return copy.deepcopy(self.current)
|
|
|
|
def put(self, raw: object) -> dict:
|
|
settings = valid_settings(raw)
|
|
with self.lock:
|
|
write_atomic(self.file, json.dumps(settings, indent=1, sort_keys=True).encode("utf-8"))
|
|
self.current = settings
|
|
return copy.deepcopy(settings)
|