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>
150 lines
6.6 KiB
JavaScript
150 lines
6.6 KiB
JavaScript
function fileList(id) {
|
|
if (!files.has(id)) {
|
|
files.set(id, "loading");
|
|
fetch(`/reviews/${encodeURIComponent(id)}/files`, {cache: "no-store"})
|
|
.then((response) => (response.ok ? response.json() : {files: null}))
|
|
.then((data) => files.set(id, Array.isArray(data.files) ? data.files : null))
|
|
.catch(() => files.set(id, null))
|
|
.then(() => {
|
|
if (ui.review === id) {
|
|
drawReviewSheet();
|
|
}
|
|
});
|
|
}
|
|
return files.get(id);
|
|
}
|
|
|
|
function reviewOf(id) {
|
|
const pending = state.reviews.find((review) => review.id === id);
|
|
if (pending) {
|
|
return pending;
|
|
}
|
|
const entry = stream.events.find((row) => row.kind === "review" && row.ref.id === id);
|
|
return entry ? {id, title: entry.title, notes: entry.text, created_ts: entry.ts, worktree: entry.ref.worktree, branch: entry.ref.branch, tree: entry.ref.tree, gate: entry.ref.gate, score: entry.ref.score, status: entry.ref.status} : null;
|
|
}
|
|
|
|
function reviewSheet(id) {
|
|
ui.review = id;
|
|
ui.file = 0;
|
|
drawReviewSheet();
|
|
}
|
|
|
|
function drawReviewSheet() {
|
|
const review = reviewOf(ui.review);
|
|
if (!review) {
|
|
closeSheet();
|
|
toast("Review nicht mehr im Zeitraum", [], false);
|
|
return;
|
|
}
|
|
const item = state.inbox.find((entry) => entry.kind === "review" && entry.id === review.id);
|
|
const list = fileList(review.id);
|
|
const treeNav = h("nav", {class: "card tree", "aria-label": "Geänderte Dateien"}, treePane(list));
|
|
const diff = h("section", {class: "card diff-pane", "aria-label": "Diff"}, diffPane(list));
|
|
const id = ui.review;
|
|
const head = h("article", {class: "card"},
|
|
h("div", {class: "card-head"}, h("div", {}, h("p", {class: "muted"}, "eingereicht ", when(review.created_ts))), h("div", {class: "who"}, status(review.status || "pending"), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null)),
|
|
meta([["Repo", review.worktree], ["Branch", review.branch], ["Tree", (review.tree || "").slice(0, 12), review.tree], ["Score", review.score]]),
|
|
review.notes ? h("pre", {class: "notes"}, review.notes) : null,
|
|
h("div", {class: "actions"},
|
|
h("a", {class: "btn ghost", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer"}, "Vollständige Seite öffnen ↗"),
|
|
h("span", {class: "spacer"}),
|
|
item ? actionsFor(item).map((node) => {
|
|
node.addEventListener("click", closeSheet);
|
|
return node;
|
|
}) : null));
|
|
openSheet(review.title, [head, h("div", {class: "split"}, treeNav, diff)], true);
|
|
ui.review = id;
|
|
reviewDom = {tree: treeNav, diff};
|
|
}
|
|
|
|
function nest(entries) {
|
|
const root = {dirs: new Map(), files: []};
|
|
for (const [file, index] of entries) {
|
|
const parts = file.path.split("/");
|
|
let node = root;
|
|
for (const part of parts.slice(0, -1)) {
|
|
if (!node.dirs.has(part)) {
|
|
node.dirs.set(part, {dirs: new Map(), files: []});
|
|
}
|
|
node = node.dirs.get(part);
|
|
}
|
|
node.files.push([file, index, parts[parts.length - 1]]);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function stat(file) {
|
|
return h("span", {class: "stat"}, h("span", {class: "add"}, `+${file.added}`), h("span", {class: "del"}, `-${file.removed}`));
|
|
}
|
|
|
|
function pick(index, list) {
|
|
ui.file = index;
|
|
for (const entry of reviewDom.tree.querySelectorAll(".file-entry")) {
|
|
if (Number(entry.dataset.index) === index) {
|
|
entry.setAttribute("aria-current", "true");
|
|
} else {
|
|
entry.removeAttribute("aria-current");
|
|
}
|
|
}
|
|
reviewDom.diff.replaceChildren(diffPane(list));
|
|
}
|
|
|
|
function branch(node, list) {
|
|
const items = [];
|
|
for (const [name, child] of [...node.dirs].sort(([a], [b]) => a.localeCompare(b))) {
|
|
let label = name;
|
|
let inner = child;
|
|
while (!inner.files.length && inner.dirs.size === 1) {
|
|
const [[next, deeper]] = inner.dirs;
|
|
label = `${label}/${next}`;
|
|
inner = deeper;
|
|
}
|
|
items.push(h("li", {}, h("details", {open: true}, h("summary", {}, h("span", {class: "dir"}, `${label}/`)), branch(inner, list))));
|
|
}
|
|
for (const [file, index, name] of node.files.sort((a, b) => a[2].localeCompare(b[2]))) {
|
|
const [letter, tone] = FILE_STATUS[file.status] || ["?", "neutral"];
|
|
items.push(h("li", {}, h("button", {
|
|
type: "button", class: "file-entry", "data-index": index, "aria-current": index === ui.file ? "true" : null,
|
|
title: file.old ? `${file.old} → ${file.path}` : file.path, on: {click: () => pick(index, list)},
|
|
}, h("span", {class: "letter", "data-tone": tone}, letter), h("span", {class: "name"}, name), stat(file))));
|
|
}
|
|
return h("ul", {class: "tree-list"}, items);
|
|
}
|
|
|
|
function treePane(list) {
|
|
if (list === "loading") {
|
|
return h("p", {class: "muted pad"}, "Lade Dateiliste …");
|
|
}
|
|
if (!Array.isArray(list) || !list.length) {
|
|
return h("p", {class: "muted pad"}, "Keine Dateiliste. Die vollständige Seite zeigt alle Änderungen.");
|
|
}
|
|
const sections = [...new Set(list.map((file) => file.section))];
|
|
return h("div", {}, sections.map((section) => h("div", {class: "tree-section"},
|
|
sections.length > 1 ? h("h3", {class: "tree-title"}, SECTIONS[section] || section || "Dateien") : null,
|
|
branch(nest(list.map((file, index) => [file, index]).filter(([file]) => file.section === section)), list))));
|
|
}
|
|
|
|
function diffRow(line) {
|
|
if (line.kind === "hunk" || line.kind === "more") {
|
|
return h("tr", {class: line.kind}, h("td", {colspan: 3}), h("td", {class: "code"}, line.text));
|
|
}
|
|
return h("tr", {class: line.kind}, h("td", {class: "num"}, line.old), h("td", {class: "num"}, line.new), h("td", {class: "sign"}, SIGNS[line.kind] || " "), h("td", {class: "code"}, line.text));
|
|
}
|
|
|
|
function diffPane(list) {
|
|
if (!Array.isArray(list) || !list.length) {
|
|
return h("p", {class: "muted pad"}, list === "loading" ? "Lade Änderungen …" : "Kein Diff verfügbar.");
|
|
}
|
|
const file = list[Math.min(ui.file, list.length - 1)];
|
|
const [letter, tone] = FILE_STATUS[file.status] || ["?", "neutral"];
|
|
return h("div", {},
|
|
h("div", {class: "diff-head"},
|
|
h("span", {class: "letter", "data-tone": tone}, letter),
|
|
h("code", {class: "path"}, file.old ? `${file.old} → ${file.path}` : file.path),
|
|
file.tags.map((tag) => pill(tag, "neutral")),
|
|
h("span", {class: "spacer"}),
|
|
stat(file)),
|
|
file.detail ? h("p", {class: "muted pad"}, file.detail) : null,
|
|
file.lines.length ? h("div", {class: "diff-scroll"}, h("table", {class: "diff"}, h("tbody", {}, file.lines.map(diffRow)))) : null);
|
|
}
|