diff --git a/doc/architecture/SPEC/kb_service.md b/doc/architecture/SPEC/kb_service.md index d0af455d..1c951cdf 100644 --- a/doc/architecture/SPEC/kb_service.md +++ b/doc/architecture/SPEC/kb_service.md @@ -1,6 +1,6 @@ # kb_service/ —— 规范 - + `知识服务核心:仓库配置、账本、outbox、CLI · refactor-status: new` @@ -135,3 +135,8 @@ stdlib + PyYAML + `cryptography`(`kb` extra)+ `.adapters` + `.knowledge_serv 知识路径都在该 PR 的改动内且内容与 head 相同;夹带额外改动的合并同样被发现)。否则(admin bypass、直接推送、账本漏记)对其涉及的仓库暂停自动合并(改动在仓库范围之外 → 全局暂停)、 出队其打开的知识 PR 并转人工;不在 `auto_merge` 的仓库只写 `unrecorded_merge` trace。首次运行只记录基线;晚于 2 小时宽限期的提交留到下次,避免与调度器记录合并赛跑。暂停逻辑与熔断共用 `Scheduler._pause`。 + +## 2026-09-29 暂停清单端点 +`holds_server`(`kb holds-server`)只以 `GET/HEAD /holds.json` 提供 `/public/holds.json`(`Cache-Control: no-store`), +其余路径一律 404,不列目录、不跟随路径、不接受写入;清单尚不存在时返回 503(门禁视为不可达并失败关闭)。默认只绑定 +`127.0.0.1`,由 bot 主机的反向代理加 TLS 后对外,URL 写入 `.github/kb-gate/config.json` 的 `holds_url`。 diff --git a/doc/features/knowledge-service.md b/doc/features/knowledge-service.md index 5d34c668..9ffa2e1b 100644 --- a/doc/features/knowledge-service.md +++ b/doc/features/knowledge-service.md @@ -84,6 +84,13 @@ python tools/build_kb_gate_bundle.py --check # CI 提醒(非阻塞) python tools/build_kb_gate_bundle.py --lock # 从 PyPI 重新按哈希固定依赖 ``` +暂停清单端点(bot 主机): + +```bash +infermatrix-copilot kb holds-server --port 8765 # 只提供 GET /holds.json,默认绑定 127.0.0.1 +# 反向代理加 TLS,例如 Caddy: handle /kb/holds.json { rewrite * /holds.json; reverse_proxy 127.0.0.1:8765 } +``` + 切换前的负责人步骤:提交 `.github/kb-gate.pub`(`kb keygen` 的公钥);在 `.github/kb-gate/config.json` 填入暂停清单的公开 HTTPS 地址(`$KB_STATE_DIR/public/holds.json`); 在 `.github/CODEOWNERS` 中再加入至少一名知识维护者;仓库 admin 开启合并队列(merge commit) diff --git a/src/infermatrix_copilot/kb_service/cli.py b/src/infermatrix_copilot/kb_service/cli.py index 4d831523..72247e98 100644 --- a/src/infermatrix_copilot/kb_service/cli.py +++ b/src/infermatrix_copilot/kb_service/cli.py @@ -10,6 +10,7 @@ kb serve [--once] the scheduler (holds the single-writer lease) kb activate activate the knowledge snapshot of the repository's main now kb rollback --to SHA point `active` back at an earlier snapshot + kb holds-server [--host H] [--port P] serve ONLY the signed hold list (read-only) kb traces [--kind K] [--changeset ID] [--rule ID] [--limit N] query trace/1 records kb replay --record ID --model PROVIDER:MODEL[:EFFORT] re-ask a recorded call kb export --out FILE [--role judge|generator] dataset (calibration-safe) @@ -113,6 +114,9 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("activate") rollback = sub.add_parser("rollback") rollback.add_argument("--to", required=True) + holds = sub.add_parser("holds-server") + holds.add_argument("--host", default="127.0.0.1", help="bind address; put TLS in front of it") + holds.add_argument("--port", type=int, default=8765) traces = sub.add_parser("traces") traces.add_argument("--kind") traces.add_argument("--changeset") @@ -144,6 +148,11 @@ def main(argv: list[str] | None = None) -> int: if args.command == "publish": return _publish(args) + if args.command == "holds-server": + from .holds_server import serve_holds + + serve_holds(_state_dir(args.state_dir), host=args.host, port=args.port) + return 0 if args.command in {"traces", "replay", "export"}: return _traces_command(args, _state_dir(args.state_dir)) diff --git a/src/infermatrix_copilot/kb_service/holds_server.py b/src/infermatrix_copilot/kb_service/holds_server.py new file mode 100644 index 00000000..9bbf9c16 --- /dev/null +++ b/src/infermatrix_copilot/kb_service/holds_server.py @@ -0,0 +1,75 @@ +"""The public, read-only endpoint for the signed hold list. + +The kb-gate verifier (a GitHub-hosted runner with no secrets) reads the +service's signed hold list over HTTPS and fails any knowledge segment it +cannot fetch, verify, or that is stale. This server exposes exactly one file, +``/public/holds.json``, at ``GET /holds.json``; everything else is +404. It never lists directories, follows paths, accepts writes, or serves +anything but that signed file, so running it exposes nothing the kb-gate does +not already publish. Put TLS in front of it (the bot host's reverse proxy) and +set that URL as ``holds_url`` in ``.github/kb-gate/config.json``. +""" + +from __future__ import annotations + +import threading +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROUTE = "/holds.json" +MAX_BYTES = 1 << 20 + + +def make_handler(holds_path: Path): + class HoldsHandler(BaseHTTPRequestHandler): + server_version = "kb-holds" + sys_version = "" + + def _serve(self, with_body: bool) -> None: + if self.path.split("?", 1)[0] != ROUTE: + self.send_error(HTTPStatus.NOT_FOUND) + return + try: + data = holds_path.read_bytes()[:MAX_BYTES] + except OSError: + # no hold list yet: the gate treats unreachable as held + self.send_error(HTTPStatus.SERVICE_UNAVAILABLE) + return + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + if with_body: + self.wfile.write(data) + + def do_GET(self) -> None: # noqa: N802 - http.server API + self._serve(True) + + def do_HEAD(self) -> None: # noqa: N802 + self._serve(False) + + def log_message(self, format: str, *args) -> None: # noqa: A002 - quiet by default + return + + return HoldsHandler + + +def serve_holds(state_dir: str | Path, *, host: str = "127.0.0.1", port: int = 8765, + stop: threading.Event | None = None) -> ThreadingHTTPServer: + """Serve until ``stop`` is set (blocking); returns the server when stopped.""" + server = ThreadingHTTPServer((host, port), make_handler(Path(state_dir) / "public" / "holds.json")) + server.daemon_threads = True + if stop is None: + try: + server.serve_forever() + finally: + server.server_close() + return server + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + stop.wait() + server.shutdown() + server.server_close() + return server diff --git a/test/test_kb_holds_server.py b/test/test_kb_holds_server.py new file mode 100644 index 00000000..f942c17d --- /dev/null +++ b/test/test_kb_holds_server.py @@ -0,0 +1,65 @@ +"""The hold-list endpoint serves exactly one signed file, read-only.""" + +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from infermatrix_copilot.kb_service.holds_server import make_handler +from infermatrix_copilot.knowledge_service import gate_verifier as gv +from infermatrix_copilot.knowledge_service.signing import sign + + +@pytest.fixture +def server(tmp_path): + from http.server import ThreadingHTTPServer + + holds = tmp_path / "public" / "holds.json" + srv = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(holds)) + thread = threading.Thread(target=srv.serve_forever, daemon=True) + thread.start() + yield srv, holds, f"http://127.0.0.1:{srv.server_address[1]}" + srv.shutdown() + srv.server_close() + + +def _status(url, method="GET"): + try: + with urllib.request.urlopen(urllib.request.Request(url, method=method), timeout=5) as response: + return response.status, response.read(), response.headers + except urllib.error.HTTPError as exc: + return exc.code, b"", exc.headers + + +def test_the_signed_hold_list_is_served_and_verifies_through_the_gate(server): + import time + + srv, holds, base = server + key = Ed25519PrivateKey.generate() + holds.parent.mkdir(parents=True) + payload = {"issued_at": time.time(), "sequence": 3, "global": False, "repos": ["demo"], "prs": [7]} + holds.write_text(json.dumps(sign("kb-holds", payload, key))) + status, body, headers = _status(base + "/holds.json") + assert status == 200 and headers["Cache-Control"] == "no-store" + # exactly what the kb-gate verifier does with it + envelope = gv.fetch_holds(base + "/holds.json") + assert gv.verify_holds(envelope, key.public_key(), now=time.time())["prs"] == [7] + + +def test_nothing_else_is_served(server, tmp_path): + srv, holds, base = server + holds.parent.mkdir(parents=True) + holds.write_text("{}") + (tmp_path / "kb.db").write_text("secret ledger") + for path in ("/", "/kb.db", "/../kb.db", "/public/", "/public/holds.json", "/holds.json/../kb.db"): + assert _status(base + path)[0] == 404, path + assert _status(base + "/holds.json", method="POST")[0] in (404, 405, 501) + + +def test_a_missing_hold_list_is_unavailable_not_empty(server): + assert _status(server[2] + "/holds.json")[0] == 503