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
passkey signed exactly this tool, command, cwd, session and ask id;
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
Claude Code shows its own dialog. The exit code is 0.
error, a missing or invalid signature, a value longer than the hub
takes or a hub that is down, so Claude Code shows its own dialog.
The exit code is 0.
"""
from __future__ import annotations
@@ -34,6 +35,7 @@ POLL = 25.0
NAME_LIMIT = 200
COMMAND_LIMIT = 4000
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."
@@ -51,18 +53,12 @@ def facts(hook: dict) -> dict:
"command": summary(hook.get("tool_input")),
"cwd": str(hook.get("cwd") 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:
payload = {
"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,
}
def create(base: str, ask: dict, timeout: float) -> str:
payload = {**ask, "timeout": timeout}
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")
ask_id = hub_api.call(request, hub_api.SLACK)["id"]
@@ -104,7 +100,11 @@ def main() -> int:
try:
hook = json.load(sys.stdin)
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"}
while answer["status"] == "pending" and (left := deadline - time.monotonic()) > 0:
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:
for line in stream:
with lock:
tail.append(line)
# 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 stream:
for line in stream:
with lock:
tail.append(line)
def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
+2 -2
View File
@@ -55,7 +55,7 @@ class Asking:
payload = decode(body)
expect(payload, ("tool", *ASK_TEXTS, "timeout"))
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()
fields = {**values, "tool": field(payload, "tool").strip(), "status": "pending", "decided": None, "proof": None}
record = self.asks.add({**fields, "created_ts": created, "expires": created + lifetime})
@@ -122,7 +122,7 @@ class Asking:
def decision_wait(self, arguments: dict) -> dict:
expect(arguments, ("id", "timeout_seconds"))
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:
raise ToolError(f"no decision {decision_id}")
return decision_state(record)
+2 -2
View File
@@ -97,7 +97,7 @@ class Exchange(BaseHTTPRequestHandler):
def client(self) -> bool:
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")
if length is None or not LENGTH.fullmatch(length):
self.send_text(HTTPStatus.LENGTH_REQUIRED, "Content-Length required")
@@ -105,4 +105,4 @@ class Exchange(BaseHTTPRequestHandler):
if int(length) > limit:
self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large")
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()
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:
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))
+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))
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)
def do_GET(self) -> None:
@@ -87,50 +84,50 @@ class Handler(Exchange):
else:
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
route = ROUTE.fullmatch(path)
item = ITEM.fullmatch(path)
board = BOARD.fullmatch(path)
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):
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:
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":
return lambda: self.answer(lambda: hub.message(body), HTTPStatus.CREATED)
return lambda body: self.answer(lambda: hub.message(body), HTTPStatus.CREATED)
if board:
return lambda: self.answer(lambda: hub.board(board.group(1), body), HTTPStatus.OK)
return self.inbox_action(path, body)
return lambda body: self.answer(lambda: hub.board(board.group(1), body), HTTPStatus.OK)
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
inbox = INBOX_ROUTE.fullmatch(path)
undo = UNDO_ROUTE.fullmatch(path)
rule = RULE_ROUTE.fullmatch(path)
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:
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:
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:
return lambda: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
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":
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:
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":
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:
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:
return lambda: self.speak(body)
return self.speak
if path == STT_ROUTE:
return lambda: self.hear(body)
return self.hear
return None
def posted(self, path: str) -> bool:
@@ -139,41 +136,51 @@ class Handler(Exchange):
def runner(self, path: str, body: bytes) -> None:
hub = self.server.hub
route = RUNNER_ROUTE.fullmatch(path)
if not self.client():
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only")
elif route.group(2) == "claim":
if route.group(2) == "claim":
self.answer(lambda: hub.claim(route.group(1)), HTTPStatus.OK)
else:
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:
path = urlsplit(self.path).path
body = self.body({UPLOADS: UPLOAD_LIMIT, STT_ROUTE: STT_LIMIT}.get(path, BODY_LIMIT))
if body is None:
length = self.body_length({UPLOADS: UPLOAD_LIMIT, STT_ROUTE: STT_LIMIT}.get(path, BODY_LIMIT))
if length is None:
return
host = self.host()
if host is None:
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
return
if path == "/mcp":
self.mcp(host, body)
return
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()
receive = self.receiver(path, host)
if receive is not None:
receive(self.rfile.read(length))
def index(self) -> None:
nonce = secrets.token_urlsafe(18)
@@ -188,16 +195,19 @@ class Handler(Exchange):
if reviews.get(review_id) is None or not path.is_file():
self.send_text(HTTPStatus.NOT_FOUND, "not found")
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")
if origins is not None and origins != [f"http://{host}"]:
self.send_text(HTTPStatus.FORBIDDEN, "foreign origin")
return
if self.headers.get_content_type() != "application/json":
elif self.headers.get_content_type() != "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)
if reply is not None:
self.send_json(status, reply)
+3 -1
View File
@@ -180,7 +180,9 @@ class Language:
now = time.time()
claims = read_claims(self.council)
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)))
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)
+1 -1
View File
@@ -97,7 +97,7 @@ class Reviewing:
def wait(self, arguments: dict) -> dict:
expect(arguments, ("id", "timeout_seconds"))
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))
def pending(self, arguments: dict) -> dict:
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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 }}">
:root {
--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)
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")]
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.stdin.write(json.dumps(HOOK))
process.stdin.write(json.dumps(payload))
process.stdin.close()
self.addCleanup(process.stdout.close)
self.addCleanup(process.stderr.close)
@@ -91,7 +91,7 @@ class TestHubAsks(HubCase):
relayed = []
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()
self.assertEqual((ask["tool"], ask["command"], ask["cwd"], ask["session"]), ("Bash", "rm -rf node_modules", "/repo", "s1"))
decide(ask["id"])
@@ -123,11 +123,37 @@ class TestHubAsks(HubCase):
code, out = asked(allow_by(key(-7, "main")))
self.assertEqual((code, json.loads(out)), (0, {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "allow"}}}))
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:
probe.bind(("127.0.0.1", 0))
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):
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(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):
now = time.time()
rule = {"kind": "ask", "tool": "Bash", "pattern": "make *", "scope": "/repo*", "expires": now + 3600}
+20
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import dataclasses
import json
import time
import unittest
@@ -99,6 +100,25 @@ class TestHubInbox(HubCase):
with self.subTest(text=text):
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__":
unittest.main()
+31 -1
View File
@@ -1,11 +1,12 @@
from __future__ import annotations
import http.client
import json
import unittest
from hub_case import HubCase
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"}
@@ -79,6 +80,35 @@ class TestHubMcp(HubCase):
self.assertEqual(self.post_mcp(ping, {"Origin": f"http://{self.host}"})[0], 200)
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):
tools = {entry["name"]: entry["inputSchema"] for entry in self.rpc("tools/list", {})["result"]["tools"]}
self.assertEqual(tools["review_request"]["properties"]["gate"]["enum"], ["pass", "fail"])
+10
View File
@@ -1,10 +1,13 @@
from __future__ import annotations
import errno
import json
import re
import subprocess
import time
import unittest
from pathlib import Path
from unittest import mock
from hub_case import HubCase
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(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):
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"}}}