From 8588d99a8e434dae8a2d018e3f693c4bdfdbcb15 Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:45:13 +0200 Subject: [PATCH 1/7] fix: search: action crashes when search input absent Calling with actions like 'search:foo' on a page without input[placeholder="Search memories..."] (e.g. wrong route or unauthenticated state) makes `input` null; `input.type` throws TypeError and no screenshot is saved. Found by the GLM relay (memorygate__dashboard__screenshot-interact.mjs_p0__bugs__0), confirmed by GLM votes (round 2). Regression test to add: Run the script with actions 'search:foo' against a route lacking the search input and verify it logs an error, skips the action, and still saves the screenshot. Co-Authored-By: Claude Opus 5.5 --- dashboard/screenshot-interact.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard/screenshot-interact.mjs b/dashboard/screenshot-interact.mjs index 037ca53..9d3abd6 100644 --- a/dashboard/screenshot-interact.mjs +++ b/dashboard/screenshot-interact.mjs @@ -43,6 +43,7 @@ for (const action of actions) { } else if (action.startsWith('search:')) { const query = action.slice('search:'.length); const input = await page.$('input[placeholder="Search memories..."]'); + if (!input) { console.error(`search: input not found for "${query}"`); continue; } await input.type(query, { delay: 30 }); await new Promise((r) => setTimeout(r, 900)); } else if (action.startsWith('click:')) { From eb2315a56588b633cb3ff1ed519ce8dde5f57f9e Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:45:13 +0200 Subject: [PATCH 2/7] fix: Path traversal via encoded ../ in full_path serves files outside dist/ GET /..%2F..%2F..%2Fetc%2Fpasswd -> candidate = DIST_DIR/../../../etc/passwd resolves outside dist; candidate.is_file() is True and FileResponse returns the arbitrary system file (the {full_path:path} converter does not reject dot-segments after URL decoding). Found by the GLM relay (memorygate__dashboard__server.py_p0__bugs__0), confirmed by GLM votes (round 2). Regression test to add: Test that a GET to /..%2F..%2F..%2Fetc%2Fpasswd returns the SPA index.html (or a non-passwd response) rather than /etc/passwd contents, and that normal files under dist/ still serve. Co-Authored-By: Claude Opus 5.5 --- dashboard/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/server.py b/dashboard/server.py index 9144ae5..a7fbd54 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -22,8 +22,8 @@ def spa(full_path: str): if not DIST_DIR.exists(): raise HTTPException(503, "dashboard/dist not built yet - run `npm run build` in dashboard/") - candidate = DIST_DIR / full_path - if full_path and candidate.is_file(): + candidate = (DIST_DIR / full_path).resolve() + if full_path and candidate.is_file() and candidate.is_relative_to(DIST_DIR.resolve()): return FileResponse(candidate) return FileResponse(DIST_DIR / "index.html") From 8d5f0f2535df5189b99f90837c4c8ccdeb8461f2 Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:45:13 +0200 Subject: [PATCH 3/7] fix: app/__init__.py is flagged as an illegal loose module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If services/api/app/ contains a normal __init__.py (required for `from app import ...` to resolve in most setups), APP.glob("*.py") matches it, and since its name is not main.py the check reports '__init__.py: modules live in a package named for their type' and exits 1 — the invariant check fails on a perfectly legal layout. Found by the GLM relay (memorygate__scripts__check_package_boundaries.py_p0__bugs__0), confirmed by GLM votes (round 2). Regression test to add: Add an empty services/api/app/__init__.py and verify the script exits 0 and reports no loose-module or cross-package problems. Co-Authored-By: Claude Opus 5.5 --- scripts/check_package_boundaries.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/check_package_boundaries.py b/scripts/check_package_boundaries.py index 6475231..d307cee 100644 --- a/scripts/check_package_boundaries.py +++ b/scripts/check_package_boundaries.py @@ -49,6 +49,8 @@ def imported_packages(path: Path) -> set[str]: def edges() -> dict[tuple[str, str], list[str]]: found: dict[tuple[str, str], list[str]] = {} for path in sorted(APP.rglob("*.py")): + if path.name == "__init__.py": + continue path = path.relative_to(ROOT) source = owner(path) for target in imported_packages(path) - {source}: @@ -70,7 +72,7 @@ def main() -> int: problems = [ f"{path.as_posix()}: modules live in a package named for their type; only main.py sits in app/." for path in APP.glob("*.py") - if path.name != "main.py" + if path.name not in ("main.py", "__init__.py") ] allowed = allowlist() found = edges() From cf5aa2650e4cf9120a1f24bedb08e756009067c3 Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:45:14 +0200 Subject: [PATCH 4/7] fix: Unknown methods acknowledged as success A request with an id and an unrecognized method (e.g. "resources/list") falls into the elif "id" branch and gets an empty success result instead of a JSON-RPC Method not found error, so clients believe the capability exists. Found by the GLM relay (memorygate__services__mcp_p0__bugs__0), confirmed by GLM votes (round 2). Regression test to add: Send a JSON-RPC request with method "resources/list" and an id, and assert the response contains error code -32601 ("Method not found") rather than an empty result. Co-Authored-By: Claude Opus 5.5 --- services/mcp/memorygate_mcp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/mcp/memorygate_mcp.py b/services/mcp/memorygate_mcp.py index 16d4fe1..21dbe54 100644 --- a/services/mcp/memorygate_mcp.py +++ b/services/mcp/memorygate_mcp.py @@ -92,7 +92,7 @@ def main(): value = handler(params.get("arguments", {})) respond(request.get("id"), {"content": [{"type": "text", "text": json.dumps(value)}]}) elif "id" in request: - respond(request.get("id"), {}) + respond(request.get("id"), error="Method not found") except Exception: respond(request.get("id") if isinstance(request, dict) else None, error="Memory request failed; check configuration, scope and arguments.") From 5c288768334cc4f2b94f05594c57c47480bd5782 Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:45:14 +0200 Subject: [PATCH 5/7] fix: JSON-RPC batch requests crash the handler A batch payload like "[{\"id\":1,\"method\":\"tools/list\"}]" makes request.get raise AttributeError; the except branch then emits a single response with null id, so the batch's per-item responses are never delivered. Found by the GLM relay (memorygate__services__mcp_p0__bugs__1), confirmed by GLM votes (round 2). Regression test to add: A test should feed the line '[{"id":1,"method":"tools/list"}]' to the server and assert it emits an Invalid request error response with null id without crashing or attempting request.get on a list. Co-Authored-By: Claude Opus 5.5 --- services/mcp/memorygate_mcp.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/mcp/memorygate_mcp.py b/services/mcp/memorygate_mcp.py index 21dbe54..88bcf87 100644 --- a/services/mcp/memorygate_mcp.py +++ b/services/mcp/memorygate_mcp.py @@ -79,6 +79,9 @@ def main(): request = {} try: request = json.loads(line) + if not isinstance(request, dict): + respond(None, error="Invalid request: batch requests are not supported") + continue method = request.get("method") params = request.get("params", {}) if method == "initialize": From a7c57f29f27fd49c7b6ae8237e63229bc97a6eaa Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:45:14 +0200 Subject: [PATCH 6/7] fix: Unbounded query length in context tool schema memorygate_context's inputSchema imposes no maxLength on 'query' (unlike EXPLORE's strict schema), so a model/client can send an arbitrarily large query string that is forwarded verbatim to the backend, enabling unbounded input / DoS or oversized storage of query text. Found by the GLM relay (memorygate__services__mcp_p0__sec__0), confirmed by GLM votes (round 2). Regression test to add: A regression test should send a memorygate_context tools/call with a query longer than 2000 characters (or missing/non-string) and assert the server returns an error without issuing a request to the backend, while a query at or under the limit still succeeds. Co-Authored-By: Claude Opus 5.5 --- services/mcp/memorygate_mcp.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/mcp/memorygate_mcp.py b/services/mcp/memorygate_mcp.py index 88bcf87..5005425 100644 --- a/services/mcp/memorygate_mcp.py +++ b/services/mcp/memorygate_mcp.py @@ -16,7 +16,7 @@ def redirect_request(self, request, file_pointer, code, message, headers, new_ur TOOL = { "name": "memorygate_context", "description": "Retrieve bounded, evidence-aware personal memory context. Read-only; use it before answering personal questions.", - "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}, "include_evidence": {"type": "boolean"}}, "required": ["query"]}, + "inputSchema": {"type": "object", "additionalProperties": False, "properties": {"query": {"type": "string", "maxLength": 2000}, "include_evidence": {"type": "boolean"}}, "required": ["query"]}, } EXPLORE = { @@ -58,7 +58,10 @@ def exchange(path, payload): def context(arguments: dict) -> dict: - return exchange("context", {"query": arguments["query"], "compact": True, + query = arguments.get("query") + if not isinstance(query, str) or not query or len(query) > 2000: + raise ValueError("Invalid query") + return exchange("context", {"query": query, "compact": True, "max_items": 8, "include_evidence": bool(arguments.get("include_evidence", False))}) From 6575f53191e308584cbfd53c837bd50f2313ecc9 Mon Sep 17 00:00:00 2001 From: alexeybe1kin <210597588+alexeybe1kin@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:01:29 +0200 Subject: [PATCH 7/7] review: keep package edges from __init__.py; answer MCP ping The boundary check must still read imports in package __init__.py files (only the loose-module rule allows them). MCP clients send ping; the new Method-not-found branch would have refused it. Co-Authored-By: Claude Opus 5.5 --- scripts/check_package_boundaries.py | 2 -- services/mcp/memorygate_mcp.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check_package_boundaries.py b/scripts/check_package_boundaries.py index d307cee..586bb9d 100644 --- a/scripts/check_package_boundaries.py +++ b/scripts/check_package_boundaries.py @@ -49,8 +49,6 @@ def imported_packages(path: Path) -> set[str]: def edges() -> dict[tuple[str, str], list[str]]: found: dict[tuple[str, str], list[str]] = {} for path in sorted(APP.rglob("*.py")): - if path.name == "__init__.py": - continue path = path.relative_to(ROOT) source = owner(path) for target in imported_packages(path) - {source}: diff --git a/services/mcp/memorygate_mcp.py b/services/mcp/memorygate_mcp.py index 5005425..8cdc9e5 100644 --- a/services/mcp/memorygate_mcp.py +++ b/services/mcp/memorygate_mcp.py @@ -97,6 +97,8 @@ def main(): raise RuntimeError("unknown tool") value = handler(params.get("arguments", {})) respond(request.get("id"), {"content": [{"type": "text", "text": json.dumps(value)}]}) + elif method == "ping": + respond(request.get("id"), {}) elif "id" in request: respond(request.get("id"), error="Method not found") except Exception: