Skip to content

Commit 2d8b990

Browse files
authored
Merge pull request #217 from JiusiServe/kb/holds-endpoint
Serve the signed hold list read-only for the kb-gate verifier
2 parents aed7c89 + 88b354d commit 2d8b990

5 files changed

Lines changed: 162 additions & 1 deletion

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# kb_service/ —— 规范
22

3-
<!-- verified-against: 2026-09-28 -->
3+
<!-- verified-against: 2026-09-29 -->
44

55
`知识服务核心:仓库配置、账本、outbox、CLI · refactor-status: new`
66

@@ -135,3 +135,8 @@ stdlib + PyYAML + `cryptography`(`kb` extra)+ `.adapters` + `.knowledge_serv
135135
知识路径都在该 PR 的改动内且内容与 head 相同;夹带额外改动的合并同样被发现)。否则(admin bypass、直接推送、账本漏记)对其涉及的仓库暂停自动合并(改动在仓库范围之外 → 全局暂停)、
136136
出队其打开的知识 PR 并转人工;不在 `auto_merge` 的仓库只写 `unrecorded_merge` trace。首次运行只记录基线;晚于
137137
2 小时宽限期的提交留到下次,避免与调度器记录合并赛跑。暂停逻辑与熔断共用 `Scheduler._pause`。
138+
139+
## 2026-09-29 暂停清单端点
140+
`holds_server`(`kb holds-server`)只以 `GET/HEAD /holds.json` 提供 `<state_dir>/public/holds.json`(`Cache-Control: no-store`),
141+
其余路径一律 404,不列目录、不跟随路径、不接受写入;清单尚不存在时返回 503(门禁视为不可达并失败关闭)。默认只绑定
142+
`127.0.0.1`,由 bot 主机的反向代理加 TLS 后对外,URL 写入 `.github/kb-gate/config.json` 的 `holds_url`。

‎doc/features/knowledge-service.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ python tools/build_kb_gate_bundle.py --check # CI 提醒(非阻塞)
8484
python tools/build_kb_gate_bundle.py --lock # 从 PyPI 重新按哈希固定依赖
8585
```
8686

87+
暂停清单端点(bot 主机):
88+
89+
```bash
90+
infermatrix-copilot kb holds-server --port 8765 # 只提供 GET /holds.json,默认绑定 127.0.0.1
91+
# 反向代理加 TLS,例如 Caddy: handle /kb/holds.json { rewrite * /holds.json; reverse_proxy 127.0.0.1:8765 }
92+
```
93+
8794
切换前的负责人步骤:提交 `.github/kb-gate.pub`(`kb keygen` 的公钥);在
8895
`.github/kb-gate/config.json` 填入暂停清单的公开 HTTPS 地址(`$KB_STATE_DIR/public/holds.json`);
8996
在 `.github/CODEOWNERS` 中再加入至少一名知识维护者;仓库 admin 开启合并队列(merge commit)

‎src/infermatrix_copilot/kb_service/cli.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
kb serve [--once] the scheduler (holds the single-writer lease)
1111
kb activate activate the knowledge snapshot of the repository's main now
1212
kb rollback --to SHA point `active` back at an earlier snapshot
13+
kb holds-server [--host H] [--port P] serve ONLY the signed hold list (read-only)
1314
kb traces [--kind K] [--changeset ID] [--rule ID] [--limit N] query trace/1 records
1415
kb replay --record ID --model PROVIDER:MODEL[:EFFORT] re-ask a recorded call
1516
kb export --out FILE [--role judge|generator] dataset (calibration-safe)
@@ -113,6 +114,9 @@ def main(argv: list[str] | None = None) -> int:
113114
sub.add_parser("activate")
114115
rollback = sub.add_parser("rollback")
115116
rollback.add_argument("--to", required=True)
117+
holds = sub.add_parser("holds-server")
118+
holds.add_argument("--host", default="127.0.0.1", help="bind address; put TLS in front of it")
119+
holds.add_argument("--port", type=int, default=8765)
116120
traces = sub.add_parser("traces")
117121
traces.add_argument("--kind")
118122
traces.add_argument("--changeset")
@@ -144,6 +148,11 @@ def main(argv: list[str] | None = None) -> int:
144148

145149
if args.command == "publish":
146150
return _publish(args)
151+
if args.command == "holds-server":
152+
from .holds_server import serve_holds
153+
154+
serve_holds(_state_dir(args.state_dir), host=args.host, port=args.port)
155+
return 0
147156
if args.command in {"traces", "replay", "export"}:
148157
return _traces_command(args, _state_dir(args.state_dir))
149158

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""The public, read-only endpoint for the signed hold list.
2+
3+
The kb-gate verifier (a GitHub-hosted runner with no secrets) reads the
4+
service's signed hold list over HTTPS and fails any knowledge segment it
5+
cannot fetch, verify, or that is stale. This server exposes exactly one file,
6+
``<state_dir>/public/holds.json``, at ``GET /holds.json``; everything else is
7+
404. It never lists directories, follows paths, accepts writes, or serves
8+
anything but that signed file, so running it exposes nothing the kb-gate does
9+
not already publish. Put TLS in front of it (the bot host's reverse proxy) and
10+
set that URL as ``holds_url`` in ``.github/kb-gate/config.json``.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import threading
16+
from http import HTTPStatus
17+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18+
from pathlib import Path
19+
20+
ROUTE = "/holds.json"
21+
MAX_BYTES = 1 << 20
22+
23+
24+
def make_handler(holds_path: Path):
25+
class HoldsHandler(BaseHTTPRequestHandler):
26+
server_version = "kb-holds"
27+
sys_version = ""
28+
29+
def _serve(self, with_body: bool) -> None:
30+
if self.path.split("?", 1)[0] != ROUTE:
31+
self.send_error(HTTPStatus.NOT_FOUND)
32+
return
33+
try:
34+
data = holds_path.read_bytes()[:MAX_BYTES]
35+
except OSError:
36+
# no hold list yet: the gate treats unreachable as held
37+
self.send_error(HTTPStatus.SERVICE_UNAVAILABLE)
38+
return
39+
self.send_response(HTTPStatus.OK)
40+
self.send_header("Content-Type", "application/json")
41+
self.send_header("Content-Length", str(len(data)))
42+
self.send_header("Cache-Control", "no-store")
43+
self.end_headers()
44+
if with_body:
45+
self.wfile.write(data)
46+
47+
def do_GET(self) -> None: # noqa: N802 - http.server API
48+
self._serve(True)
49+
50+
def do_HEAD(self) -> None: # noqa: N802
51+
self._serve(False)
52+
53+
def log_message(self, format: str, *args) -> None: # noqa: A002 - quiet by default
54+
return
55+
56+
return HoldsHandler
57+
58+
59+
def serve_holds(state_dir: str | Path, *, host: str = "127.0.0.1", port: int = 8765,
60+
stop: threading.Event | None = None) -> ThreadingHTTPServer:
61+
"""Serve until ``stop`` is set (blocking); returns the server when stopped."""
62+
server = ThreadingHTTPServer((host, port), make_handler(Path(state_dir) / "public" / "holds.json"))
63+
server.daemon_threads = True
64+
if stop is None:
65+
try:
66+
server.serve_forever()
67+
finally:
68+
server.server_close()
69+
return server
70+
thread = threading.Thread(target=server.serve_forever, daemon=True)
71+
thread.start()
72+
stop.wait()
73+
server.shutdown()
74+
server.server_close()
75+
return server

‎test/test_kb_holds_server.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""The hold-list endpoint serves exactly one signed file, read-only."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import threading
7+
import urllib.error
8+
import urllib.request
9+
10+
import pytest
11+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
12+
13+
from infermatrix_copilot.kb_service.holds_server import make_handler
14+
from infermatrix_copilot.knowledge_service import gate_verifier as gv
15+
from infermatrix_copilot.knowledge_service.signing import sign
16+
17+
18+
@pytest.fixture
19+
def server(tmp_path):
20+
from http.server import ThreadingHTTPServer
21+
22+
holds = tmp_path / "public" / "holds.json"
23+
srv = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(holds))
24+
thread = threading.Thread(target=srv.serve_forever, daemon=True)
25+
thread.start()
26+
yield srv, holds, f"http://127.0.0.1:{srv.server_address[1]}"
27+
srv.shutdown()
28+
srv.server_close()
29+
30+
31+
def _status(url, method="GET"):
32+
try:
33+
with urllib.request.urlopen(urllib.request.Request(url, method=method), timeout=5) as response:
34+
return response.status, response.read(), response.headers
35+
except urllib.error.HTTPError as exc:
36+
return exc.code, b"", exc.headers
37+
38+
39+
def test_the_signed_hold_list_is_served_and_verifies_through_the_gate(server):
40+
import time
41+
42+
srv, holds, base = server
43+
key = Ed25519PrivateKey.generate()
44+
holds.parent.mkdir(parents=True)
45+
payload = {"issued_at": time.time(), "sequence": 3, "global": False, "repos": ["demo"], "prs": [7]}
46+
holds.write_text(json.dumps(sign("kb-holds", payload, key)))
47+
status, body, headers = _status(base + "/holds.json")
48+
assert status == 200 and headers["Cache-Control"] == "no-store"
49+
# exactly what the kb-gate verifier does with it
50+
envelope = gv.fetch_holds(base + "/holds.json")
51+
assert gv.verify_holds(envelope, key.public_key(), now=time.time())["prs"] == [7]
52+
53+
54+
def test_nothing_else_is_served(server, tmp_path):
55+
srv, holds, base = server
56+
holds.parent.mkdir(parents=True)
57+
holds.write_text("{}")
58+
(tmp_path / "kb.db").write_text("secret ledger")
59+
for path in ("/", "/kb.db", "/../kb.db", "/public/", "/public/holds.json", "/holds.json/../kb.db"):
60+
assert _status(base + path)[0] == 404, path
61+
assert _status(base + "/holds.json", method="POST")[0] in (404, 405, 501)
62+
63+
64+
def test_a_missing_hold_list_is_unavailable_not_empty(server):
65+
assert _status(server[2] + "/holds.json")[0] == 503

0 commit comments

Comments
 (0)