[claude] Weekly privacy audit cron + fleet HTTP API (#1075) #1109
28
.gitea/workflows/weekly-audit.yml
Normal file
28
.gitea/workflows/weekly-audit.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Weekly Privacy Audit
|
||||
|
||||
# Runs every Monday at 05:00 UTC against a CI test fixture.
|
||||
# On production wizards this same script should be run via cron:
|
||||
# 0 5 * * 1 python /opt/nexus/mempalace/audit_privacy.py /var/lib/mempalace/fleet
|
||||
#
|
||||
# Refs: #1083, #1075
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 5 * * 1" # Monday 05:00 UTC
|
||||
workflow_dispatch: {} # allow manual trigger
|
||||
|
||||
jobs:
|
||||
privacy-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Run privacy audit against CI fixture
|
||||
run: |
|
||||
python mempalace/audit_privacy.py tests/fixtures/fleet_palace
|
||||
186
mempalace/fleet_api.py
Normal file
186
mempalace/fleet_api.py
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fleet_api.py — Lightweight HTTP API for the shared fleet palace.
|
||||
|
||||
Exposes fleet memory search over HTTP so that Alpha servers and other
|
||||
wizard deployments can query the palace without direct filesystem access.
|
||||
|
||||
Endpoints:
|
||||
GET /health
|
||||
Returns {"status": "ok", "palace": "<path>"}
|
||||
|
||||
GET /search?q=<query>[&room=<room>][&n=<int>]
|
||||
Returns {"results": [...], "query": "...", "room": "...", "count": N}
|
||||
Each result: {"text": "...", "room": "...", "wing": "...", "score": 0.9}
|
||||
|
||||
GET /wings
|
||||
Returns {"wings": ["bezalel", ...]} — distinct wizard wings present
|
||||
|
||||
Error responses use {"error": "<message>"} with appropriate HTTP status codes.
|
||||
|
||||
Usage:
|
||||
# Default: localhost:7771, fleet palace at /var/lib/mempalace/fleet
|
||||
python mempalace/fleet_api.py
|
||||
|
||||
# Custom host/port/palace:
|
||||
FLEET_PALACE_PATH=/data/fleet python mempalace/fleet_api.py --host 0.0.0.0 --port 8080
|
||||
|
||||
Refs: #1078, #1075
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Add repo root to path so we can import nexus.mempalace
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_REPO_ROOT = _HERE.parent
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 7771
|
||||
MAX_RESULTS = 50
|
||||
|
||||
|
||||
def _get_palace_path() -> Path:
|
||||
return Path(os.environ.get("FLEET_PALACE_PATH", "/var/lib/mempalace/fleet"))
|
||||
|
||||
|
||||
def _json_response(handler: BaseHTTPRequestHandler, status: int, body: dict) -> None:
|
||||
payload = json.dumps(body).encode()
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(payload)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(payload)
|
||||
|
||||
|
||||
def _handle_health(handler: BaseHTTPRequestHandler) -> None:
|
||||
palace = _get_palace_path()
|
||||
_json_response(handler, 200, {
|
||||
"status": "ok",
|
||||
"palace": str(palace),
|
||||
"palace_exists": palace.exists(),
|
||||
})
|
||||
|
||||
|
||||
def _handle_search(handler: BaseHTTPRequestHandler, qs: dict) -> None:
|
||||
query_terms = qs.get("q", [""])
|
||||
q = query_terms[0].strip() if query_terms else ""
|
||||
if not q:
|
||||
_json_response(handler, 400, {"error": "Missing required parameter: q"})
|
||||
return
|
||||
|
||||
room_terms = qs.get("room", [])
|
||||
room = room_terms[0].strip() if room_terms else None
|
||||
|
||||
n_terms = qs.get("n", [])
|
||||
try:
|
||||
n = max(1, min(int(n_terms[0]), MAX_RESULTS)) if n_terms else 10
|
||||
except (ValueError, IndexError):
|
||||
_json_response(handler, 400, {"error": "Invalid parameter: n must be an integer"})
|
||||
return
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import search_fleet, MemPalaceUnavailable
|
||||
except ImportError as exc:
|
||||
_json_response(handler, 503, {"error": f"MemPalace module not available: {exc}"})
|
||||
return
|
||||
|
||||
try:
|
||||
results = search_fleet(q, room=room, n_results=n)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_json_response(handler, 503, {"error": str(exc)})
|
||||
return
|
||||
|
||||
_json_response(handler, 200, {
|
||||
"query": q,
|
||||
"room": room,
|
||||
"count": len(results),
|
||||
"results": [
|
||||
{
|
||||
"text": r.text,
|
||||
"room": r.room,
|
||||
"wing": r.wing,
|
||||
"score": round(r.score, 4),
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def _handle_wings(handler: BaseHTTPRequestHandler) -> None:
|
||||
"""Return distinct wizard wing names found in the fleet palace directory."""
|
||||
palace = _get_palace_path()
|
||||
if not palace.exists():
|
||||
_json_response(handler, 503, {
|
||||
"error": f"Fleet palace not found: {palace}",
|
||||
})
|
||||
return
|
||||
|
||||
wings = sorted({
|
||||
p.name for p in palace.iterdir() if p.is_dir()
|
||||
})
|
||||
_json_response(handler, 200, {"wings": wings})
|
||||
|
||||
|
||||
class FleetAPIHandler(BaseHTTPRequestHandler):
|
||||
"""Request handler for the fleet memory API."""
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None: # noqa: ANN001
|
||||
# Prefix with tag for easier log filtering
|
||||
sys.stderr.write(f"[fleet_api] {fmt % args}\n")
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
qs = parse_qs(parsed.query)
|
||||
|
||||
if path == "/health":
|
||||
_handle_health(self)
|
||||
elif path == "/search":
|
||||
_handle_search(self, qs)
|
||||
elif path == "/wings":
|
||||
_handle_wings(self)
|
||||
else:
|
||||
_json_response(self, 404, {
|
||||
"error": f"Unknown endpoint: {path}",
|
||||
"endpoints": ["/health", "/search", "/wings"],
|
||||
})
|
||||
|
||||
|
||||
def make_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> HTTPServer:
|
||||
return HTTPServer((host, port), FleetAPIHandler)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fleet palace HTTP API server."
|
||||
)
|
||||
parser.add_argument("--host", default=DEFAULT_HOST, help=f"Bind host (default: {DEFAULT_HOST})")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Bind port (default: {DEFAULT_PORT})")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
palace = _get_palace_path()
|
||||
print(f"[fleet_api] Palace: {palace}")
|
||||
if not palace.exists():
|
||||
print(f"[fleet_api] WARNING: palace path does not exist yet: {palace}", file=sys.stderr)
|
||||
|
||||
server = make_server(args.host, args.port)
|
||||
print(f"[fleet_api] Listening on http://{args.host}:{args.port}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[fleet_api] Shutting down.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
16
tests/fixtures/fleet_palace/bezalel/forge.closet.json
vendored
Normal file
16
tests/fixtures/fleet_palace/bezalel/forge.closet.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"wizard": "bezalel",
|
||||
"room": "forge",
|
||||
"drawers": [
|
||||
{
|
||||
"text": "CI pipeline green on main. All 253 tests passing.",
|
||||
"source_file": "forge.closet.json",
|
||||
"closet": true
|
||||
},
|
||||
{
|
||||
"text": "Deployed nexus heartbeat cron fix to Beta. Poka-yoke checks pass.",
|
||||
"source_file": "forge.closet.json",
|
||||
"closet": true
|
||||
}
|
||||
]
|
||||
}
|
||||
11
tests/fixtures/fleet_palace/bezalel/hermes.closet.json
vendored
Normal file
11
tests/fixtures/fleet_palace/bezalel/hermes.closet.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"wizard": "bezalel",
|
||||
"room": "hermes",
|
||||
"drawers": [
|
||||
{
|
||||
"text": "Hermes gateway v2 deployed. MCP tools registered: mempalace, gitea, cron.",
|
||||
"source_file": "hermes.closet.json",
|
||||
"closet": true
|
||||
}
|
||||
]
|
||||
}
|
||||
11
tests/fixtures/fleet_palace/bezalel/issues.closet.json
vendored
Normal file
11
tests/fixtures/fleet_palace/bezalel/issues.closet.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"wizard": "bezalel",
|
||||
"room": "issues",
|
||||
"drawers": [
|
||||
{
|
||||
"text": "MemPalace x Evennia milestone: 6 of 8 issues closed. #1078 and #1083 in progress.",
|
||||
"source_file": "issues.closet.json",
|
||||
"closet": true
|
||||
}
|
||||
]
|
||||
}
|
||||
239
tests/test_mempalace_fleet_api.py
Normal file
239
tests/test_mempalace_fleet_api.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Tests for mempalace/fleet_api.py — Alpha-side HTTP fleet memory API.
|
||||
|
||||
Refs: #1078, #1075
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Import handler directly so we can test without running a server process.
|
||||
from mempalace.fleet_api import FleetAPIHandler, _handle_health, _handle_search, _handle_wings, make_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeSocket:
|
||||
"""Minimal socket stub for BaseHTTPRequestHandler."""
|
||||
|
||||
def makefile(self, mode: str, *args, **kwargs): # noqa: ANN001
|
||||
return io.BytesIO(b"")
|
||||
|
||||
|
||||
def _make_handler(path: str = "/health") -> tuple[FleetAPIHandler, io.BytesIO]:
|
||||
"""Construct a handler pointed at *path*, capture wfile output."""
|
||||
buf = io.BytesIO()
|
||||
request = _FakeSocket()
|
||||
client_address = ("127.0.0.1", 0)
|
||||
|
||||
handler = FleetAPIHandler.__new__(FleetAPIHandler)
|
||||
handler.path = path
|
||||
handler.request = request
|
||||
handler.client_address = client_address
|
||||
handler.server = MagicMock()
|
||||
handler.wfile = buf
|
||||
handler.rfile = io.BytesIO(b"")
|
||||
handler.command = "GET"
|
||||
handler._headers_buffer = []
|
||||
|
||||
# Stub send_response / send_header / end_headers to write minimal HTTP
|
||||
handler._response_code = None
|
||||
def _send_response(code, message=None): # noqa: ANN001
|
||||
handler._response_code = code
|
||||
def _send_header(k, v): # noqa: ANN001
|
||||
pass
|
||||
def _end_headers(): # noqa: ANN001
|
||||
pass
|
||||
handler.send_response = _send_response
|
||||
handler.send_header = _send_header
|
||||
handler.end_headers = _end_headers
|
||||
|
||||
return handler, buf
|
||||
|
||||
|
||||
def _parse_response(buf: io.BytesIO) -> dict:
|
||||
buf.seek(0)
|
||||
return json.loads(buf.read())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_health_returns_ok(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path))
|
||||
handler, buf = _make_handler("/health")
|
||||
_handle_health(handler)
|
||||
data = _parse_response(buf)
|
||||
assert data["status"] == "ok"
|
||||
assert data["palace_exists"] is True
|
||||
|
||||
|
||||
def test_health_missing_palace(tmp_path, monkeypatch):
|
||||
missing = tmp_path / "nonexistent"
|
||||
monkeypatch.setenv("FLEET_PALACE_PATH", str(missing))
|
||||
handler, buf = _make_handler("/health")
|
||||
_handle_health(handler)
|
||||
data = _parse_response(buf)
|
||||
assert data["status"] == "ok"
|
||||
assert data["palace_exists"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mock_search_fleet(results):
|
||||
"""Return a patch target that returns *results*."""
|
||||
mock = MagicMock(return_value=results)
|
||||
return mock
|
||||
|
||||
|
||||
def _make_result(text="hello", room="forge", wing="bezalel", score=0.9):
|
||||
from nexus.mempalace.searcher import MemPalaceResult
|
||||
return MemPalaceResult(text=text, room=room, wing=wing, score=score)
|
||||
|
||||
|
||||
def test_search_missing_q_param():
|
||||
handler, buf = _make_handler("/search")
|
||||
_handle_search(handler, {})
|
||||
data = _parse_response(buf)
|
||||
assert "error" in data
|
||||
assert handler._response_code == 400
|
||||
|
||||
|
||||
def test_search_returns_results(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path))
|
||||
(tmp_path / "chroma.sqlite3").touch()
|
||||
result = _make_result(text="CI green", room="forge", wing="bezalel", score=0.95)
|
||||
|
||||
with patch("mempalace.fleet_api.FleetAPIHandler") as _:
|
||||
handler, buf = _make_handler("/search?q=CI")
|
||||
|
||||
import nexus.mempalace.searcher as s_module
|
||||
with patch.object(s_module, "search_fleet", return_value=[result]):
|
||||
import importlib
|
||||
import mempalace.fleet_api as api_module
|
||||
# Patch search_fleet inside the handler's import context
|
||||
with patch("nexus.mempalace.searcher.search_fleet", return_value=[result]):
|
||||
_handle_search(handler, {"q": ["CI"]})
|
||||
|
||||
data = _parse_response(buf)
|
||||
assert data["count"] == 1
|
||||
assert data["results"][0]["text"] == "CI green"
|
||||
assert data["results"][0]["room"] == "forge"
|
||||
assert data["results"][0]["wing"] == "bezalel"
|
||||
assert data["results"][0]["score"] == 0.95
|
||||
assert handler._response_code == 200
|
||||
|
||||
|
||||
def test_search_with_room_filter(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path))
|
||||
result = _make_result()
|
||||
|
||||
import nexus.mempalace.searcher as s_module
|
||||
with patch.object(s_module, "search_fleet", return_value=[result]) as mock_sf:
|
||||
_handle_search(MagicMock(), {"q": ["test"], "room": ["hermes"]})
|
||||
|
||||
# Verify room was passed through
|
||||
mock_sf.assert_called_once_with("test", room="hermes", n_results=10)
|
||||
|
||||
|
||||
def test_search_invalid_n_param():
|
||||
handler, buf = _make_handler("/search?q=test&n=bad")
|
||||
_handle_search(handler, {"q": ["test"], "n": ["bad"]})
|
||||
data = _parse_response(buf)
|
||||
assert "error" in data
|
||||
assert handler._response_code == 400
|
||||
|
||||
|
||||
def test_search_palace_unavailable(monkeypatch):
|
||||
from nexus.mempalace.searcher import MemPalaceUnavailable
|
||||
|
||||
handler, buf = _make_handler("/search?q=test")
|
||||
|
||||
import nexus.mempalace.searcher as s_module
|
||||
with patch.object(s_module, "search_fleet", side_effect=MemPalaceUnavailable("no palace")):
|
||||
_handle_search(handler, {"q": ["test"]})
|
||||
|
||||
data = _parse_response(buf)
|
||||
assert "error" in data
|
||||
assert handler._response_code == 503
|
||||
|
||||
|
||||
def test_search_n_clamped_to_max():
|
||||
"""n > MAX_RESULTS is silently clamped."""
|
||||
import nexus.mempalace.searcher as s_module
|
||||
with patch.object(s_module, "search_fleet", return_value=[]) as mock_sf:
|
||||
handler = MagicMock()
|
||||
_handle_search(handler, {"q": ["test"], "n": ["9999"]})
|
||||
|
||||
mock_sf.assert_called_once_with("test", room=None, n_results=50)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /wings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_wings_returns_list(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path))
|
||||
(tmp_path / "bezalel").mkdir()
|
||||
(tmp_path / "timmy").mkdir()
|
||||
# A file should not appear in wings
|
||||
(tmp_path / "README.txt").touch()
|
||||
|
||||
handler, buf = _make_handler("/wings")
|
||||
_handle_wings(handler)
|
||||
data = _parse_response(buf)
|
||||
assert set(data["wings"]) == {"bezalel", "timmy"}
|
||||
assert handler._response_code == 200
|
||||
|
||||
|
||||
def test_wings_missing_palace(tmp_path, monkeypatch):
|
||||
missing = tmp_path / "nonexistent"
|
||||
monkeypatch.setenv("FLEET_PALACE_PATH", str(missing))
|
||||
handler, buf = _make_handler("/wings")
|
||||
_handle_wings(handler)
|
||||
data = _parse_response(buf)
|
||||
assert "error" in data
|
||||
assert handler._response_code == 503
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 404 unknown endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_unknown_endpoint():
|
||||
handler, buf = _make_handler("/foobar")
|
||||
handler.do_GET()
|
||||
data = _parse_response(buf)
|
||||
assert "error" in data
|
||||
assert handler._response_code == 404
|
||||
assert "/search" in data["endpoints"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# audit fixture smoke test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_audit_fixture_is_clean():
|
||||
"""Ensure tests/fixtures/fleet_palace/ passes privacy audit (no violations)."""
|
||||
from mempalace.audit_privacy import audit_palace
|
||||
|
||||
fixture_dir = Path(__file__).parent / "fixtures" / "fleet_palace"
|
||||
assert fixture_dir.exists(), f"Fixture directory missing: {fixture_dir}"
|
||||
|
||||
result = audit_palace(fixture_dir)
|
||||
assert result.clean, (
|
||||
f"Privacy violations found in CI fixture:\n" +
|
||||
"\n".join(f" [{v.rule}] {v.path}: {v.detail}" for v in result.violations)
|
||||
)
|
||||
Reference in New Issue
Block a user