fix(memory): gate /mcp/memory bulk delete + pin the ADMIN delete surface (#1302, #1024) - #1359
Conversation
|
This PR is being reconciled against it. It will either be closed as superseded or reduced to the genuinely unique delta (e.g. the |
…the prefix rule (#1024) Reduced onto main after 610150d (#1357) landed the #1302 half of the original PR #1359 independently. Everything that overlapped is dropped — main owns the /mcp/memory bulk-delete approval gate (make_dispatcher's approval_queue, delegating classification to admin.is_gated) and the /api/memory auth-posture comment. What is left is the slice 610150d does not touch at all: it never edits src/hal0/security/exposure.py. #1024's incident was one unauthenticated `DELETE /api/memory/banks/{id}` cascade-deleting ~632 live records. Its confirm guard (#1028) and audit row (#1030) landed; the classification did not. `/api/memory` was ADMIN only by generic prefix, and nothing asserted it — while #1024's own follow-up proposes "keep reads open if desired", whose natural expression (`_prefix("/api/memory")` -> CLIENT) silently takes the bank wipe with it under first-match-wins. Verified: with the pinned rows removed, that widening downgrades all 8 destructive routes to CLIENT. - exposure.py: `_DELETE`, two narrow rules ordered ABOVE the generic `memory` row, and `DESTRUCTIVE_MEMORY_ROUTES` as the named ratchet. Zero behaviour change today, by design — the rows exist so the classification cannot be widened without a conscious diff. - tests/security/test_memory_delete_auth.py: the constant tracks the live route table; each route resolves via a pinned rule (not the ADMIN fallback); all survive a simulated reads-are-CLIENT widening; and against the real armed AuthEnforcementMiddleware an anonymous call 401s, a client key 403s, and the admin key clears the gate. Note the posture this pins is only enforced when auth is enabled: `require_auth_enabled()` ships OFF by default (operator decision 2026-07-19, finding O19), so on a default install these routes are still reachable unauthenticated. This makes the classification correct and un-widenable; it does not by itself close #1024 on a default box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f1244ca to
ca27b2c
Compare
Reduced after reconciliation with
|
Closes #1302. Closes #1024.
The real bug: ungated bulk delete on
/mcp/memoryhal0.mcp.admin.dispatchclassifies amemory_deletewith >1 id as capital-D destructive and enqueues it on theApprovalQueue. The standalone memory server (mcp/memory.py::build_server) wired its tools straight tomake_dispatcher, so the identical call on that mount executed immediately. Demonstrated on the parent commit:_memory_deletes own docstring asserted the opposite ("by the time we get here it has already been approved"). The MCP docs also already claimed the gate existed.The auth half was already closed — the issue titles are stale
Verified on the base commit: with
HAL0_REQUIRE_AUTH=1every destructive memory route already behaved correctly (anon=401 admin=503 client=403). Both issues predate the KB-1 exposure table (2026-07-18).What was genuinely missing is that nothing held it in place. No test asserted the classification,
match_rule("DELETE", "/api/memory/banks/{bank_id}")resolved via the generic"memory"row, and #1024s own follow-up proposes "keep reads open if desired" — a widening whose natural expression silently downgrades the bank wipe to CLIENT.Changes
memory_deletearity rule gets one home (is_bulk_delete);admin.is_gateddelegates,build_serverreads itbuild_servertakes the same lifespan-scopedApprovalQueue, returns{status: pending_approval, approval_id}, runs only via the bound executor on approve, and fails closed with no queueDELETEunder/api/memoryplus bulkPOST /api/memory/deletepinned as explicit ADMIN rules above the generic prefix row, enumerated inDESTRUCTIVE_MEMORY_ROUTESnext toOPEN_ALLOWLISTroutes/memory.pyandmcp_mount.pyNo new privileged surface —
memory_deletestill maps toPOST /api/memory/delete. Nothing added to any OPEN allowlist.Verification
tests/memory tests/mcp tests/security+ targeted api/agents → 485 passed, 1 skippedtests/dispatcher tests/brain tests/activity tests/golden_paths→ 275 passedHonest note: the bare anon-401 assertion passes on base too, since the perimeter was already right. The auth-side tests that genuinely fail are the pinning/ratchet/widening ones.
docs/reference/mcp-tools.mdxanddocs/reference/api/rest-api.mdxare mirrored from hal0-web — those edits are also ported in the hal0-web docs branch, or the next mirror sync will clobber them.🤖 Generated with Claude Code