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>
206 lines
8.3 KiB
Python
206 lines
8.3 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import threading
|
|
import wave
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .council import HUMAN
|
|
from .errors import EngineError, ToolError, Unavailable
|
|
from .fields import TEXT_LIMIT, bounded, decode, expect
|
|
from .phrasing import SPEECH_LIMIT, SPOKEN_LINE, phrase
|
|
from .records import digest, read_json
|
|
from .skill_scripts import SAFE_PATH
|
|
|
|
MODEL_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")
|
|
SPEAKER_LIMIT = 10000
|
|
TTS_TIMEOUT = 60
|
|
TTS_KEEP = 200
|
|
STT_RATE = 16000
|
|
STT_SECONDS = 120
|
|
STT_LIMIT = 44 + STT_RATE * 2 * STT_SECONDS
|
|
STT_TIMEOUT = 120
|
|
NOISE = re.compile(r"\[[^\]\n]{0,40}\]")
|
|
|
|
|
|
def installed_voices(folder: Path) -> dict[str, tuple[str, int | None]]:
|
|
"""Args:
|
|
folder: directory holding <model>.onnx files with <model>.onnx.json.
|
|
|
|
Returns:
|
|
voice id <model>:<speaker> -> (model, speaker id or None for a model
|
|
with one speaker), in model and speaker order; a model without a
|
|
readable config or with an unsafe name is left out.
|
|
"""
|
|
try:
|
|
models = sorted(folder.glob("*.onnx"))
|
|
except OSError:
|
|
return {}
|
|
found: dict[str, tuple[str, int | None]] = {}
|
|
for model in models:
|
|
data = read_json(model.with_name(f"{model.name}.json"))
|
|
count = data.get("num_speakers") if isinstance(data, dict) else None
|
|
if not MODEL_NAME.fullmatch(model.stem) or isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= SPEAKER_LIMIT:
|
|
continue
|
|
for speaker in range(count):
|
|
found[f"{model.stem}:{speaker}"] = (model.stem, speaker if count > 1 else None)
|
|
return found
|
|
|
|
|
|
def voice_of(key: str, pool: list[str], operator: str) -> str:
|
|
"""Args:
|
|
key: the speaker's session id or directory key; HUMAN is the operator.
|
|
pool: the installed voice ids other than the operator's, in their
|
|
fixed order.
|
|
operator: the operator's voice; empty when no voice is installed.
|
|
|
|
Returns:
|
|
operator for HUMAN or when the pool is empty, otherwise the voice
|
|
sha256(key) picks from the pool.
|
|
"""
|
|
if key == HUMAN or not pool:
|
|
return operator
|
|
return pool[int(digest(key), 16) % len(pool)]
|
|
|
|
|
|
def wav_seconds(body: bytes) -> float:
|
|
try:
|
|
with wave.open(io.BytesIO(body), "rb") as reader:
|
|
shape = (reader.getnchannels(), reader.getsampwidth(), reader.getframerate(), reader.getcomptype())
|
|
frames = reader.getnframes()
|
|
except (wave.Error, EOFError):
|
|
raise ToolError("the recording must be a WAV file") from None
|
|
if shape != (1, 2, STT_RATE, "NONE"):
|
|
raise ToolError(f"the recording must be {STT_RATE} Hz mono 16-bit PCM")
|
|
if not 0 < frames <= STT_RATE * STT_SECONDS:
|
|
raise ToolError(f"the recording must last more than 0 and at most {STT_SECONDS} seconds")
|
|
return frames / STT_RATE
|
|
|
|
|
|
def playable(path: Path) -> bool:
|
|
try:
|
|
with wave.open(str(path), "rb") as reader:
|
|
return reader.getnframes() > 0
|
|
except (OSError, wave.Error, EOFError):
|
|
return False
|
|
|
|
|
|
def heard(output: str) -> str:
|
|
return " ".join(NOISE.sub(" ", output).split())[:TEXT_LIMIT]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Engines:
|
|
piper: Path
|
|
voices: Path
|
|
operator_voice: str
|
|
whisper: Path
|
|
whisper_model: Path
|
|
scratch: Path
|
|
|
|
|
|
class Speech:
|
|
def __init__(self, engines: Engines, cache: Path) -> None:
|
|
self.engines = engines
|
|
self.cache = cache
|
|
cache.mkdir(exist_ok=True)
|
|
self.voices = installed_voices(engines.voices)
|
|
if self.voices and engines.operator_voice not in self.voices:
|
|
raise ToolError(f"the operator voice {engines.operator_voice!r} is not installed in {engines.voices}")
|
|
self.operator = engines.operator_voice if self.voices else ""
|
|
self.pool = [voice for voice in self.voices if voice != self.operator]
|
|
self.speaking = threading.Lock()
|
|
self.hearing = threading.Lock()
|
|
|
|
def env(self) -> dict[str, str]:
|
|
scratch = str(self.engines.scratch)
|
|
return {"PATH": SAFE_PATH, "HOME": scratch, "TMPDIR": scratch, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PYTHONIOENCODING": "utf-8", "PYTHONDONTWRITEBYTECODE": "1"}
|
|
|
|
def speaks(self) -> bool:
|
|
return bool(self.voices) and os.access(self.engines.piper, os.X_OK)
|
|
|
|
def hears(self) -> bool:
|
|
return os.access(self.engines.whisper, os.X_OK) and self.engines.whisper_model.is_file()
|
|
|
|
def voice_of(self, key: str) -> str:
|
|
return voice_of(key, self.pool, self.operator)
|
|
|
|
def describe(self) -> dict:
|
|
operator = self.voice_of(HUMAN)
|
|
sample = phrase("broadcast", HUMAN, [], "radio check")
|
|
return {"tts": self.speaks(), "stt": self.hears(), "voices": len(self.voices), "operator": operator, "sample": sample, "limit": SPEECH_LIMIT, "rate": STT_RATE, "seconds": STT_SECONDS}
|
|
|
|
def synthesize(self, body: bytes) -> bytes:
|
|
if not self.speaks():
|
|
raise Unavailable("speech output is off: no Piper voice or executable is installed")
|
|
payload = decode(body)
|
|
expect(payload, ("text", "voice"))
|
|
voice = payload["voice"]
|
|
if not isinstance(voice, str) or voice not in self.voices:
|
|
raise ToolError("voice must be one of the installed voices")
|
|
line = " ".join(bounded(payload, "text", SPEECH_LIMIT).split())
|
|
if not SPOKEN_LINE.fullmatch(line):
|
|
raise ToolError(f"text must be 1 to {SPEECH_LIMIT} printable characters")
|
|
key = digest(f"{voice}\n{line}")
|
|
target = self.cache / f"{key}.wav"
|
|
with self.speaking:
|
|
if not target.is_file():
|
|
self.render(voice, line, target)
|
|
self.trim()
|
|
os.utime(target)
|
|
return target.read_bytes()
|
|
|
|
def render(self, voice: str, line: str, target: Path) -> None:
|
|
model, speaker = self.voices[voice]
|
|
onnx = self.engines.voices / f"{model}.onnx"
|
|
partial = self.cache / f".{target.stem}-{secrets.token_hex(4)}.wav"
|
|
command = [str(self.engines.piper), "--model", str(onnx), "--config", f"{onnx}.json", "--output-file", str(partial)]
|
|
command += [] if speaker is None else ["--speaker", str(speaker)]
|
|
try:
|
|
done = subprocess.run(
|
|
command,
|
|
input=f"{line}\n",
|
|
capture_output=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=TTS_TIMEOUT,
|
|
env=self.env(),
|
|
cwd=self.engines.scratch,
|
|
check=False,
|
|
)
|
|
if done.returncode != 0 or not playable(partial):
|
|
raise EngineError(f"piper failed with exit code {done.returncode}: {done.stderr.strip()[-300:]}")
|
|
os.replace(partial, target)
|
|
except subprocess.TimeoutExpired:
|
|
raise EngineError(f"piper took longer than {TTS_TIMEOUT} seconds") from None
|
|
except OSError as error:
|
|
raise EngineError(f"piper did not start: {error.strerror}") from None
|
|
finally:
|
|
partial.unlink(missing_ok=True)
|
|
|
|
def trim(self) -> None:
|
|
kept = sorted(self.cache.glob("*.wav"), key=lambda path: path.stat().st_mtime, reverse=True)
|
|
for path in kept[TTS_KEEP:]:
|
|
path.unlink(missing_ok=True)
|
|
|
|
def transcribe(self, body: bytes) -> dict:
|
|
if not self.hears():
|
|
raise Unavailable("speech input is off: whisper.cpp or its model is missing")
|
|
wav_seconds(body)
|
|
command = [str(self.engines.whisper), "--model", str(self.engines.whisper_model), "--file", "-", "--output-txt", "--language", "auto", "--no-prints", "--suppress-nst"]
|
|
with self.hearing:
|
|
try:
|
|
done = subprocess.run(command, input=body, capture_output=True, timeout=STT_TIMEOUT, env=self.env(), cwd=self.engines.scratch, check=False)
|
|
except subprocess.TimeoutExpired:
|
|
raise EngineError(f"whisper.cpp took longer than {STT_TIMEOUT} seconds") from None
|
|
except OSError as error:
|
|
raise EngineError(f"whisper.cpp did not start: {error.strerror}") from None
|
|
if done.returncode != 0:
|
|
raise EngineError(f"whisper.cpp failed with exit code {done.returncode}: {done.stderr.decode('utf-8', errors='replace').strip()[-300:]}")
|
|
return {"text": heard(done.stdout.decode("utf-8", errors="replace"))}
|