Skip to content

fix(memory): gate /mcp/memory bulk delete + pin the ADMIN delete surface (#1302, #1024) - #1359

Merged
thinmintdev merged 2 commits into
mainfrom
fix/memory-delete-hardening
Jul 27, 2026
Merged

fix(memory): gate /mcp/memory bulk delete + pin the ADMIN delete surface (#1302, #1024)#1359
thinmintdev merged 2 commits into
mainfrom
fix/memory-delete-hardening

Conversation

@thinmintdev

Copy link
Copy Markdown
Contributor

Closes #1302. Closes #1024.

The real bug: ungated bulk delete on /mcp/memory

hal0.mcp.admin.dispatch classifies a memory_delete with >1 id as capital-D destructive and enqueues it on the ApprovalQueue. The standalone memory server (mcp/memory.py::build_server) wired its tools straight to make_dispatcher, so the identical call on that mount executed immediately. Demonstrated on the parent commit:

admin.is_gated(memory_delete, 3 ids) = True
/mcp/memory memory_delete -> {"status": "ok", "deleted": 3}
provider.delete calls      = [["a","b","c"]]

_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=1 every 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_delete arity rule gets one home (is_bulk_delete); admin.is_gated delegates, build_server reads it
  • build_server takes the same lifespan-scoped ApprovalQueue, returns {status: pending_approval, approval_id}, runs only via the bound executor on approve, and fails closed with no queue
  • Every DELETE under /api/memory plus bulk POST /api/memory/delete pinned as explicit ADMIN rules above the generic prefix row, enumerated in DESTRUCTIVE_MEMORY_ROUTES next to OPEN_ALLOWLIST
  • Corrected stale "auth was removed" comments in routes/memory.py and mcp_mount.py

No new privileged surface — memory_delete still maps to POST /api/memory/delete. Nothing added to any OPEN allowlist.

Verification

  • 59 new tests, all failing on the parent commit
  • tests/memory tests/mcp tests/security + targeted api/agents → 485 passed, 1 skipped
  • tests/dispatcher tests/brain tests/activity tests/golden_paths275 passed
  • Baseline arithmetic: 324 before → 383 after, exactly +59
  • ruff / format / sunset (200 ≤ 200) / import smoke clean

Honest 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.mdx and docs/reference/api/rest-api.mdx are 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

@thinmintdev
thinmintdev marked this pull request as draft July 27, 2026 14:43
@thinmintdev

Copy link
Copy Markdown
Contributor Author

⚠️ Converted to draft — collision detected.

610150d7 ("fix(tests): hermes uninstall tests hit real system bus; RC 1.0 carryover", #1357) landed on main and already closes #1302, #1301, #1300, #1224 via an independent implementation.

This PR is being reconciled against it. It will either be closed as superseded or reduced to the genuinely unique delta (e.g. the security/exposure.py ADMIN route pinning and #1024, which 610150d7 does not appear to cover). Not merging duplicate implementations.

…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>
@thinmintdev
thinmintdev force-pushed the fix/memory-delete-hardening branch from f1244ca to ca27b2c Compare July 27, 2026 16:07
@thinmintdev

Copy link
Copy Markdown
Contributor Author

Reduced after reconciliation with 610150d7

Rebased onto main and cut from 729 → 332 changed lines. Every surviving line is something 610150d7 does not implement.

Dropped as duplicate: the /mcp/memory bulk-delete gate itself. 610150d7 implements it at a better layer — inside make_dispatcher rather than a build_server-level wrapper — so there is one code path instead of two.

Kept — 610150d7 does not touch src/hal0/security/exposure.py at all, and does not list #1024 among the issues it closes:

  • _DELETE + two narrow rules ordered above the generic memory row
  • DESTRUCTIVE_MEMORY_ROUTES exported in __all__
  • tests/security/test_memory_delete_auth.py

Non-vacuity proven: removing the two pinned rows and applying #1024s own proposed widening (_prefix("/api/memory") → CLIENT) downgrades all 8 destructive routes to client, including DELETE /api/memory/banks/{bank_id} — #1024s incident route.

Honest caveat

This makes the classification correct and un-widenable. It does not close #1024 on a default box, because require_auth_enabled() ships off (operator decision 2026-07-19, finding O19).

One gap deliberately not grafted

610150d7s approval_queue=None overloads two meanings — "admin already gated, run it" and "misconfigured memory server" — so a build_server(approval_queue=None) silently executes bulk deletes ungated. This branch failed closed there. There is exactly one production call site and it passes the queue, so this is a ratchet rather than a live bug, and fixing it properly means redesigning mains signature, not cherry-picking. Flagged for a follow-up.

Also worth a separate one-line fix on main

routes/memory.pys new comment says ADR-0012 "removed auth and TLS platform wide". hal0/api/auth.py::AuthEnforcementMiddleware exists and /api/memory is ADMIN-classed, so that parenthetical is stale — what is true is that require_auth_enabled() defaults off.

Verification: ruff check ✅ · ruff format (985 files) ✅ · import smoke ✅ · sunset (195 ≤ 195) ✅ · tests/security tests/mcp tests/memory tests/api1892 passed, 1 skipped · tests/security under fastapi 0.139 → 66 passed.

The fastapi version-skew hazard is closedtests/mcp tests/security give identical results (226 passed, 1 skipped) on both 0.136.1 and 0.139.2. The earlier 485-vs-184 split was the _IncludedRouter bug that 610150d7s _iter_leaf_routes fixes.

@thinmintdev
thinmintdev marked this pull request as ready for review July 27, 2026 16:07
@thinmintdev
thinmintdev enabled auto-merge (squash) July 27, 2026 16:08
@thinmintdev
thinmintdev merged commit abfd1be into main Jul 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant