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
9.5 KiB
JavaScript
140 lines
9.5 KiB
JavaScript
function adoptSettings(next) {
|
||
settings = next;
|
||
settingsSig = JSON.stringify(next);
|
||
if (space.edits === space.saved) {
|
||
space.views = {graph: {...next.space.graph}, sky: {...next.space.sky}};
|
||
space.glides = {graph: null, sky: null};
|
||
requestSpace();
|
||
}
|
||
sky.mode = "";
|
||
drawSky();
|
||
syncMusic();
|
||
syncRadio();
|
||
musicButton();
|
||
}
|
||
|
||
async function saveSettings(next) {
|
||
const result = await sendJson("/api/settings", next);
|
||
if (!result.ok || !result.data) {
|
||
toast("Einstellungen nicht gespeichert", [`${result.status} ${result.text}`.trim()], false);
|
||
return false;
|
||
}
|
||
const fresh = result.data.streams.map((stream) => new URL(stream.url).origin).filter((origin) => !pageMedia.has(origin));
|
||
adoptSettings(result.data);
|
||
if (fresh.length) {
|
||
toast("Neuer Sender", ["Die Seite lädt neu, damit der Browser ihn abspielen darf."], true);
|
||
window.setTimeout(() => window.location.reload(), 1500);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function streamRow(stream, rows, list) {
|
||
const field = (attrs) => h("input", {class: "field", required: true, ...attrs});
|
||
const name = field({type: "text", value: stream.name, placeholder: "Sendername", maxlength: 120});
|
||
const url = field({type: "url", value: stream.url, placeholder: "https://…"});
|
||
const from = field({type: "time", value: stream.from});
|
||
const to = field({type: "time", value: stream.to === "24:00" ? "00:00" : stream.to});
|
||
const on = h("input", {type: "checkbox", checked: stream.on});
|
||
const days = WEEKDAYS.map((label, index) => h("input", {type: "checkbox", checked: stream.days.includes(index), "aria-label": label}));
|
||
const entry = {read: () => ({
|
||
name: name.value.trim(),
|
||
url: url.value.trim(),
|
||
from: from.value,
|
||
to: to.value === "00:00" && stream.to === "24:00" ? "24:00" : to.value,
|
||
days: days.flatMap((box, index) => (box.checked ? [index] : [])),
|
||
on: on.checked,
|
||
})};
|
||
entry.row = h("div", {class: "stream-row"},
|
||
h("div", {class: "grid"}, h("label", {}, "Name", name), h("label", {}, "Stream-URL", url), h("label", {}, "von", from), h("label", {}, "bis", to)),
|
||
h("div", {class: "days"}, days.map((box, index) => h("label", {class: "check"}, box, WEEKDAYS[index])), h("label", {class: "check"}, on, "aktiv"), h("span", {class: "spacer"}),
|
||
btn("Entfernen", "btn small danger", () => {
|
||
rows.splice(rows.indexOf(entry), 1);
|
||
entry.row.remove();
|
||
})));
|
||
rows.push(entry);
|
||
list.append(entry.row);
|
||
}
|
||
|
||
function settingsSheet() {
|
||
const number = (value, min, max, label) => h("input", {class: "field", type: "number", min, max, step: "any", value, required: true, "aria-label": label});
|
||
const latitude = number(settings.latitude, -90, 90, "Breite");
|
||
const longitude = number(settings.longitude, -180, 180, "Länge");
|
||
const theme = h("select", {class: "field"}, THEME_CHOICES.map(([id, label]) => h("option", {value: id, selected: settings.theme === id}, label)));
|
||
const colors = Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, h("input", {type: "color", value: settings.colors[id]})]));
|
||
const custom = h("div", {class: "grid"}, COLOR_FIELDS.map(([id, label]) => h("label", {}, label, colors[id])));
|
||
const verdict = h("p", {class: "muted", "aria-live": "polite"});
|
||
const judge = () => {
|
||
custom.hidden = theme.value !== "custom";
|
||
verdict.hidden = theme.value !== "custom";
|
||
const panel = Ambience.THEMES[Ambience.modeOf(colors.top.value, colors.bottom.value)].panel;
|
||
const ratio = Ambience.contrast(Ambience.hex(colors.accent.value), Ambience.hex(panel));
|
||
verdict.textContent = `Akzent auf Flächen: Kontrast ${ratio.toFixed(1)} : 1${ratio < 3 ? " – zu schwach, wähle einen kräftigeren Akzent" : ""}`;
|
||
};
|
||
theme.addEventListener("change", judge);
|
||
Object.values(colors).forEach((input) => input.addEventListener("input", judge));
|
||
judge();
|
||
const musicOn = h("input", {type: "checkbox", checked: settings.music.on});
|
||
const volume = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.music.volume, "aria-label": "Gesamtlautstärke"});
|
||
const tones = SOUND_KINDS.map(([id, label]) => {
|
||
const on = h("input", {type: "checkbox", checked: settings.sounds[id].on, "aria-label": `${label} an`});
|
||
const level = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.sounds[id].volume, "aria-label": `${label} Lautstärke`});
|
||
const loudest = () => Math.max(...tones.map((tone) => Number(tone.level.value)), Number(volume.value));
|
||
return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "an"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))};
|
||
});
|
||
const notify = h("input", {type: "checkbox", checked: settings.notify});
|
||
const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts});
|
||
const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday"})}));
|
||
const stations = Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now))
|
||
.map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `${entry.now} vorlesen`})}));
|
||
const absent = settings.speech.muted.filter((key) => !people()[key]);
|
||
const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "direct"}));
|
||
sample.disabled = !state.speech.tts;
|
||
const list = h("div", {class: "mini"});
|
||
const rows = [];
|
||
settings.streams.forEach((stream) => streamRow(stream, rows, list));
|
||
const form = h("form", {class: "settings"},
|
||
h("section", {}, h("h3", {}, "Ort"), h("p", {class: "muted"}, "Sonne, Mond und Sterne stehen dort, wo du sie von hier aus siehst, Blick nach Süden; unter 🔭 Himmel (Taste h) drehst du den Blick. Dezimalgrad, Nord und Ost positiv."),
|
||
h("div", {class: "grid"}, h("label", {}, "Breite", latitude), h("label", {}, "Länge", longitude))),
|
||
h("section", {}, h("h3", {}, "Darstellung"), h("label", {}, "Farben", theme), custom, verdict),
|
||
h("section", {}, h("h3", {}, "Musik"), h("p", {class: "muted"}, "Generative Ambient-Musik, offline erzeugt: ruhig bei leerer Inbox, etwas gespannter, wenn etwas wartet, leiser unter Meldungen."),
|
||
h("div", {class: "grid"}, h("label", {class: "check"}, musicOn, "Musik an"), h("label", {}, "Gesamtlautstärke (Musik und Radio)", volume))),
|
||
h("section", {}, h("h3", {}, "Töne und Benachrichtigungen"), h("p", {class: "muted"}, "MAYDAY und PAN-PAN sind immer am lautesten. Bei mehreren neuen Ereignissen spielt nur das dringendste."),
|
||
tones.map((tone) => tone.row), h("label", {class: "check"}, notify, "Desktop-Benachrichtigungen zeigen, wenn der Browser sie erlaubt")),
|
||
h("section", {}, h("h3", {}, "Funk vorlesen"),
|
||
h("p", {class: "muted"}, state.speech.tts
|
||
? "Neuer Funk wird in Seefunk-Sprechweise (SMCP) vorgelesen, eine Meldung nach der anderen, jede Station mit eigener Stimme; Musik und Radio werden solange leiser. Die Lautstärke folgt den Tönen oben, MAYDAY wird immer und am lautesten gesprochen."
|
||
: "Piper oder seine Stimmen fehlen im Image; der Funk bleibt stumm."),
|
||
h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Funk vorlesen"), sample),
|
||
h("div", {class: "days", role: "group", "aria-label": "Klassen"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))),
|
||
stations.length ? h("div", {class: "days", role: "group", "aria-label": "Stationen"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null),
|
||
h("section", {}, h("h3", {}, "Radio"), h("p", {class: "muted"}, "Internetradio zu festen Zeiten mit 3 s Ein- und Ausblenden; die Musik pausiert solange. Die Seite darf nur von diesen Adressen Ton laden."),
|
||
list, h("div", {}, btn("+ Sender", "btn small soft", () => streamRow({name: "", url: "", from: "07:00", to: "09:00", days: [0, 1, 2, 3, 4], on: true}, rows, list)))),
|
||
passkeySection(),
|
||
h("div", {class: "sheet-foot"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), h("button", {type: "submit", class: "btn primary"}, "Speichern")));
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
context();
|
||
const next = {
|
||
latitude: Number(latitude.value),
|
||
longitude: Number(longitude.value),
|
||
theme: theme.value,
|
||
colors: Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, colors[id].value])),
|
||
music: {on: musicOn.checked, volume: Number(volume.value)},
|
||
sounds: Object.fromEntries(tones.map((tone) => [tone.id, {on: tone.on.checked, volume: Number(tone.level.value)}])),
|
||
notify: notify.checked,
|
||
streams: rows.map((entry) => entry.read()),
|
||
speech: {
|
||
on: speechOn.checked,
|
||
classes: spoken.filter((entry) => entry.box.checked).map((entry) => entry.id),
|
||
muted: [...absent, ...stations.filter((entry) => !entry.box.checked).map((entry) => entry.key)],
|
||
},
|
||
space: savedSpace(),
|
||
};
|
||
if (await saveSettings(next)) {
|
||
closeSheet();
|
||
toast("Einstellungen gespeichert", [], true);
|
||
}
|
||
});
|
||
openSheet("Einstellungen", [form], false);
|
||
}
|