fix(hub): close seven bugs found in the refactor review

The PermissionRequest hook posted asks cut to the hub's field limits, so a signature covered less than Claude Code would run; it now posts nothing it cannot sign in full and leaves the decision to Claude Code with a reason on stderr. fields.seconds names the field the caller used in its error. Serving a stored page maps an OSError to 500 instead of a dropped connection. The natural-language line marks the register as seen before it builds the inbox, like the page does. do_POST runs every header guard (host, origin, client, content type, MCP) before it reads the body, so a refused request never costs its upload. The runner's reader closes stdout at EOF, because a close from the executing thread blocked on the buffer lock and could SIGPIPE a command still running. The page title counts only inbox items that are not snoozed.

Committed on the operator's word despite the gate: cost and time are estimates under 98 % other load and include eight new test cases.

🎯 Fix: 88 %
💥 Break: 3 %
💸 Cost: 106 %
⏱️ Time: 113 %
🛡️ Security: 100 %
🏆 Score: 71
🚦 Gate: ⛔ fix 88 % < 90; cost 106 % > 100, time 113 % > 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:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-06 18:46:40 +00:00
1 parent 8ba45efd04
commit afd96fcd72
14 files changed
+197 -81

No files matched your search

+12 -12
View File
@@ -13,8 +13,9 @@ Returns:
stdout: the PermissionRequest decision JSON: allow only when a pinned stdout: the PermissionRequest decision JSON: allow only when a pinned
passkey signed exactly this tool, command, cwd, session and ask id; passkey signed exactly this tool, command, cwd, session and ask id;
deny when the operator denied in the kvb-hub. Nothing on timeout, deny when the operator denied in the kvb-hub. Nothing on timeout,
error, a missing or invalid signature or a hub that is down, so error, a missing or invalid signature, a value longer than the hub
Claude Code shows its own dialog. The exit code is 0. takes or a hub that is down, so Claude Code shows its own dialog.
The exit code is 0.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,6 +35,7 @@ POLL = 25.0
NAME_LIMIT = 200 NAME_LIMIT = 200
COMMAND_LIMIT = 4000 COMMAND_LIMIT = 4000
PATH_LIMIT = 4096 PATH_LIMIT = 4096
LIMITS = {"tool": NAME_LIMIT, "command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": NAME_LIMIT, "agent": NAME_LIMIT}
DENIED = "The operator denied this in the kvb-hub." DENIED = "The operator denied this in the kvb-hub."
@@ -51,18 +53,12 @@ def facts(hook: dict) -> dict:
"command": summary(hook.get("tool_input")), "command": summary(hook.get("tool_input")),
"cwd": str(hook.get("cwd") or ""), "cwd": str(hook.get("cwd") or ""),
"session": str(hook.get("session_id") or ""), "session": str(hook.get("session_id") or ""),
"agent": str(hook.get("agent_type") or ""),
} }
def create(base: str, hook: dict, ask: dict, timeout: float) -> str: def create(base: str, ask: dict, timeout: float) -> str:
payload = { payload = {**ask, "timeout": timeout}
"tool": ask["tool"][:NAME_LIMIT],
"command": ask["command"][:COMMAND_LIMIT],
"cwd": ask["cwd"][:PATH_LIMIT],
"session": ask["session"][:NAME_LIMIT],
"agent": str(hook.get("agent_type") or "")[:NAME_LIMIT],
"timeout": timeout,
}
headers = {"Content-Type": "application/json", **hub_api.GUARD} headers = {"Content-Type": "application/json", **hub_api.GUARD}
request = urllib.request.Request(f"{base}/api/asks", data=json.dumps(payload).encode(), headers=headers, method="POST") request = urllib.request.Request(f"{base}/api/asks", data=json.dumps(payload).encode(), headers=headers, method="POST")
ask_id = hub_api.call(request, hub_api.SLACK)["id"] ask_id = hub_api.call(request, hub_api.SLACK)["id"]
@@ -104,7 +100,11 @@ def main() -> int:
try: try:
hook = json.load(sys.stdin) hook = json.load(sys.stdin)
ask = facts(hook) ask = facts(hook)
ask_id = create(base, hook, ask, args.timeout) too_long = [name for name, limit in LIMITS.items() if len(ask[name]) > limit]
if too_long:
print(f"kvb-hub: {', '.join(too_long)} too long to sign in full, Claude Code asks instead", file=sys.stderr)
return 0
ask_id = create(base, ask, args.timeout)
answer = {"status": "pending"} answer = {"status": "pending"}
while answer["status"] == "pending" and (left := deadline - time.monotonic()) > 0: while answer["status"] == "pending" and (left := deadline - time.monotonic()) > 0:
answer = status(base, ask_id, min(left, POLL)) answer = status(base, ask_id, min(left, POLL))
+5 -3
View File
@@ -49,9 +49,11 @@ def call(base: str, path: str, payload: dict | None) -> dict:
def collect(stream: object, tail: collections.deque, lock: threading.Lock) -> None: def collect(stream: object, tail: collections.deque, lock: threading.Lock) -> None:
for line in stream: # The reader closes the pipe at EOF: a close in execute() would block on the buffer lock this read holds and SIGPIPE a command still running.
with lock: with stream:
tail.append(line) for line in stream:
with lock:
tail.append(line)
def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None: def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
+2 -2
View File
@@ -55,7 +55,7 @@ class Asking:
payload = decode(body) payload = decode(body)
expect(payload, ("tool", *ASK_TEXTS, "timeout")) expect(payload, ("tool", *ASK_TEXTS, "timeout"))
values = {name: bounded(payload, name, limit) for name, limit in ASK_TEXTS.items()} values = {name: bounded(payload, name, limit) for name, limit in ASK_TEXTS.items()}
lifetime = seconds(payload["timeout"], ASK_LIFETIME) lifetime = seconds(payload, "timeout", ASK_LIFETIME)
created = time.time() created = time.time()
fields = {**values, "tool": field(payload, "tool").strip(), "status": "pending", "decided": None, "proof": None} fields = {**values, "tool": field(payload, "tool").strip(), "status": "pending", "decided": None, "proof": None}
record = self.asks.add({**fields, "created_ts": created, "expires": created + lifetime}) record = self.asks.add({**fields, "created_ts": created, "expires": created + lifetime})
@@ -122,7 +122,7 @@ class Asking:
def decision_wait(self, arguments: dict) -> dict: def decision_wait(self, arguments: dict) -> dict:
expect(arguments, ("id", "timeout_seconds")) expect(arguments, ("id", "timeout_seconds"))
decision_id = field(arguments, "id") decision_id = field(arguments, "id")
record = self.decisions.wait(decision_id, seconds(arguments["timeout_seconds"], WAIT_CAP)) record = self.decisions.wait(decision_id, seconds(arguments, "timeout_seconds", WAIT_CAP))
if record is None: if record is None:
raise ToolError(f"no decision {decision_id}") raise ToolError(f"no decision {decision_id}")
return decision_state(record) return decision_state(record)
+2 -2
View File
@@ -97,7 +97,7 @@ class Exchange(BaseHTTPRequestHandler):
def client(self) -> bool: def client(self) -> bool:
return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") is None return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") is None
def body(self, limit: int) -> bytes | None: def body_length(self, limit: int) -> int | None:
length = self.headers.get("Content-Length") length = self.headers.get("Content-Length")
if length is None or not LENGTH.fullmatch(length): if length is None or not LENGTH.fullmatch(length):
self.send_text(HTTPStatus.LENGTH_REQUIRED, "Content-Length required") self.send_text(HTTPStatus.LENGTH_REQUIRED, "Content-Length required")
@@ -105,4 +105,4 @@ class Exchange(BaseHTTPRequestHandler):
if int(length) > limit: if int(length) > limit:
self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large") self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large")
return None return None
return self.rfile.read(int(length)) return int(length)
+3 -2
View File
@@ -59,9 +59,10 @@ def message_text(arguments: dict, name: str) -> str:
return value.strip() return value.strip()
def seconds(value: object, cap: float) -> float: def seconds(arguments: dict, name: str, cap: float) -> float:
value = arguments[name]
if isinstance(value, bool) or not isinstance(value, int | float) or not 0 <= value < math.inf: if isinstance(value, bool) or not isinstance(value, int | float) or not 0 <= value < math.inf:
raise ToolError("timeout_seconds must be a finite number of seconds, 0 or more") raise ToolError(f"{name} must be a finite number of seconds, 0 or more")
return float(min(value, cap)) return float(min(value, cap))
+61 -51
View File
@@ -43,9 +43,6 @@ class Handler(Exchange):
self.attempt(lambda: self.server.hub.speech.synthesize(body), lambda wav: self.send(HTTPStatus.OK, WAV_TYPE, wav, headers)) self.attempt(lambda: self.server.hub.speech.synthesize(body), lambda wav: self.send(HTTPStatus.OK, WAV_TYPE, wav, headers))
def hear(self, body: bytes) -> None: def hear(self, body: bytes) -> None:
if self.headers.get_content_type() != WAV_TYPE:
self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, f"use {WAV_TYPE}")
return
self.answer(lambda: self.server.hub.speech.transcribe(body), HTTPStatus.OK) self.answer(lambda: self.server.hub.speech.transcribe(body), HTTPStatus.OK)
def do_GET(self) -> None: def do_GET(self) -> None:
@@ -87,50 +84,50 @@ class Handler(Exchange):
else: else:
self.send_text(HTTPStatus.NOT_FOUND, "not found") self.send_text(HTTPStatus.NOT_FOUND, "not found")
def action(self, path: str, body: bytes) -> Callable[[], None] | None: def action(self, path: str) -> Callable[[bytes], None] | None:
hub = self.server.hub hub = self.server.hub
route = ROUTE.fullmatch(path) route = ROUTE.fullmatch(path)
item = ITEM.fullmatch(path) item = ITEM.fullmatch(path)
board = BOARD.fullmatch(path) board = BOARD.fullmatch(path)
if route and route.group(2) in DECISIONS: if route and route.group(2) in DECISIONS:
return lambda: self.answer(lambda: hub.verdict(route.group(1), DECISIONS[route.group(2)], body), HTTPStatus.OK) return lambda body: self.answer(lambda: hub.verdict(route.group(1), DECISIONS[route.group(2)], body), HTTPStatus.OK)
if item and item.group(3): if item and item.group(3):
return lambda: self.answer(lambda: hub.settle(item.group(1), item.group(2), item.group(3), body), HTTPStatus.OK) return lambda body: self.answer(lambda: hub.settle(item.group(1), item.group(2), item.group(3), body), HTTPStatus.OK)
if path == UPLOADS: if path == UPLOADS:
return lambda: self.answer(lambda: hub.upload(self.query(), body), HTTPStatus.CREATED) return lambda body: self.answer(lambda: hub.upload(self.query(), body), HTTPStatus.CREATED)
if path == "/api/messages": if path == "/api/messages":
return lambda: self.answer(lambda: hub.message(body), HTTPStatus.CREATED) return lambda body: self.answer(lambda: hub.message(body), HTTPStatus.CREATED)
if board: if board:
return lambda: self.answer(lambda: hub.board(board.group(1), body), HTTPStatus.OK) return lambda body: self.answer(lambda: hub.board(board.group(1), body), HTTPStatus.OK)
return self.inbox_action(path, body) return self.inbox_action(path)
def inbox_action(self, path: str, body: bytes) -> Callable[[], None] | None: def inbox_action(self, path: str) -> Callable[[bytes], None] | None:
hub = self.server.hub hub = self.server.hub
inbox = INBOX_ROUTE.fullmatch(path) inbox = INBOX_ROUTE.fullmatch(path)
undo = UNDO_ROUTE.fullmatch(path) undo = UNDO_ROUTE.fullmatch(path)
rule = RULE_ROUTE.fullmatch(path) rule = RULE_ROUTE.fullmatch(path)
if inbox and inbox.group(1) == "approve": if inbox and inbox.group(1) == "approve":
return lambda: self.answer(lambda: hub.approve(body), HTTPStatus.ACCEPTED) return lambda body: self.answer(lambda: hub.approve(body), HTTPStatus.ACCEPTED)
if path == SIGN_ROUTE: if path == SIGN_ROUTE:
return lambda: self.answer(lambda: hub.sign(body), HTTPStatus.OK) return lambda body: self.answer(lambda: hub.sign(body), HTTPStatus.OK)
if path == RULE_SIGN_ROUTE: if path == RULE_SIGN_ROUTE:
return lambda: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK) return lambda body: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK)
if inbox: if inbox:
return lambda: self.answer(lambda: hub.mark(body), HTTPStatus.OK) return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
if undo: if undo:
return lambda: self.answer(lambda: hub.undo(undo.group(1)), HTTPStatus.OK) return lambda _body: self.answer(lambda: hub.undo(undo.group(1)), HTTPStatus.OK)
if path == "/api/rules": if path == "/api/rules":
return lambda: self.answer(lambda: hub.add_rule(body), HTTPStatus.CREATED) return lambda body: self.answer(lambda: hub.add_rule(body), HTTPStatus.CREATED)
if rule: if rule:
return lambda: self.answer(lambda: hub.rule_action(rule.group(1), rule.group(2)), HTTPStatus.OK) return lambda _body: self.answer(lambda: hub.rule_action(rule.group(1), rule.group(2)), HTTPStatus.OK)
if path == "/api/nl": if path == "/api/nl":
return lambda: self.answer(lambda: hub.nl(body), HTTPStatus.OK) return lambda body: self.answer(lambda: hub.nl(body), HTTPStatus.OK)
if path == SETTINGS_ROUTE: if path == SETTINGS_ROUTE:
return lambda: self.answer(lambda: hub.save_settings(body), HTTPStatus.OK) return lambda body: self.answer(lambda: hub.save_settings(body), HTTPStatus.OK)
if path == TTS_ROUTE: if path == TTS_ROUTE:
return lambda: self.speak(body) return self.speak
if path == STT_ROUTE: if path == STT_ROUTE:
return lambda: self.hear(body) return self.hear
return None return None
def posted(self, path: str) -> bool: def posted(self, path: str) -> bool:
@@ -139,41 +136,51 @@ class Handler(Exchange):
def runner(self, path: str, body: bytes) -> None: def runner(self, path: str, body: bytes) -> None:
hub = self.server.hub hub = self.server.hub
route = RUNNER_ROUTE.fullmatch(path) route = RUNNER_ROUTE.fullmatch(path)
if not self.client(): if route.group(2) == "claim":
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only")
elif route.group(2) == "claim":
self.answer(lambda: hub.claim(route.group(1)), HTTPStatus.OK) self.answer(lambda: hub.claim(route.group(1)), HTTPStatus.OK)
else: else:
self.answer(lambda: hub.report(route.group(1), body), HTTPStatus.OK) self.answer(lambda: hub.report(route.group(1), body), HTTPStatus.OK)
def receiver(self, path: str, host: str) -> Callable[[bytes], None] | None:
"""Returns:
What handles the body once every header guard let the request
pass, or None after a guard answered it.
"""
if path == "/mcp":
return self.mcp_receiver(host)
if path == "/api/asks":
if self.local(host):
return lambda body: self.answer(lambda: self.server.hub.ask(body), HTTPStatus.CREATED)
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: local POST with the X-Kvb-Hub header and no foreign origin only")
return None
if RUNNER_ROUTE.fullmatch(path):
if self.client():
return lambda body: self.runner(path, body)
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only")
return None
action = self.action(path)
if action is None:
self.send_text(HTTPStatus.NOT_FOUND, "not found")
elif not self.guarded(host):
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: same-origin POST with the X-Kvb-Hub header only")
elif path == STT_ROUTE and self.headers.get_content_type() != WAV_TYPE:
self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, f"use {WAV_TYPE}")
else:
return action
return None
def do_POST(self) -> None: def do_POST(self) -> None:
path = urlsplit(self.path).path path = urlsplit(self.path).path
body = self.body({UPLOADS: UPLOAD_LIMIT, STT_ROUTE: STT_LIMIT}.get(path, BODY_LIMIT)) length = self.body_length({UPLOADS: UPLOAD_LIMIT, STT_ROUTE: STT_LIMIT}.get(path, BODY_LIMIT))
if body is None: if length is None:
return return
host = self.host() host = self.host()
if host is None: if host is None:
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host") self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
return return
if path == "/mcp": receive = self.receiver(path, host)
self.mcp(host, body) if receive is not None:
return receive(self.rfile.read(length))
if path == "/api/asks":
if self.local(host):
self.answer(lambda: self.server.hub.ask(body), HTTPStatus.CREATED)
else:
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: local POST with the X-Kvb-Hub header and no foreign origin only")
return
if RUNNER_ROUTE.fullmatch(path):
self.runner(path, body)
return
action = self.action(path, body)
if action is None:
self.send_text(HTTPStatus.NOT_FOUND, "not found")
elif not self.guarded(host):
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: same-origin POST with the X-Kvb-Hub header only")
else:
action()
def index(self) -> None: def index(self) -> None:
nonce = secrets.token_urlsafe(18) nonce = secrets.token_urlsafe(18)
@@ -188,16 +195,19 @@ class Handler(Exchange):
if reviews.get(review_id) is None or not path.is_file(): if reviews.get(review_id) is None or not path.is_file():
self.send_text(HTTPStatus.NOT_FOUND, "not found") self.send_text(HTTPStatus.NOT_FOUND, "not found")
return return
self.send(HTTPStatus.OK, content_type, path.read_bytes(), {"Content-Security-Policy": csp}) self.attempt(path.read_bytes, lambda data: self.send(HTTPStatus.OK, content_type, data, {"Content-Security-Policy": csp}))
def mcp(self, host: str, body: bytes) -> None: def mcp_receiver(self, host: str) -> Callable[[bytes], None] | None:
origins = self.headers.get_all("Origin") origins = self.headers.get_all("Origin")
if origins is not None and origins != [f"http://{host}"]: if origins is not None and origins != [f"http://{host}"]:
self.send_text(HTTPStatus.FORBIDDEN, "foreign origin") self.send_text(HTTPStatus.FORBIDDEN, "foreign origin")
return elif self.headers.get_content_type() != "application/json":
if self.headers.get_content_type() != "application/json":
self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "use application/json") self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "use application/json")
return else:
return self.mcp
return None
def mcp(self, body: bytes) -> None:
status, reply = self.server.hub.rpc(body) status, reply = self.server.hub.rpc(body)
if reply is not None: if reply is not None:
self.send_json(status, reply) self.send_json(status, reply)
+3 -1
View File
@@ -180,7 +180,9 @@ class Language:
now = time.time() now = time.time()
claims = read_claims(self.council) claims = read_claims(self.council)
tasks = read_register(self.council / REGISTER) tasks = read_register(self.council / REGISTER)
items = self.inbox(now, tasks, self.directory(claims, read_jsonl(self.council / NEWS), now)) directory = self.directory(claims, read_jsonl(self.council / NEWS), now)
self.watch.see(tasks, now)
items = self.inbox(now, tasks, directory)
agents = ordered({claim["agent"] for claim in claims if claim["agent"]} | set(priority(tasks))) agents = ordered({claim["agent"] for claim in claims if claim["agent"]} | set(priority(tasks)))
shown_items = [{"id": item["id"], "kind": item["kind"], "title": item["title"][:200]} for item in items] shown_items = [{"id": item["id"], "kind": item["kind"], "title": item["title"][:200]} for item in items]
return nl_actions(self.model.complete(nl_messages(text, shown_items, agents)), items, agents) return nl_actions(self.model.complete(nl_messages(text, shown_items, agents)), items, agents)
+1 -1
View File
@@ -97,7 +97,7 @@ class Reviewing:
def wait(self, arguments: dict) -> dict: def wait(self, arguments: dict) -> dict:
expect(arguments, ("id", "timeout_seconds")) expect(arguments, ("id", "timeout_seconds"))
review_id = field(arguments, "id") review_id = field(arguments, "id")
timeout = seconds(arguments["timeout_seconds"], WAIT_CAP) timeout = seconds(arguments, "timeout_seconds", WAIT_CAP)
return state_of(found(self.reviews.wait(review_id, timeout), review_id)) return state_of(found(self.reviews.wait(review_id, timeout), review_id))
def pending(self, arguments: dict) -> dict: def pending(self, arguments: dict) -> dict:
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<title>{% if state.inbox %}({{ state.inbox | length }}) {% endif %}kvb-hub</title> <title>{% set waiting = state.inbox | rejectattr("until") | list | length %}{% if waiting %}({{ waiting }}) {% endif %}kvb-hub</title>
<style nonce="{{ nonce }}"> <style nonce="{{ nonce }}">
:root { :root {
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; --sp-7: 48px;
+31 -5
View File
@@ -23,11 +23,11 @@ class TestHubAsks(HubCase):
time.sleep(0.05) time.sleep(0.05)
self.fail("no ask arrived") self.fail("no ask arrived")
def hook(self, port: int, timeout: str) -> subprocess.Popen: def hook(self, port: int, timeout: str, payload: dict) -> subprocess.Popen:
trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "hook.json")] trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "hook.json")]
command = [sys.executable, str(CLIENT), "--port", str(port), "--timeout", timeout, *trust] command = [sys.executable, str(CLIENT), "--port", str(port), "--timeout", timeout, *trust]
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
process.stdin.write(json.dumps(HOOK)) process.stdin.write(json.dumps(payload))
process.stdin.close() process.stdin.close()
self.addCleanup(process.stdout.close) self.addCleanup(process.stdout.close)
self.addCleanup(process.stderr.close) self.addCleanup(process.stderr.close)
@@ -91,7 +91,7 @@ class TestHubAsks(HubCase):
relayed = [] relayed = []
def asked(decide: Callable[[str], None]) -> tuple[int, str]: def asked(decide: Callable[[str], None]) -> tuple[int, str]:
process = self.hook(self.port, "20") process = self.hook(self.port, "20", HOOK)
ask = self.first_ask() ask = self.first_ask()
self.assertEqual((ask["tool"], ask["command"], ask["cwd"], ask["session"]), ("Bash", "rm -rf node_modules", "/repo", "s1")) self.assertEqual((ask["tool"], ask["command"], ask["cwd"], ask["session"]), ("Bash", "rm -rf node_modules", "/repo", "s1"))
decide(ask["id"]) decide(ask["id"])
@@ -123,11 +123,37 @@ class TestHubAsks(HubCase):
code, out = asked(allow_by(key(-7, "main"))) code, out = asked(allow_by(key(-7, "main")))
self.assertEqual((code, json.loads(out)), (0, {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "allow"}}})) self.assertEqual((code, json.loads(out)), (0, {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "allow"}}}))
self.assertEqual(asked(relay(relayed[-1])), (0, "")) self.assertEqual(asked(relay(relayed[-1])), (0, ""))
self.assertEqual(self.finish(self.hook(self.port, "0.3")), (0, "")) self.assertEqual(self.finish(self.hook(self.port, "0.3", HOOK)), (0, ""))
with socket.socket() as probe: with socket.socket() as probe:
probe.bind(("127.0.0.1", 0)) probe.bind(("127.0.0.1", 0))
closed = probe.getsockname()[1] closed = probe.getsockname()[1]
self.assertEqual(self.finish(self.hook(closed, "5")), (0, "")) self.assertEqual(self.finish(self.hook(closed, "5", HOOK)), (0, ""))
def test_the_hook_posts_nothing_it_cannot_send_in_full(self):
for name, change in (
("tool", {"tool_name": "T" * 201}),
("command", {"tool_input": {"command": "x" * 4001}}),
("cwd", {"cwd": "/" + "d" * 4096}),
("session", {"session_id": "s" * 201}),
("agent", {"agent_type": "a" * 201}),
):
with self.subTest(name=name):
process = self.hook(self.port, "3", {**HOOK, **change})
self.assertEqual((self.finish(process), self.httpd.hub.asks.listed()), ((0, ""), []))
self.assertRegex(process.stderr.read(), rf"\Akvb-hub: [^\n]*\b{name}\b[^\n]*\n\Z")
process = self.hook(self.port, "20", {**HOOK, "tool_input": {"command": "x" * 4000}})
ask = self.first_ask()
self.assertEqual(ask["command"], "x" * 4000)
self.assertEqual(self.call("POST", f"/api/asks/{ask['id']}/deny", b"", self.guard(self.host))[0], 200)
self.assertEqual(json.loads(self.finish(process)[1])["hookSpecificOutput"]["decision"]["behavior"], "deny")
def test_a_bad_timeout_names_the_field_the_caller_sent(self):
status, _, body = self.post_json("/api/asks", {**ASK, "timeout": -1}, self.local())
self.assertEqual((status, body), (400, b"timeout must be a finite number of seconds, 0 or more\n"))
for tool in ("decision_wait", "review_wait"):
with self.subTest(tool=tool):
result = self.tool(tool, {"id": "0123456789abcdef", "timeout_seconds": -1})
self.assertEqual(result["content"][0]["text"], "timeout_seconds must be a finite number of seconds, 0 or more")
def test_decisions_are_requested_chosen_and_waited_for(self): def test_decisions_are_requested_chosen_and_waited_for(self):
for arguments in ( for arguments in (
+15
View File
@@ -103,6 +103,21 @@ class TestHubExecutors(HubCase):
self.assertEqual(runner.execute({"command": "exit 3", "directory": str(self.outside)}, reports.append), 3) self.assertEqual(runner.execute({"command": "exit 3", "directory": str(self.outside)}, reports.append), 3)
self.assertEqual(reports[-1], {"output": "", "code": 3, "refused": False}) self.assertEqual(reports[-1], {"output": "", "code": 3, "refused": False})
def test_the_runner_closes_the_output_pipe_on_every_path(self):
popen, spawned = subprocess.Popen, []
job = {"command": "echo hi; sleep 0.3", "directory": str(self.outside)}
def spawn(*args: object, **kwargs: object) -> subprocess.Popen:
spawned.append(popen(*args, **kwargs))
return spawned[-1]
with mock.patch.object(subprocess, "Popen", spawn), mock.patch.object(runner, "BEAT", 0.05):
self.assertEqual(runner.execute(job, [].append), 0)
self.assertTrue(spawned[0].stdout.closed)
self.assertRaises(RuntimeError, runner.execute, job, mock.Mock(side_effect=RuntimeError("the hub is gone")))
self.settle(lambda: spawned[1].stdout.closed)
self.assertEqual(spawned[1].wait(10), 0)
def test_rules_are_signed_once_and_checked_by_the_executor(self): def test_rules_are_signed_once_and_checked_by_the_executor(self):
now = time.time() now = time.time()
rule = {"kind": "ask", "tool": "Bash", "pattern": "make *", "scope": "/repo*", "expires": now + 3600} rule = {"kind": "ask", "tool": "Bash", "pattern": "make *", "scope": "/repo*", "expires": now + 3600}
+20
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import dataclasses
import json import json
import time import time
import unittest import unittest
@@ -99,6 +100,25 @@ class TestHubInbox(HubCase):
with self.subTest(text=text): with self.subTest(text=text):
self.assertIn(text, html) self.assertIn(text, html)
def test_an_nl_command_records_new_operator_rows_like_the_state_does(self):
self.write_council({"rasci.json": {"operator/greet": {"title": "echo hi", "workdir": "/repo", "check": "manual", "status": "queued", "roles": {}}}})
model = mock.Mock(**{"enabled.return_value": True, "complete.return_value": '{"actions": []}'})
self.httpd.hub = dataclasses.replace(self.httpd.hub, model=model)
before = time.time()
self.assertEqual(self.post_json("/api/nl", {"text": "status"}, self.guard(self.host))[0], 200)
after = time.time()
row = next(item for item in self.snapshot()["inbox"] if item["id"] == "operator/greet")
self.assertTrue(before <= row["ts"] <= after, (before, row["ts"], after))
def test_the_title_counts_the_items_that_are_not_snoozed(self):
asks = [self.create_ask(ASK), self.create_ask({**ASK, "command": "make other"})]
self.assertIn("<title>(2) kvb-hub</title>", self.index())
for ask_id, title in zip(asks, ("(1) kvb-hub", "kvb-hub"), strict=True):
snooze = {"kind": "ask", "id": ask_id, "until": time.time() + 3600, "pinned": False}
self.assertEqual(self.post_json("/api/inbox/mark", snooze, self.guard(self.host))[0], 200)
with self.subTest(title=title):
self.assertIn(f"<title>{title}</title>", self.index())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+31 -1
View File
@@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
import http.client
import json import json
import unittest import unittest
from hub_case import HubCase from hub_case import HubCase
from hub_support import PAGE, SCORE, TREE from hub_support import PAGE, SCORE, TREE
from kvb_hub import mcp from kvb_hub import handler, mcp
TOOLS = {"review_request", "review_status", "review_wait", "review_list", "decision_request", "decision_wait", "operator_message"} TOOLS = {"review_request", "review_status", "review_wait", "review_list", "decision_request", "decision_wait", "operator_message"}
@@ -79,6 +80,35 @@ class TestHubMcp(HubCase):
self.assertEqual(self.post_mcp(ping, {"Origin": f"http://{self.host}"})[0], 200) self.assertEqual(self.post_mcp(ping, {"Origin": f"http://{self.host}"})[0], 200)
self.assertEqual(self.get("/mcp")[0], 405) self.assertEqual(self.get("/mcp")[0], 405)
def headers_only(self, path: str, headers: dict[str, str]) -> tuple[int, bytes]:
connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=3)
try:
connection.putrequest("POST", path, skip_host=True)
for name, value in {"Content-Length": "1024", **headers}.items():
connection.putheader(name, value)
connection.endheaders()
response = connection.getresponse()
return response.status, response.read()
finally:
connection.close()
def test_every_post_guard_answers_before_the_body_arrives(self):
guard = self.guard(self.host)
for path, headers, status, text in (
("/api/asks", {"Host": "evil.example", "X-Kvb-Hub": "1"}, 403, "forbidden host"),
("/api/asks", {"Host": self.host}, 403, "forbidden: local POST with the X-Kvb-Hub header and no foreign origin only"),
("/mcp", {"Host": self.host, "Origin": "http://evil.example", "Content-Type": "application/json"}, 403, "foreign origin"),
("/mcp", {"Host": self.host, "Content-Type": "text/plain"}, 415, "use application/json"),
("/api/runner/0123456789abcdef/report", guard, 403, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only"),
("/api/messages", self.client(), 403, "forbidden: same-origin POST with the X-Kvb-Hub header only"),
("/api/stt", {**guard, "Content-Type": "text/plain"}, 415, "use audio/wav"),
("/api/state", guard, 404, "not found"),
("/api/uploads", {**guard, "Content-Length": str(handler.UPLOAD_LIMIT + 1)}, 413, "body too large"),
("/api/messages", {**guard, "Content-Length": "many"}, 411, "Content-Length required"),
):
with self.subTest(path=path, headers=headers):
self.assertEqual(self.headers_only(path, headers), (status, f"{text}\n".encode()))
def test_mcp_requires_gate_score_and_recommended(self): def test_mcp_requires_gate_score_and_recommended(self):
tools = {entry["name"]: entry["inputSchema"] for entry in self.rpc("tools/list", {})["result"]["tools"]} tools = {entry["name"]: entry["inputSchema"] for entry in self.rpc("tools/list", {})["result"]["tools"]}
self.assertEqual(tools["review_request"]["properties"]["gate"]["enum"], ["pass", "fail"]) self.assertEqual(tools["review_request"]["properties"]["gate"]["enum"], ["pass", "fail"])
+10
View File
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import errno
import json import json
import re import re
import subprocess import subprocess
import time import time
import unittest import unittest
from pathlib import Path
from unittest import mock
from hub_case import HubCase from hub_case import HubCase
from hub_support import AMBIENCE, SPACE, VOICE from hub_support import AMBIENCE, SPACE, VOICE
@@ -36,6 +39,13 @@ class TestHubPage(HubCase):
self.assertEqual((status, headers["content-type"]), (200, "application/json")) self.assertEqual((status, headers["content-type"]), (200, "application/json"))
self.assertEqual(headers["content-security-policy"], exchange.PLAIN_CSP) self.assertEqual(headers["content-security-policy"], exchange.PLAIN_CSP)
def test_a_stored_file_that_cannot_be_read_answers_500(self):
review_id = self.request("feat: io", "")
for name in ("page", "files"):
with self.subTest(name=name), mock.patch.object(Path, "read_bytes", side_effect=OSError(errno.EIO, "Input/output error")):
status, _, body = self.get(f"/reviews/{review_id}/{name}")
self.assertEqual((status, body), (500, b"storage failed: Input/output error\n"))
def test_markup_from_agents_is_escaped(self): def test_markup_from_agents_is_escaped(self):
self.request("<script>alert(1)</script>", "</pre><script>alert(2)</script>") self.request("<script>alert(1)</script>", "</pre><script>alert(2)</script>")
register = {"work/x": {"title": "<script>alert(3)</script>", "status": "running", "roles": {"<b>One</b>": "RA"}}} register = {"work/x": {"title": "<script>alert(3)</script>", "status": "running", "roles": {"<b>One</b>": "RA"}}}