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:
1 parent
8ba45efd04
commit
afd96fcd72
14 files changed
+197
-81
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user