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>
174 lines
5.9 KiB
JavaScript
174 lines
5.9 KiB
JavaScript
function loudness(kind) {
|
|
return Voice.loudness(kind, settings);
|
|
}
|
|
|
|
function speechFailed(lines) {
|
|
if (Date.now() - speech.warned > SPEECH_QUIET) {
|
|
speech.warned = Date.now();
|
|
toast("Funk vorlesen fehlgeschlagen", lines, false);
|
|
}
|
|
}
|
|
|
|
function say(item) {
|
|
speech.pending = Voice.enqueue(speech.pending, item, SPEECH_QUEUE);
|
|
pump();
|
|
}
|
|
|
|
async function pump() {
|
|
if (speech.busy || !speech.pending.length) {
|
|
return;
|
|
}
|
|
const sound = context();
|
|
if (!sound) {
|
|
speech.pending = [];
|
|
return;
|
|
}
|
|
const [item, ...rest] = speech.pending;
|
|
speech.pending = rest;
|
|
speech.busy = true;
|
|
const next = () => {
|
|
speech.busy = false;
|
|
pump();
|
|
};
|
|
try {
|
|
const response = await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Hub": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: item.text, voice: item.voice}), cache: "no-store"});
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${(await response.text()).trim()}`);
|
|
}
|
|
const buffer = await sound.decodeAudioData(await response.arrayBuffer());
|
|
const source = sound.createBufferSource();
|
|
const gain = sound.createGain();
|
|
gain.gain.value = loudness(item.tone);
|
|
source.buffer = buffer;
|
|
source.connect(gain).connect(sound.destination);
|
|
source.addEventListener("ended", next, {once: true});
|
|
duck(buffer.duration);
|
|
source.start();
|
|
} catch (error) {
|
|
speechFailed([String(error.message || error)]);
|
|
next();
|
|
}
|
|
}
|
|
|
|
function speakRows(rows) {
|
|
if (!settings.speech.on || !state.speech.tts) {
|
|
return;
|
|
}
|
|
for (const row of rows.filter((entry) => entry.ts >= state.now - SPEECH_FRESH && Voice.speakable(entry, settings.speech))) {
|
|
say({text: row.speech, voice: row.voice, tone: Voice.toneOf(row, HUMAN), cls: row.class});
|
|
}
|
|
}
|
|
|
|
function micButton(field) {
|
|
const button = h("button", {type: "button", class: "btn small ghost icon-btn mic", "aria-pressed": "false", "aria-label": "Spracheingabe: gedrückt halten und sprechen", title: "Gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet"}, "🎙");
|
|
const reason = !window.isSecureContext || !navigator.mediaDevices
|
|
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
|
|
: !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? "Spracheingabe ist aus: whisper.cpp oder sein Modell fehlt im Image" : "";
|
|
if (reason) {
|
|
button.disabled = true;
|
|
button.title = reason;
|
|
return button;
|
|
}
|
|
const press = (event) => {
|
|
event.preventDefault();
|
|
record(field, button);
|
|
};
|
|
button.addEventListener("pointerdown", press);
|
|
for (const name of ["pointerup", "pointerleave", "pointercancel"]) {
|
|
button.addEventListener(name, release);
|
|
}
|
|
button.addEventListener("keydown", (event) => {
|
|
if ((event.key === " " || event.key === "Enter") && !event.repeat) {
|
|
press(event);
|
|
}
|
|
});
|
|
button.addEventListener("keyup", (event) => {
|
|
if (event.key === " " || event.key === "Enter") {
|
|
event.preventDefault();
|
|
release();
|
|
}
|
|
});
|
|
return button;
|
|
}
|
|
|
|
async function record(field, button) {
|
|
if (mic.held || mic.recorder || mic.busy) {
|
|
return;
|
|
}
|
|
mic.held = true;
|
|
let media = null;
|
|
try {
|
|
media = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true}});
|
|
} catch (error) {
|
|
mic.held = false;
|
|
toast("Mikrofon nicht verfügbar", [String(error.message || error)], false);
|
|
return;
|
|
}
|
|
if (!mic.held) {
|
|
media.getTracks().forEach((track) => track.stop());
|
|
return;
|
|
}
|
|
const recorder = new MediaRecorder(media);
|
|
const chunks = [];
|
|
recorder.addEventListener("dataavailable", (event) => {
|
|
if (event.data.size) {
|
|
chunks.push(event.data);
|
|
}
|
|
});
|
|
recorder.addEventListener("stop", () => {
|
|
media.getTracks().forEach((track) => track.stop());
|
|
mic.recorder = null;
|
|
button.setAttribute("aria-pressed", "false");
|
|
transcribe(new Blob(chunks, {type: recorder.mimeType}), field, button);
|
|
}, {once: true});
|
|
mic.recorder = recorder;
|
|
recorder.start();
|
|
button.setAttribute("aria-pressed", "true");
|
|
mic.timer = window.setTimeout(release, state.speech.seconds * 1000);
|
|
}
|
|
|
|
function release() {
|
|
mic.held = false;
|
|
window.clearTimeout(mic.timer);
|
|
if (mic.recorder && mic.recorder.state === "recording") {
|
|
mic.recorder.stop();
|
|
}
|
|
}
|
|
|
|
async function transcribe(blob, field, button) {
|
|
const sound = context();
|
|
if (!sound || !blob.size) {
|
|
return;
|
|
}
|
|
mic.busy = true;
|
|
button.disabled = true;
|
|
try {
|
|
const decoded = await sound.decodeAudioData(await blob.arrayBuffer());
|
|
const rate = state.speech.rate;
|
|
const length = Voice.frames(decoded.duration, rate, state.speech.seconds);
|
|
if (length < rate / 4) {
|
|
throw new Error("zu kurz: halte die Taste, solange du sprichst");
|
|
}
|
|
const offline = new OfflineAudioContext(1, length, rate);
|
|
const source = offline.createBufferSource();
|
|
source.buffer = decoded;
|
|
source.connect(offline.destination);
|
|
source.start();
|
|
const rendered = await offline.startRendering();
|
|
const result = await send("/api/stt", Voice.wav(rendered.getChannelData(0), rate), "audio/wav");
|
|
if (!result.ok || !result.data) {
|
|
throw new Error(`${result.status} ${result.text}`.trim());
|
|
}
|
|
if (result.data.text) {
|
|
field.value = [field.value.trim(), result.data.text].filter(Boolean).join(" ");
|
|
field.dispatchEvent(new Event("input", {bubbles: true}));
|
|
}
|
|
field.focus();
|
|
} catch (error) {
|
|
toast("Spracheingabe fehlgeschlagen", [String(error.message || error)], false);
|
|
} finally {
|
|
mic.busy = false;
|
|
button.disabled = false;
|
|
}
|
|
}
|