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>
152 lines
6.7 KiB
JavaScript
152 lines
6.7 KiB
JavaScript
function paletteItems() {
|
||
return [
|
||
...SCREENS.map(([id, label], index) => ({label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)})),
|
||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Ansicht", run: () => useView(id)})),
|
||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
|
||
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
||
...ACTIONS.map(([id, label]) => ({label, hint: "Aktion", run: () => confirmAct(id)})),
|
||
{label: "Steuerung …", hint: "Rundruf, Nacht, Priorität", run: boardSheet},
|
||
{label: "Regeln …", hint: "automatische Freigaben", run: rulesSheet},
|
||
{label: "Einstellungen …", hint: "Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
|
||
{label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic},
|
||
{label: space.look ? "Himmel verlassen" : "Himmel ansehen", hint: "h", run: () => skyLook(!space.look)},
|
||
{label: "Leitstand umschalten", hint: "Vollbild", run: toggleLeitstand},
|
||
{label: "Tastenkürzel", hint: "?", run: helpSheet},
|
||
];
|
||
}
|
||
|
||
function drawPalette() {
|
||
if (palette.plan) {
|
||
return;
|
||
}
|
||
const raw = palette.input.value.trim();
|
||
const q = raw.toLowerCase();
|
||
const items = paletteItems().filter((item) => !q || item.label.toLowerCase().includes(q) || item.hint.toLowerCase().includes(q));
|
||
if (raw) {
|
||
items.push(state.llm
|
||
? {label: `KI-Befehl: „${raw}“`, hint: "Tab", keep: true, run: () => askModel(raw)}
|
||
: {label: "KI-Befehl ist aus", hint: "KVB_HUB_LLM_URL und KVB_HUB_LLM_MODEL in hub/.env setzen", keep: true, run: () => toast("KI-Befehl ist aus", ["Setze KVB_HUB_LLM_URL, KVB_HUB_LLM_MODEL und KVB_HUB_LLM_KEY_FILE in hub/.env und installiere neu."], false)});
|
||
}
|
||
palette.items = items;
|
||
palette.index = Math.min(palette.index, Math.max(items.length - 1, 0));
|
||
palette.list.replaceChildren(...items.map((item, index) => h("li", {role: "option", "aria-selected": String(index === palette.index), on: {
|
||
click: () => runPalette(index),
|
||
mousemove: () => {
|
||
if (palette.index !== index) {
|
||
palette.index = index;
|
||
drawPalette();
|
||
}
|
||
},
|
||
}}, h("span", {}, item.label), h("span", {class: "muted"}, item.hint))));
|
||
const current = palette.list.children[palette.index];
|
||
if (current) {
|
||
current.scrollIntoView({block: "nearest"});
|
||
}
|
||
}
|
||
|
||
function runPalette(index) {
|
||
const item = palette.items[index];
|
||
if (!item) {
|
||
return;
|
||
}
|
||
if (!item.keep) {
|
||
closePalette();
|
||
}
|
||
item.run();
|
||
}
|
||
|
||
async function askModel(text) {
|
||
palette.plan = {actions: [], rejected: [], busy: true};
|
||
palette.list.replaceChildren(h("li", {}, h("span", {class: "muted"}, "Frage das Modell … (nur Befehl, Ids und Titel gehen hinaus)")));
|
||
const result = await sendJson("/api/nl", {text});
|
||
if (!palette.plan) {
|
||
return;
|
||
}
|
||
if (!result.ok || !result.data) {
|
||
palette.plan = null;
|
||
palette.list.replaceChildren(h("li", {}, h("span", {}, `KI-Befehl fehlgeschlagen: ${result.status} ${result.text}`.trim())));
|
||
return;
|
||
}
|
||
palette.plan = result.data;
|
||
const actions = result.data.actions.map((action) => h("pre", {}, action.summary));
|
||
const rejected = result.data.rejected.map((entry) => h("p", {class: "muted"}, `verworfen: ${entry.reason} · ${entry.action}`));
|
||
palette.list.replaceChildren(h("li", {class: "palette-plan"},
|
||
actions.length ? actions : h("p", {class: "muted"}, "Das Modell schlug keine gültige Aktion vor."),
|
||
rejected,
|
||
h("p", {class: "muted"}, actions.length ? "Enter führt genau diese Aktionen aus · Esc bricht ab" : "Esc schließt")));
|
||
}
|
||
|
||
async function executePlan(plan) {
|
||
for (const action of plan.actions) {
|
||
const args = action.args;
|
||
if (action.name === "approve" || action.name === "reject" || action.name === "choose") {
|
||
await submit(args.items, action.summary.split("\n")[0]);
|
||
} else if (action.name === "snooze" || action.name === "pin") {
|
||
for (const key of args.keys) {
|
||
const item = state.inbox.find((entry) => entry.kind === key.kind && entry.id === key.id);
|
||
if (item) {
|
||
await mark(item, action.name === "snooze" ? nowSeconds() + args.hours * 3600 : item.until, action.name === "pin" ? true : item.pinned);
|
||
}
|
||
}
|
||
} else if (action.name === "view") {
|
||
useView(args.name);
|
||
} else if (action.name === "open") {
|
||
go(args.view, null);
|
||
} else if (action.name === "message") {
|
||
await message(args.to, args.text, "");
|
||
} else if (action.name === "filter") {
|
||
filter.q = args.q;
|
||
if (ui.screen === "stream" && stream.dom) {
|
||
stream.dom.search.value = args.q;
|
||
refilter();
|
||
} else {
|
||
go("stream", null);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function openPalette() {
|
||
const box = document.getElementById("palette");
|
||
palette.input = h("input", {type: "text", placeholder: "Ansicht, Agent, Aktion – oder ein Befehl in Worten …", "aria-label": "Befehl", role: "combobox", "aria-expanded": "true"});
|
||
palette.list = h("ul", {class: "palette-list", role: "listbox"});
|
||
palette.index = 0;
|
||
palette.plan = null;
|
||
palette.input.addEventListener("input", () => {
|
||
palette.index = 0;
|
||
palette.plan = null;
|
||
drawPalette();
|
||
});
|
||
palette.input.addEventListener("keydown", (event) => {
|
||
if (palette.plan && event.key === "Enter") {
|
||
event.preventDefault();
|
||
const plan = palette.plan;
|
||
if (!plan.busy && plan.actions.length) {
|
||
closePalette();
|
||
executePlan(plan);
|
||
}
|
||
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
const size = Math.max(palette.items.length, 1);
|
||
palette.index = (palette.index + (event.key === "ArrowDown" ? 1 : size - 1)) % size;
|
||
drawPalette();
|
||
} else if (event.key === "Tab" && palette.input.value.trim() && !palette.plan) {
|
||
event.preventDefault();
|
||
palette.index = palette.items.length - 1;
|
||
runPalette(palette.index);
|
||
} else if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
runPalette(palette.index);
|
||
}
|
||
});
|
||
box.replaceChildren(h("div", {class: "palette-box", role: "dialog", "aria-label": "Befehle"}, h("div", {class: "palette-row"}, palette.input, micButton(palette.input)), palette.list));
|
||
box.hidden = false;
|
||
drawPalette();
|
||
palette.input.focus();
|
||
}
|
||
|
||
function closePalette() {
|
||
document.getElementById("palette").hidden = true;
|
||
palette.plan = null;
|
||
}
|