feat(hub): kvb-hub review hub with passkey-signed approvals
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>
This commit is contained in:
1 parent
305ccdb1c2
commit
8ba45efd04
119 files changed
+13672
-4
No files matched your search
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
--port: port of the kvb-hub on 127.0.0.1; the signature's origin is
|
||||
http://localhost:<port>.
|
||||
--interval: seconds between two polls of the hub.
|
||||
--approvers: the pinned passkeys, e.g. ~/.config/kvb-hub/approvers.json.
|
||||
--replay: the runner's replay store, outside the hub's reach, e.g.
|
||||
~/.local/state/kvb-hub-approvals/runner.json.
|
||||
--once: handle the queued jobs once and exit.
|
||||
|
||||
Returns:
|
||||
Runs as the operator, outside any sandbox, every operator command a
|
||||
pinned passkey signed in the kvb-hub: exactly the signed text, with bash
|
||||
in the signed directory, each signature once and before its expiry.
|
||||
Refuses every other job. Posts the last 200 output lines back while it
|
||||
runs and the exit code at its end. The exit code is 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import hub_api
|
||||
import signing
|
||||
import verifier
|
||||
|
||||
LINES = 200
|
||||
BEAT = 2.0
|
||||
REFUSED = "refused: no valid operator signature for this command"
|
||||
|
||||
|
||||
def call(base: str, path: str, payload: dict | None) -> dict:
|
||||
headers = dict(hub_api.GUARD)
|
||||
data = None
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(payload).encode()
|
||||
request = urllib.request.Request(f"{base}{path}", data=data, headers=headers, method="GET" if payload is None else "POST")
|
||||
return hub_api.call(request, hub_api.SLACK)
|
||||
|
||||
|
||||
def collect(stream: object, tail: collections.deque, lock: threading.Lock) -> None:
|
||||
for line in stream:
|
||||
with lock:
|
||||
tail.append(line)
|
||||
|
||||
|
||||
def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
|
||||
"""Args:
|
||||
job: the claimed job with task, command, directory and proof.
|
||||
trust: the pinned keys, the runner's replay store and the origin.
|
||||
now: the current time in epoch seconds.
|
||||
|
||||
Returns:
|
||||
Why the job must not run, or None when a pinned key signed it.
|
||||
"""
|
||||
try:
|
||||
verifier.check(job.get("proof"), signing.operator_item(job["task"], job["command"], job["directory"]), "", trust, now)
|
||||
except (signing.Refused, OSError) as error:
|
||||
return f"{REFUSED}: {error}"
|
||||
return None
|
||||
|
||||
|
||||
def execute(job: dict, report: Callable[[dict], None]) -> int:
|
||||
"""Args:
|
||||
job: the claimed and verified job with command and directory.
|
||||
report: called with {"output", "code", "refused"}; code None while
|
||||
the command runs.
|
||||
|
||||
Returns:
|
||||
The exit code.
|
||||
"""
|
||||
tail: collections.deque = collections.deque(maxlen=LINES)
|
||||
lock = threading.Lock()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["bash", "-c", job["command"]],
|
||||
cwd=job["directory"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
errors="replace",
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
report({"output": f"could not start: {error}", "code": 127, "refused": False})
|
||||
return 127
|
||||
reader = threading.Thread(target=collect, args=(process.stdout, tail, lock), daemon=True)
|
||||
reader.start()
|
||||
while True:
|
||||
try:
|
||||
code = process.wait(BEAT)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
with lock:
|
||||
output = "".join(tail)
|
||||
report({"output": output, "code": None, "refused": False})
|
||||
reader.join()
|
||||
report({"output": "".join(tail), "code": code, "refused": False})
|
||||
return code
|
||||
|
||||
|
||||
def sender(base: str, job_id: str) -> Callable[[dict], None]:
|
||||
def send(result: dict) -> None:
|
||||
try:
|
||||
call(base, f"/api/runner/{job_id}/report", result)
|
||||
except (OSError, ValueError, TypeError):
|
||||
return
|
||||
|
||||
return send
|
||||
|
||||
|
||||
def cycle(base: str, trust: verifier.Trust) -> None:
|
||||
for job in call(base, "/api/runner/jobs", None)["jobs"]:
|
||||
try:
|
||||
claimed = call(base, f"/api/runner/{job['id']}/claim", {})
|
||||
except urllib.error.HTTPError:
|
||||
continue
|
||||
send = sender(base, claimed["id"])
|
||||
reason = refusal(claimed, trust, time.time())
|
||||
if reason is None:
|
||||
execute(claimed, send)
|
||||
else:
|
||||
send({"output": reason, "code": None, "refused": True})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="runner")
|
||||
parser.add_argument("--port", type=int, required=True)
|
||||
parser.add_argument("--interval", type=float, required=True)
|
||||
parser.add_argument("--approvers", type=Path, required=True)
|
||||
parser.add_argument("--replay", type=Path, required=True)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args()
|
||||
base = hub_api.base_url(args.port)
|
||||
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
|
||||
while True:
|
||||
try:
|
||||
cycle(base, trust)
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if args.once:
|
||||
return 0
|
||||
time.sleep(args.interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user