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>
145 lines
5.8 KiB
JavaScript
145 lines
5.8 KiB
JavaScript
function aggregate() {
|
|
const links = new Map();
|
|
const add = (source, target, cls) => {
|
|
const key = `${source}\u0000${target}\u0000${cls}`;
|
|
const link = links.get(key) || {source, target, cls, count: 0};
|
|
link.count += 1;
|
|
links.set(key, link);
|
|
};
|
|
for (const row of state.radio) {
|
|
for (const target of row.source ? (row.targets.length ? row.targets : ["*"]) : []) {
|
|
add(row.source, target, row.class);
|
|
}
|
|
}
|
|
for (const grant of state.grants) {
|
|
add(chairKey(), grant.target, "grant");
|
|
}
|
|
const order = (link) => (tone(link.cls) === "alarm" ? 1e6 : 0) + link.count;
|
|
return [...links.values()].sort((a, b) => order(a) - order(b));
|
|
}
|
|
|
|
const width = (count) => Math.min(1.25 + Math.log2(1 + count) * 1.6, 9);
|
|
const tone = (cls) => Space.TONES[cls] || "direct";
|
|
|
|
function defs() {
|
|
return s("defs", {}, LEGEND.map(([name]) => s("marker", {id: `tip-${name}`, viewBox: "0 0 10 10", refX: 8, refY: 5, markerWidth: 9, markerHeight: 9, markerUnits: "userSpaceOnUse", orient: "auto-start-reverse", class: `t-${name}`},
|
|
s("path", {class: "tip", d: "M0,0 L10,5 L0,10 z"}))));
|
|
}
|
|
|
|
function toward(point, aim, gap) {
|
|
const dx = aim.x - point.x;
|
|
const dy = aim.y - point.y;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
return {x: point.x + (dx / length) * gap, y: point.y + (dy / length) * gap};
|
|
}
|
|
|
|
function radiusOf(id) {
|
|
const node = state.graph.nodes.find((entry) => entry.id === id);
|
|
return node ? RADIUS[node.kind] : RADIUS.session;
|
|
}
|
|
|
|
function targets() {
|
|
const plan = state.graph.layout;
|
|
const order = [HUMAN, ...plan.order];
|
|
return new Map(order.map((id, index) => {
|
|
const spot = plan.orbit[id] || {angle: -Math.PI / 2, radius: 0};
|
|
return [id, {angle: spot.angle, radius: spot.radius, chord: -Math.PI / 2 + (2 * Math.PI * index) / order.length}];
|
|
}));
|
|
}
|
|
|
|
function step(now) {
|
|
const progress = motion.duration ? Math.min((now - motion.start) / motion.duration, 1) : 1;
|
|
const eased = Space.ease(progress);
|
|
motion.current = new Map([...motion.target].map(([id, to]) => {
|
|
const from = motion.from.get(id) || to;
|
|
return [id, {angle: Space.turn(from.angle, to.angle, eased), radius: from.radius + (to.radius - from.radius) * eased, chord: Space.turn(from.chord, to.chord, eased)}];
|
|
}));
|
|
if (ui.graph !== "swimlane") {
|
|
stage();
|
|
}
|
|
motion.frame = progress < 1 ? window.requestAnimationFrame(step) : 0;
|
|
}
|
|
|
|
function relayout(duration) {
|
|
const next = targets();
|
|
motion.from = new Map([...next.keys()].map((id) => [id, motion.current.get(id) || next.get(id)]));
|
|
motion.target = next;
|
|
motion.start = performance.now();
|
|
motion.duration = calm.matches ? 0 : duration;
|
|
if (!motion.frame) {
|
|
motion.frame = window.requestAnimationFrame(step);
|
|
}
|
|
}
|
|
|
|
function spotOf(id, fresh) {
|
|
return motion.current.get(id) || fresh.get(id) || {angle: -Math.PI / 2, radius: RINGS[1], chord: -Math.PI / 2};
|
|
}
|
|
|
|
function layout(nodes) {
|
|
const fresh = targets();
|
|
const spots = new Map();
|
|
for (const node of nodes.filter((entry) => entry.kind !== "subagent")) {
|
|
const at = spotOf(node.id, fresh);
|
|
const radius = node.kind === "operator" ? 0 : at.radius;
|
|
spots.set(node.id, {x: radius * Math.cos(at.angle), y: radius * Math.sin(at.angle), angle: at.angle});
|
|
}
|
|
const moons = new Map();
|
|
for (const node of nodes.filter((entry) => entry.kind === "subagent")) {
|
|
moons.set(node.parent, [...(moons.get(node.parent) || []), node]);
|
|
}
|
|
for (const [parent, list] of moons) {
|
|
const base = spots.get(parent) || spots.get(chairKey()) || {x: 0, y: 0, angle: -Math.PI / 2};
|
|
list.forEach((node, index) => spots.set(node.id, Space.moon(base, index, list.length)));
|
|
}
|
|
return spots;
|
|
}
|
|
|
|
function caption(node, at, r, gap) {
|
|
if (node.kind === "subagent") {
|
|
return null;
|
|
}
|
|
if (gap === null) {
|
|
return s("text", {class: "label", y: r + 17, "text-anchor": "middle"}, node.label);
|
|
}
|
|
const cos = Math.cos(at.angle);
|
|
const sin = Math.sin(at.angle);
|
|
const anchor = cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle";
|
|
return s("text", {class: "label", x: fixed(cos * gap), y: fixed(sin * (gap + 3)), dy: "0.35em", "text-anchor": anchor}, node.label);
|
|
}
|
|
|
|
function select(id) {
|
|
ui.node = ui.node === id ? null : id;
|
|
stage();
|
|
drawSide();
|
|
const node = state.graph.nodes.find((entry) => entry.id === id);
|
|
if (ui.node !== null && node && node.kind !== "operator") {
|
|
openAgent(node.kind === "subagent" ? node.parent : id);
|
|
}
|
|
}
|
|
|
|
function nodeMark(node, at, gap) {
|
|
const r = RADIUS[node.kind];
|
|
const classes = ["node", `k-${node.kind}`, ui.node === node.id ? "selected" : "", node.claims.length ? "claimed" : ""].filter(Boolean).join(" ");
|
|
const tasks = node.tasks.length ? ` · ${node.tasks.length} Aufgaben` : "";
|
|
return s("g", {
|
|
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`,
|
|
on: {
|
|
click: () => select(node.id),
|
|
keydown: (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
select(node.id);
|
|
}
|
|
},
|
|
},
|
|
},
|
|
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}${tasks}`),
|
|
s("circle", {r}),
|
|
node.kind === "subagent" ? null : s("text", {class: "initial", "text-anchor": "middle", dy: "0.35em"}, node.label.slice(0, 1).toUpperCase()),
|
|
caption(node, at, r, gap));
|
|
}
|
|
|
|
function chartFrame(size, label) {
|
|
return s("svg", {viewBox: `${-size} ${-size} ${2 * size} ${2 * size}`, class: "chart", role: "img", "aria-label": label}, defs());
|
|
}
|