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>
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from collections.abc import Callable
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from . import NAME
|
|
from .errors import Conflict, EngineError, Missing, ToolError, Unavailable
|
|
from .hub import Hub
|
|
|
|
GUARD = "X-Kvb-Hub"
|
|
PLAIN_CSP = "default-src 'none'; frame-ancestors 'none'"
|
|
LENGTH = re.compile(r"[0-9]{1,9}")
|
|
QUERY_FIELDS = 32
|
|
|
|
|
|
class HubServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
hub: Hub
|
|
|
|
|
|
class Exchange(BaseHTTPRequestHandler):
|
|
server: HubServer
|
|
server_version = NAME
|
|
sys_version = ""
|
|
timeout = 60
|
|
|
|
def send(self, status: HTTPStatus, content_type: str, body: bytes, headers: dict[str, str]) -> None:
|
|
self.send_response(status)
|
|
common = {
|
|
"Content-Type": content_type,
|
|
"Content-Length": str(len(body)),
|
|
"Cache-Control": "no-store",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "no-referrer",
|
|
}
|
|
for name, value in {**common, **headers}.items():
|
|
self.send_header(name, value)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def send_text(self, status: HTTPStatus, message: str) -> None:
|
|
self.send(status, "text/plain; charset=utf-8", f"{message}\n".encode(), {"Content-Security-Policy": PLAIN_CSP})
|
|
|
|
def send_json(self, status: HTTPStatus, payload: dict) -> None:
|
|
self.send(status, "application/json", json.dumps(payload).encode(), {"Content-Security-Policy": PLAIN_CSP})
|
|
|
|
def not_allowed(self) -> None:
|
|
self.send_response(HTTPStatus.METHOD_NOT_ALLOWED)
|
|
self.send_header("Allow", "POST")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
def answer(self, produce: Callable[[], dict], status: HTTPStatus) -> None:
|
|
self.attempt(produce, lambda payload: self.send_json(status, payload))
|
|
|
|
def attempt(self, produce: Callable[[], object], deliver: Callable[[object], None]) -> None:
|
|
try:
|
|
result = produce()
|
|
except ToolError as error:
|
|
self.send_text(HTTPStatus.BAD_REQUEST, str(error))
|
|
except Missing:
|
|
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
|
except Conflict as error:
|
|
self.send_json(HTTPStatus.CONFLICT, error.payload)
|
|
except Unavailable as error:
|
|
self.send_text(HTTPStatus.SERVICE_UNAVAILABLE, str(error))
|
|
except EngineError as error:
|
|
self.send_text(HTTPStatus.BAD_GATEWAY, str(error))
|
|
except OSError as error:
|
|
self.send_text(HTTPStatus.INTERNAL_SERVER_ERROR, f"storage failed: {error.strerror}")
|
|
else:
|
|
deliver(result)
|
|
|
|
def query(self) -> dict[str, list[str]]:
|
|
try:
|
|
return parse_qs(urlsplit(self.path).query, keep_blank_values=True, max_num_fields=QUERY_FIELDS)
|
|
except ValueError as error:
|
|
raise ToolError(str(error)) from error
|
|
|
|
def host(self) -> str | None:
|
|
hosts = self.headers.get_all("Host")
|
|
if hosts is None or len(hosts) != 1 or hosts[0] not in self.server.hub.hosts():
|
|
return None
|
|
return hosts[0]
|
|
|
|
def guarded(self, host: str) -> bool:
|
|
return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") == [f"http://{host}"]
|
|
|
|
def local(self, host: str) -> bool:
|
|
origins = self.headers.get_all("Origin")
|
|
return self.headers.get_all(GUARD) == ["1"] and (origins is None or origins == [f"http://{host}"])
|
|
|
|
def client(self) -> bool:
|
|
return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") is 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")
|
|
return None
|
|
if int(length) > limit:
|
|
self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large")
|
|
return None
|
|
return int(length)
|