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>
75 lines
4.8 KiB
JavaScript
75 lines
4.8 KiB
JavaScript
function rulesSheet() {
|
||
const kind = h("select", {class: "field", "aria-label": "Art"}, h("option", {value: "ask"}, "Ask: Werkzeug + Befehlsmuster"), h("option", {value: "review"}, "Review: Branch-Muster + Gate grün"), h("option", {value: "decision"}, "Entscheidung: Empfehlung nehmen"));
|
||
const tool = h("input", {class: "field", type: "text", placeholder: "Bash", value: "Bash"});
|
||
const pattern = h("input", {class: "field", type: "text", placeholder: "make test*"});
|
||
const scope = h("input", {class: "field", type: "text", value: "*", placeholder: "/home/…/Repositories/*"});
|
||
const expiry = new Date(Date.now() + 7 * DAY * 1000);
|
||
const expires = h("input", {class: "field", type: "date", value: localInput(expiry.getTime() / 1000).slice(0, 10), required: true});
|
||
const enabled = h("input", {type: "checkbox", checked: true});
|
||
const toolField = h("label", {}, "Werkzeug (Glob)", tool);
|
||
const scopeField = h("label", {}, "Verzeichnis (Glob)", scope);
|
||
const hint = h("p", {class: "muted"});
|
||
const sync = () => {
|
||
toolField.hidden = kind.value !== "ask";
|
||
scopeField.hidden = kind.value === "decision";
|
||
pattern.placeholder = {ask: "make test*", review: "feature/*", decision: "* (Agent-Muster)"}[kind.value];
|
||
hint.textContent = {
|
||
ask: "Gibt passende Asks in passenden Verzeichnissen frei, nie einen Befehl mit Warnung (rm -r, push, --force, sudo, Pipe in eine Shell …). Der Passkey signiert die Regel einmal; der Hook prüft Signatur und Muster selbst.",
|
||
review: "Gibt Reviews passender Branches und Worktrees frei, nur wenn ihr Gate grün ist. Der Passkey signiert die Regel einmal; der Commit-Skill prüft Signatur und Muster selbst.",
|
||
decision: "Nimmt bei Entscheidungen passender Agenten die empfohlene Option; Entscheidungen bleiben unsigniert.",
|
||
}[kind.value];
|
||
};
|
||
kind.addEventListener("change", sync);
|
||
sync();
|
||
const form = h("form", {class: "rule-form"}, h("label", {}, "Art", kind), toolField, h("label", {}, "Muster (Glob)", pattern), scopeField, h("label", {}, "Läuft ab am", expires), h("label", {class: "check"}, enabled, "aktiv"), h("button", {type: "submit", class: "btn primary"}, "Regel anlegen"));
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const end = Date.parse(`${expires.value}T23:59:59`) / 1000;
|
||
const fields = {
|
||
kind: kind.value,
|
||
tool: kind.value === "ask" ? tool.value.trim() : "",
|
||
pattern: pattern.value.trim() || (kind.value === "decision" ? "*" : ""),
|
||
scope: kind.value === "decision" ? "*" : scope.value.trim(),
|
||
expires: Number.isFinite(end) ? end : 0,
|
||
};
|
||
const prepared = await sendJson("/api/rules/sign", fields);
|
||
if (!prepared.ok || !prepared.data) {
|
||
toast("Regel nicht angelegt", fail(prepared), false);
|
||
return;
|
||
}
|
||
const signed = prepared.data.sign ? await assertion(prepared.data, "Regel") : null;
|
||
if (prepared.data.sign && !signed) {
|
||
return;
|
||
}
|
||
const result = await sendJson("/api/rules", {rule: prepared.data.payload, assertion: signed, enabled: enabled.checked});
|
||
toast(result.ok ? "Regel angelegt" : "Regel nicht angelegt", result.ok ? [] : fail(result), result.ok);
|
||
await load();
|
||
rulesSheet();
|
||
});
|
||
const change = async (rule, verb) => {
|
||
const result = await send(`/api/rules/${encodeURIComponent(rule.id)}/${verb}`, "", "application/json");
|
||
if (!result.ok) {
|
||
toast("Regel nicht geändert", [`${result.status} ${result.text}`.trim()], false);
|
||
}
|
||
await load();
|
||
rulesSheet();
|
||
};
|
||
const now = nowSeconds();
|
||
const list = state.rules.length ? h("div", {class: "mini"}, state.rules.map((rule) => h("div", {class: "rule-row"},
|
||
pill(rule.kind, KIND_TONES[rule.kind]),
|
||
rule.tool ? h("code", {}, rule.tool) : null,
|
||
h("code", {}, rule.pattern),
|
||
rule.kind === "decision" ? null : h("code", {title: "Verzeichnis"}, rule.scope || "–"),
|
||
rule.kind === "decision" ? null : pill(rule.signed ? "signiert" : "unsigniert: wirkt nicht", rule.signed ? "ok" : "bad"),
|
||
rule.expires <= now ? pill("abgelaufen", "bad") : h("span", {class: "muted"}, `bis ${absolute.format(stampOf(rule.expires))}`),
|
||
rule.enabled ? pill("aktiv", "ok") : pill("aus", "neutral"),
|
||
h("span", {class: "spacer"}),
|
||
btn(rule.enabled ? "Ausschalten" : "Einschalten", "btn small", () => change(rule, rule.enabled ? "disable" : "enable")),
|
||
btn("Löschen", "btn small danger", () => {
|
||
if (window.confirm("Regel löschen?")) {
|
||
change(rule, "delete");
|
||
}
|
||
})))) : h("p", {class: "muted"}, "Noch keine Regel. Jede automatische Freigabe erscheint im Stream und lässt sich 3 s lang rückgängig machen.");
|
||
openSheet("Regeln für automatische Freigaben", [list, hint, form], false);
|
||
}
|