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>
125 lines
6.6 KiB
JavaScript
125 lines
6.6 KiB
JavaScript
function build(node, attrs, children) {
|
||
for (const [key, value] of Object.entries(attrs)) {
|
||
if (key === "on") {
|
||
for (const [name, handler] of Object.entries(value)) {
|
||
node.addEventListener(name, handler);
|
||
}
|
||
} else if (value !== null && value !== undefined && value !== false) {
|
||
node.setAttribute(key, value === true ? "" : String(value));
|
||
}
|
||
}
|
||
for (const child of children.flat(Infinity)) {
|
||
if (child !== null && child !== undefined && child !== false) {
|
||
node.append(child instanceof Node ? child : String(child));
|
||
}
|
||
}
|
||
return node;
|
||
}
|
||
const h = (tag, attrs, ...children) => build(document.createElement(tag), attrs, children);
|
||
const s = (tag, attrs, ...children) => build(document.createElementNS(NS, tag), attrs, children);
|
||
const dash = (value) => (value === "" || value === "-" || value === null || value === undefined ? "–" : value);
|
||
const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "alle");
|
||
const fixed = (value) => value.toFixed(1);
|
||
const short = (text) => (text.length > 80 ? `${text.slice(0, 80)}…` : text);
|
||
const btn = (label, cls, run) => h("button", {type: "button", class: cls, on: {click: run}}, label);
|
||
const nowSeconds = () => Date.now() / 1000;
|
||
|
||
function ago(ts) {
|
||
const diff = ts - nowSeconds();
|
||
const size = Math.abs(diff);
|
||
if (size < 45) {
|
||
return "gerade eben";
|
||
}
|
||
if (size < 3600) {
|
||
return relative.format(Math.round(diff / 60), "minute");
|
||
}
|
||
if (size < DAY) {
|
||
return relative.format(Math.round(diff / 3600), "hour");
|
||
}
|
||
return relative.format(Math.round(diff / DAY), "day");
|
||
}
|
||
const stampOf = (ts) => new Date(ts * 1000);
|
||
const when = (ts) => h("time", {"data-ts": ts, datetime: stampOf(ts).toISOString(), title: absolute.format(stampOf(ts))}, ago(ts));
|
||
const pill = (text, tone) => h("span", {class: "pill", "data-tone": tone}, text);
|
||
const status = (value) => pill((STATUS[value] || [value])[0], (STATUS[value] || [value, "neutral"])[1]);
|
||
const klass = (value) => pill(CLASSES[value] || value, PILLS[value] || "neutral");
|
||
const counter = (value) => h("span", {class: "count"}, String(value));
|
||
const meta = (pairs) => h("dl", {class: "meta"}, pairs.map(([label, value, title]) => h("div", {}, h("dt", {}, label), h("dd", {}, h("code", {title: title || null}, dash(value))))));
|
||
const emptyState = (title, text) => h("div", {class: "empty"}, h("div", {class: "empty-mark", "aria-hidden": "true"}), h("h2", {}, title), h("p", {}, text));
|
||
const live = () => ui.preset !== -1 && ui.asOf === null;
|
||
const alarmRows = () => state.radio.filter((row) => (row.class === "mayday" || row.class === "pan-pan") && row.ts >= state.alarm_since).reverse();
|
||
const keyOf = (item) => `${item.kind}:${item.id}`;
|
||
const visibleInbox = () => state.inbox.filter((item) => ui.snoozed || !item.until);
|
||
const waitingCount = () => state.inbox.filter((item) => !item.until).length;
|
||
const isGreen = (item) => item.green && !item.held && !item.until && item.kind !== "operator";
|
||
const selectable = (item) => !item.held && !(item.kind === "review" && item.gate !== "pass") && !(item.kind === "operator" && !item.runnable) && !(item.kind === "decision" && !item.recommended);
|
||
const entryOf = (item, verb, label) => ({kind: item.kind, id: item.id, verb, label, sha256: item.sha256 || ""});
|
||
|
||
function tick() {
|
||
for (const node of document.querySelectorAll("time[data-ts]")) {
|
||
node.textContent = ago(Number(node.dataset.ts));
|
||
}
|
||
const updated = document.getElementById("updated");
|
||
updated.classList.toggle("offline", ui.offline);
|
||
updated.textContent = `${ui.offline ? "Keine Verbindung · " : ""}${live() ? "Live" : "Stand"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
|
||
}
|
||
|
||
function avatar(name) {
|
||
let hash = 2166136261;
|
||
for (const char of String(name)) {
|
||
hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0;
|
||
}
|
||
return EMOJI[hash % EMOJI.length];
|
||
}
|
||
const face = (name, big) => h("span", {class: big ? "avatar big" : "avatar", "aria-hidden": "true"}, avatar(name));
|
||
const person = (name) => (name && name !== "-" && name !== "alle" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `${name} öffnen`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
||
const people = () => state.people || {};
|
||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||
const nameOf = (key) => (people()[key] || {now: String(key).replace(/^name:/, "")}).now;
|
||
const agentKey = (who) => (people()[who] ? who : (Object.entries(people()).find(([, entry]) => entry.now === who || entry.names.includes(who)) || [who])[0]);
|
||
const chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: CHAIR}).id;
|
||
const stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · ");
|
||
|
||
function toast(title, lines, good) {
|
||
const box = h("div", {class: `toast${good ? "" : " bad"}`, role: "status", title: "Schließen"}, h("strong", {}, title), lines.length ? h("pre", {}, lines.join("\n")) : null);
|
||
box.addEventListener("click", () => box.remove());
|
||
document.getElementById("toasts").append(box);
|
||
window.setTimeout(() => box.remove(), good ? 9000 : 20000);
|
||
}
|
||
|
||
function runLines(data) {
|
||
return (data.runs || []).flatMap((run) => [`$ ${run.command} → ${run.code}`, run.stdout.trim(), run.stderr.trim()].filter(Boolean));
|
||
}
|
||
|
||
function copyButton(text) {
|
||
const button = h("button", {type: "button", class: "btn small ghost", title: "In die Zwischenablage kopieren"}, "Kopieren");
|
||
button.addEventListener("click", async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
button.textContent = "Kopiert";
|
||
} catch {
|
||
button.textContent = "Nicht möglich";
|
||
}
|
||
window.setTimeout(() => {
|
||
button.textContent = "Kopieren";
|
||
}, 1600);
|
||
});
|
||
return button;
|
||
}
|
||
|
||
function openSheet(title, body, wide) {
|
||
const sheet = document.getElementById("sheet");
|
||
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
|
||
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeSheet}}, "×")),
|
||
h("div", {class: "sheet-body"}, body)));
|
||
sheet.hidden = false;
|
||
}
|
||
|
||
function closeSheet() {
|
||
const sheet = document.getElementById("sheet");
|
||
sheet.hidden = true;
|
||
sheet.replaceChildren();
|
||
reviewDom = null;
|
||
ui.review = null;
|
||
}
|