Skip to content

fix(security): cross-tenant memory poisoning — GLOBAL scope isolation (GH#1610) - #1791

Merged
HongmingWang-Rabbit merged 1 commit into
stagingfrom
fix/memory-poisoning-GH1610
Apr 23, 2026
Merged

HongmingWang-Rabbit merged 1 commit into
stagingfrom
fix/memory-poisoning-GH1610

Conversation

@molecule-ai

@molecule-ai molecule-ai Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes GH#1610 — GLOBAL Memory Poisoning (cross-tenant memory contamination).

Root cause

tool_commit_memory in workspace/a2a_tools.py had two critical gaps that let any tenant workspace poison org-wide (GLOBAL) memory:

  1. No RBAC enforcement — any agent could call tool_commit_memory regardless of role. The sibling builtin_tools/memory.py properly checks memory.write RBAC, but a2a_tools.py had none.

  2. No root-workspace enforcement for GLOBAL scope — the documented HMA contract states "GLOBAL: readable by all, writable by root workspaces only", but the implementation had zero enforcement. A tenant workspace could POST /workspaces/{id}/memories {scope: "GLOBAL"} and silently write to the shared org-wide memory store.

Changes

workspace/a2a_tools.py

  • Added _ROLE_PERMISSIONS table (mirrors builtin_tools/audit.py) so a2a_tools has isolated RBAC logic.
  • Added _get_workspace_tier() / _is_root_workspace() helpers: reads WorkspaceConfig.tier (0 = root, 1+ = tenant) from config.yaml, falls back to WORKSPACE_TIER env var.
  • Added _check_memory_write_permission() / _check_memory_read_permission() helpers: evaluate RBAC roles; fail closed on config errors.
  • tool_commit_memory: now (a) checks memory.write RBAC before any HTTP call, (b) returns Error: RBAC — only root workspaces (tier 0) can write to GLOBAL scope for non-root tenants, (c) embeds workspace_id in the POST body so the platform can namespace-isolate and audit cross-workspace writes.
  • tool_recall_memory: now (a) checks memory.read RBAC before any HTTP call, (b) always sends workspace_id as a GET param for platform cross-validation against future path-traversal bugs.

workspace/tests/test_a2a_tools_impl.py

Added 5 security regression tests covering:

  • test_global_scope_denied_for_non_root_workspace: tenant workspace cannot write GLOBAL scope
  • test_rbac_deny_blocks_all_scopes_including_local: RBAC denial blocks all scope levels including LOCAL
  • test_rbac_deny_blocks_recall: RBAC denial blocks recall entirely
  • test_post_includes_workspace_id_in_body: POST body includes workspace_id for audit traceability
  • test_recall_includes_workspace_id_in_params: GET params always include workspace_id

Test plan

  • All 51 tests in test_a2a_tools_impl.py pass
  • All 37 tests in test_memory.py pass
  • Integration test for cross-tenant memory boundary violations (tracked separately per acceptance criteria)

🤖 Generated with Claude Code

…mory/recall_memory (GH#1610)

Two critical gaps in a2a_tools.py let any tenant workspace poison org-wide
(GLOBAL) memory and bypass all RBAC enforcement:

1. tool_commit_memory had no RBAC check — any agent could write any scope.
2. tool_commit_memory had no root-workspace enforcement for GLOBAL scope —
   Tenant A could POST scope=GLOBAL and pollute the shared memory store
   that Tenant B's agent reads as trusted context.

Fix adds:
- _ROLE_PERMISSIONS table (mirrors builtin_tools/audit.py) so a2a_tools
  has isolated RBAC logic without depending on memory.py.
- _check_memory_write_permission() / _check_memory_read_permission() helpers:
  evaluate RBAC roles from WorkspaceConfig; fail closed (deny) on errors.
- _is_root_workspace() / _get_workspace_tier(): read WorkspaceConfig.tier
  (0 = root/org, 1+ = tenant) from config.yaml; fall back to
  WORKSPACE_TIER env var.
- tool_commit_memory now (a) checks memory.write RBAC, (b) rejects
  GLOBAL scope for non-root workspaces, (c) embeds workspace_id in the
  POST body so the platform can namespace-isolate and audit cross-workspace
  writes.
- tool_recall_memory now checks memory.read RBAC before any HTTP call,
  and always sends workspace_id as a GET param for platform cross-validation.

Security regression tests added:
- GLOBAL scope denied for non-root (tier>0) workspaces.
- RBAC denial blocks all scope levels (including LOCAL) on write.
- RBAC denial blocks recall entirely.
- workspace_id present in POST body and GET params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@HongmingWang-Rabbit
HongmingWang-Rabbit force-pushed the fix/memory-poisoning-GH1610 branch from a20edae to e00797b Compare April 23, 2026 17:21
@HongmingWang-Rabbit
HongmingWang-Rabbit merged commit 6904a8c into staging Apr 23, 2026
12 checks passed
@HongmingWang-Rabbit
HongmingWang-Rabbit deleted the fix/memory-poisoning-GH1610 branch April 24, 2026 00:11
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
…2 step 1) (#1794)

CTO-bypass merge 2026-05-24: Phase A2 step 1 — route POST /memories through v2 plugin
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
…ge (#1796)

CTO-bypass merge 2026-05-24: memory-system chain
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
…surface

Closes the v1→v2 memory migration. Phase A2 (#1791) ran on production
2026-05-24 and verified parity: every active tenant has its
agent_memories rows mirrored 1:1 into memory_plugin.memory_records,
live writes go to v2 only (v1 frozen). With parity confirmed, this PR
drops the entire v1 surface.

Per the audit before this PR:

| Tenant | v1 (frozen) | v2 (live) | Status |
|---|---|---|---|
| agents-team | 1805 | 1805+live | parity |
| hongming | 144 | 144 | parity |
| chloe-dong | 1 | 1 | parity |
| reno-stars | 102 | 102 | parity |

## Changes

1. **Migration** drops the agent_memories table. Down migration
   recreates an empty table for tool symmetry; rollback would not
   restore data (A2 was one-way).

2. **memories.go**: removed Search, Update, Delete methods + their
   dead helpers (EmbeddingFunc, embed field, WithEmbedding,
   formatVector, nextArg, memoryFTSMinQueryLen, memoryRecallMaxLimit).
   Kept Commit, which post-#1794 routes through the v2 plugin.

3. **router.go**: removed GET /memories, DELETE /memories/:id, PATCH
   /memories/:id routes. Callers use /v2/memories (canvas does this
   already) and /v2/memories/:id (Forget) instead. POST /memories
   stays — it's the high-volume write surface, still on v2.

4. **activity.go**: dropped the agent_memories UNION branch from
   buildSessionSearchQuery. Session search now returns only
   activity_logs items; memory-tab content comes from /v2/memories
   directly via MemoryInspectorPanel.

5. **workspace_crud.go**: removed agent_memories from the workspace
   purge cleanup list. Memory rows now cascade-delete via the
   memory plugin's namespace deletion path.

6. **entrypoint-tenant.sh**: removed the MEMORY_V2_CUTOVER deprecation
   shim (#1747 deprecated it; A3 retires the synonym). New tenants
   use MEMORY_PLUGIN_URL directly. Controlplane user-data still sets
   MEMORY_V2_CUTOVER='true' as belt-and-suspenders — that's a no-op
   now and will be cleaned up in a separate molecule-controlplane PR.

7. **Tests**: removed test functions that exercised the deleted
   methods (Search/Update/Delete and the embed/recall paths).
   Tests for Commit + redactSecrets stay.

## Risk

- **Hard 404** on any caller still hitting GET /workspaces/:id/memories,
  PATCH /workspaces/:id/memories/:id, or DELETE /workspaces/:id/memories/:id.
  Production traffic audit showed 2 GETs vs 66 POSTs to legacy /memories
  over a 24h window — runtime callers are POST-dominant. Canvas reads
  from /v2/memories. Acceptable.
- **No DB rollback** restores data — A2 was one-way. If a critical bug
  appears post-merge, recover via memory_plugin.memory_records direct
  SQL (data is preserved there).

## SOP Checklist (RFC #351)

### 1. Comprehensive testing performed
- `go test -short -count=1 ./internal/handlers/` green.
- `go test -short -count=1 ./cmd/memory-backfill/` green (sqlmock
  tests still pass; tool is now effectively inert on tenants since the
  source table is gone but the binary stays for one image cycle).
- `go vet ./...` clean.

### 2. Local-postgres E2E run
N/A. Schema change verified against the well-tested migration tool
shape; no new SQL paths added.

### 3. Staging-smoke verified or pending
Pending merge + tenant recycle. Will verify by SSM-checking that
agent_memories is gone from each tenant's DB and POST /memories still
returns 201 with rows landing in memory_plugin.memory_records.

### 4. Root-cause not symptom
Yes. The v1 table existed only as a dual-write target during the
A1+A2 transition. With A2 done and parity verified, the table is dead
weight. Dropping it removes the SSOT-violation surface entirely.

### 5. Five-Axis review walked
Walked solo. Happy to dispatch a hostile reviewer if anyone wants
sign-off on the cleanup scope (whether to also drop memory-backfill
binary, the activity UNION removal, etc).

### 6. No backwards-compat shim / dead code added
Net deletion: -787 LOC across 7 files. The MEMORY_V2_CUTOVER shim is
removed (was the last backwards-compat hook). One follow-up needed:
controlplane ec2.go still sets MEMORY_V2_CUTOVER='true' — that's a
no-op now but should be cleaned up in a separate PR for tidiness.

### 7. Memory/saved-feedback consulted
- `feedback_no_single_source_of_truth` — A3 is the final step in
  establishing v2 as the only memory backend.
- `feedback_check_for_parallel_work_before_fix_pr` — grep'd recent
  PRs touching memories.go / activity.go / workspace_crud.go; no
  parallel in flight.

Closes #1792. Memory v1→v2 migration complete.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant