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>
137 lines
7.0 KiB
JavaScript
137 lines
7.0 KiB
JavaScript
function history(key) {
|
||
const known = (people()[key] || {names: [nameOf(key)]}).names;
|
||
const radio = state.radio.filter((row) => row.source === key || row.targets.includes(key)).map((row) => ({ts: row.ts, mine: row.from === HUMAN, who: row.from, text: row.text}));
|
||
const sent = state.messages.filter((message) => known.includes(message.to) || message.to === "*").map((message) => ({ts: message.created_ts, mine: true, who: HUMAN, text: message.upload ? `${message.text}\n📎 ${message.upload}` : message.text, id: message.id}));
|
||
const hubIds = new Set(sent.map((entry) => entry.id));
|
||
const relayed = radio.filter((entry) => !(entry.who === HUMAN && [...hubIds].some((id) => entry.text.includes(`[kvb-hub msg ${id}]`))));
|
||
return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80);
|
||
}
|
||
|
||
function drawDrawer() {
|
||
if (!drawerDom) {
|
||
return;
|
||
}
|
||
const node = state.graph.nodes.find((entry) => entry.id === ui.agent);
|
||
const tasks = node ? node.tasks : [];
|
||
const claims = node ? node.claims : [];
|
||
drawerDom.info.replaceChildren(
|
||
h("h3", {}, "Aufgaben", counter(tasks.length)),
|
||
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
|
||
h("div", {class: "row"}, h("code", {}, task.task), status(task.status)),
|
||
h("span", {}, dash(task.title)),
|
||
h("span", {class: "roles"}, [task.worker ? "Worker" : "", task.letters ? `Rollen ${task.letters}` : ""].filter(Boolean).join(" · "))))) : h("p", {class: "muted"}, "Keine offenen Aufgaben."),
|
||
h("h3", {}, "Claims", counter(claims.length)),
|
||
claims.length ? h("ul", {class: "mini"}, claims.map((claim) => h("li", {},
|
||
h("div", {class: "row"}, h("code", {}, claim.office), claim.beat === null ? null : h("span", {class: "muted"}, "Puls ", when(claim.beat))),
|
||
h("span", {}, dash(claim.work)),
|
||
h("span", {class: "muted"}, `${dash(claim.runtime)} · seit ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "Keine Claims."));
|
||
const rows = history(ui.agent);
|
||
drawerDom.history.replaceChildren(rows.length
|
||
? h("div", {}, rows.map((row) => h("div", {class: `bubble ${row.mine ? "mine" : "theirs"}`}, h("span", {class: "muted"}, `${row.who} · `, when(row.ts)), row.text)))
|
||
: h("p", {class: "muted"}, "Kein Verlauf im gewählten Zeitraum."));
|
||
}
|
||
|
||
async function loadSubagents(key, holder, life) {
|
||
if (!SESSION_ID.test(key)) {
|
||
holder.replaceChildren(h("p", {class: "muted"}, "Keine Session bekannt."));
|
||
return;
|
||
}
|
||
let data = null;
|
||
try {
|
||
const response = await fetch(`/api/subagents?session=${encodeURIComponent(key)}`, {cache: "no-store"});
|
||
data = response.ok ? await response.json() : null;
|
||
} catch {
|
||
data = null;
|
||
}
|
||
if (!data) {
|
||
holder.replaceChildren(h("p", {class: "muted"}, "Subagenten nicht lesbar."));
|
||
return;
|
||
}
|
||
life.textContent = stamp(data);
|
||
const rows = data.subagents.map((row) => ({...row, session: key})).sort((a, b) => a.started - b.started);
|
||
holder.replaceChildren(rows.length ? h("ul", {class: "mini"}, rows.map((row) => {
|
||
const item = h("li", {},
|
||
h("div", {class: "row"}, h("button", {type: "button", class: "linkish", title: "Transkript im Stream zeigen", on: {click: () => focusSub(row.session, row.id)}}, `${row.type}: ${row.description}`), row.finished ? pill("fertig", "neutral") : pill("läuft", "ok")),
|
||
h("span", {class: "muted"}, `Tiefe ${row.depth} · `, when(row.started), stamp(row) ? ` · ${stamp(row)}` : ""));
|
||
item.style.marginLeft = `${(row.depth - 1) * 18}px`;
|
||
return item;
|
||
})) : h("p", {class: "muted"}, "Keine Subagenten."));
|
||
}
|
||
|
||
async function deliver(text, file) {
|
||
let upload = "";
|
||
if (file) {
|
||
if (file.size > UPLOAD_LIMIT) {
|
||
toast("Datei zu groß", [`${file.name}: höchstens 50 MiB`], false);
|
||
return false;
|
||
}
|
||
const stored = await send(`/api/uploads?name=${encodeURIComponent(file.name)}`, file, "application/octet-stream");
|
||
if (!stored.ok || !stored.data) {
|
||
toast("Upload fehlgeschlagen", [`${stored.status} ${stored.text}`.trim()], false);
|
||
return false;
|
||
}
|
||
upload = stored.data.id;
|
||
}
|
||
return message(nameOf(ui.agent), text, upload);
|
||
}
|
||
|
||
async function message(to, text, upload) {
|
||
const result = await sendJson("/api/messages", {to, text, upload});
|
||
if (!result.ok || !result.data) {
|
||
toast(`Nachricht an ${to} fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
|
||
return false;
|
||
}
|
||
toast(`Nachricht an ${to}: ${result.data.ok ? "gefunkt" : "gespeichert, Funk fehlgeschlagen"}`, [`kvb-hub msg ${result.data.id}`, ...runLines(result.data)], result.data.ok);
|
||
await load();
|
||
return true;
|
||
}
|
||
|
||
function openAgent(who) {
|
||
const drawer = document.getElementById("drawer");
|
||
const key = agentKey(who);
|
||
const name = nameOf(key);
|
||
ui.agent = key;
|
||
const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`});
|
||
const file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"});
|
||
const button = h("button", {type: "submit", class: "btn primary"}, "Senden");
|
||
const form = h("form", {class: "drawer-form"}, text, file, h("div", {class: "actions"}, h("span", {class: "muted"}, "Funk direkt von Operator, prüfbar mit operator_message"), h("span", {class: "spacer"}), micButton(text), button));
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (!text.value.trim()) {
|
||
return;
|
||
}
|
||
button.disabled = true;
|
||
if (await deliver(text.value, file.files[0] || null)) {
|
||
text.value = "";
|
||
file.value = "";
|
||
}
|
||
button.disabled = false;
|
||
});
|
||
const info = h("div", {});
|
||
const subs = h("div", {}, h("p", {class: "muted"}, "Lade Subagenten …"));
|
||
const log = h("div", {"aria-live": "polite"});
|
||
const life = h("span", {class: "muted"});
|
||
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
||
drawer.replaceChildren(
|
||
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · früher ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
|
||
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeAgent}}, "×")),
|
||
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagenten"), subs, h("h3", {}, "Verlauf"), log),
|
||
form);
|
||
drawer.hidden = false;
|
||
drawerDom = {info, history: log};
|
||
drawDrawer();
|
||
loadSubagents(key, subs, life);
|
||
text.focus();
|
||
}
|
||
|
||
function closeAgent() {
|
||
document.getElementById("drawer").hidden = true;
|
||
drawerDom = null;
|
||
ui.agent = null;
|
||
}
|
||
|
||
function agentNames() {
|
||
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.priority]);
|
||
return [...names].sort((a, b) => a.localeCompare(b));
|
||
}
|