Context
A second Memory Core wipe incident has occurred: live MC went from 11,286 memories → 1,102 memories between the 2026-05-16T13-08-06.565Z and 2026-05-17T... backup snapshots. Summaries went from 980 → 81 over the same window. Restored from the 5/16 bundle this turn (12,266 records back via npm run ai:restore -- ... --mode merge --only-substrate=mc).
Forensic context: prior wipe (#10867) was solved by adding a hardcoded safety check at the test-helper layer (test/playwright/unit/ai/services/memory-core/util.mjs:16-18 throws on canonical-collection-name match). That guard fires only when the test routes through cleanupChromaManager(). The current recurrence happened despite that guard — most plausible attack surface: an agent invoking npx playwright <spec> directly (no playwright.config.unit.mjs loaded), so process.env.UNIT_TEST_MODE is undefined, ai/mcp/server/memory-core/config.mjs:228-229 falls through to canonical names, and any test (or service-startup path) that touches Chroma destructive APIs operates on the live collection.
Operator framing (2026-05-19 ~15:30Z): "gemini does frequently use npx playwright without our config files. so our tests must get hardened to still never delete live dbs."
The Problem
Defense-in-depth is currently asymmetric:
- Config layer (
config.mjs:228-229) — toggles collection names on UNIT_TEST_MODE. Correct under npm run test-unit. Bypassed under npx playwright (config file not loaded).
- Test-helper layer (
util.mjs:16-18) — guards cleanupChromaManager(). Bypassed if any code path calls ChromaManager.client.deleteCollection() directly, or if the test forgets the helper.
- Substrate layer (
ai/services/memory-core/managers/ChromaManager.mjs) — NO GUARD. The underlying chromadb-js client is exposed unwrapped (ChromaManager.client.deleteCollection({name})). Any caller — production code, test, daemon, ingest path — can drop any collection by name, including the canonical ones.
The pattern works for the happy path but has zero defense against a caller who acquires ChromaManager.client and operates on it without going through the cleanup helper.
The Architectural Reality
| Layer |
File |
Current behavior |
Gap |
| Config |
ai/mcp/server/memory-core/config.mjs:228-229 |
process.env.UNIT_TEST_MODE === 'true' ? 'test-...' : 'neo-agent-memory' |
Only triggers when env var loaded; npx playwright bypasses |
| Test helper |
test/playwright/unit/ai/services/memory-core/util.mjs:16-18 |
Throws if collectionsConfig.memory === 'neo-agent-memory' |
Only fires inside the helper; direct client.deleteCollection skips |
| ChromaManager |
ai/services/memory-core/managers/ChromaManager.mjs |
No deleteCollection method; client exposed directly |
No substrate-level invariant |
| Existing destructive-op guard |
Shared_DestructiveOperationGuard (#10845) |
Wraps known destructive paths (restore.mjs, etc.) |
Not wired to chroma collection delete |
KB side has the same shape: ai/services/knowledge-base/DatabaseService.mjs:372 truncateDatabase does route through DestructiveOperationGuard for the wipe-for-restore path, but ChromaManager.client.deleteCollection is still reachable bare.
The Fix
Add a substrate-layer invariant at ChromaManager: refuse deleteCollection against canonical collection names UNLESS process.env.UNIT_TEST_MODE === 'true' is set in the environment of the calling process.
Concretely:
ai/services/memory-core/managers/ChromaManager.mjs — add a guarded async deleteCollection({name}) method that:
- Throws
CANONICAL_COLLECTION_GUARDED if name matches the canonical set (neo-agent-memory, neo-agent-sessions, neo-agent-knowledge-base, plus any other canonical names sourced from config.mjs).
- Bypass only if
process.env.UNIT_TEST_MODE === 'true' (test path).
- Bypass only via explicit operator-acknowledged token if the call originates from a documented destructive recovery flow (e.g.
restore.mjs --mode replace already gates via DestructiveOperationGuard).
- Forbid direct
ChromaManager.client.deleteCollection access — either wrap the client behind a Proxy in the manager so the underlying deleteCollection is intercepted at the JS level, OR add a CI lint rule (grep test) refusing the pattern ChromaManager.client.deleteCollection outside the manager itself.
- Mirror at the KB side (
ai/services/knowledge-base/managers/ChromaManager.mjs if it has one — verify during impl) so the invariant applies uniformly across memory-core + knowledge-base subsystems.
- Add UNIT_TEST_MODE-required check at
cleanupChromaManager — current util.mjs:16-18 only checks the collection name; also check process.env.UNIT_TEST_MODE === 'true'. If not set, refuse even non-canonical names (covers the npx playwright-without-config scenario where the collection name falls through to canonical and the test author thought they were on a test-prefixed name).
- Spec coverage — new test
test/playwright/unit/ai/services/memory-core/managers/ChromaManager.canonicalGuard.spec.mjs that:
- Calls the guarded delete against a canonical name without
UNIT_TEST_MODE → expects throw
- Calls against a canonical name with
UNIT_TEST_MODE=true → expects pass
- Calls against a test-prefixed name without
UNIT_TEST_MODE → expects throw (defensive: the prefix-rule already gives test isolation, but if env var is missing the caller shouldn't be in a test path)
Acceptance Criteria
Out of Scope
- Forensic recovery of the 2026-05-17 wipe (separate operator-orchestrated investigation; restore already executed this turn).
- Backup retention policy adjustments (#11628 Phase 4 covers retention framing).
- Cross-substrate destructive-op guard unification beyond memory-core + KB Chroma surfaces (e.g., SQLite-layer guard already covered by #10845
DestructiveOperationGuard).
- Test-skill triggering hardening (#11529 closed; this ticket is one layer below).
Avoided Traps
| Trap |
Why rejected |
Only extending util.mjs test-helper check |
Same shape as #10867 fix that just failed in production. Doesn't defend against direct ChromaManager.client.deleteCollection callers. Substrate-layer invariant is required. |
Removing ChromaManager.client accessor entirely |
Production read paths legitimately need the client for non-destructive ops. Proxy-wrap or method-level guard is the right granularity. |
Lock canonical names behind a --force CLI flag instead of env var |
--force requires CLI wiring; tests don't have it. process.env.UNIT_TEST_MODE === 'true' is the existing convention and applies symmetrically. |
| Add a runtime "are we in a test process?" heuristic |
Heuristics drift. Explicit env var is the invariant — if it's not set, you're not in a test. |
| Skip the lint/CI gate and rely on developer discipline |
Empirically failed twice (#10867 + this incident). The guard must be mechanically enforced, not advisory. |
Related
- Prior incident: #10867 (closed; insufficient depth, only addressed test-helper layer)
- Skill-trigger hardening: #11529 (closed; meta layer above this)
- Existing destructive-op guard: #10845 (
DestructiveOperationGuard infrastructure to extend)
- Restore tooling that fires the existing guard correctly:
buildScripts/ai/restore.mjs (#10129 / #11141 / #11144)
Origin Session ID
7360e917-1733-4cdd-a6f3-5ac51c34b838
Handoff Retrieval Hints
query_raw_memories({query: 'memory core wipe npx playwright canonical collection guard'})
ask_knowledge_base({query: 'ChromaManager deleteCollection guard canonical names UNIT_TEST_MODE', type: 'src'})
- Empirical anchor: live MC count 1,102 → 12,842 post-restore on 2026-05-19; backup
backup-2026-05-16T13-08-06.565Z contained 11,286+980 records
- Existing guard precedent:
DestructiveOperationGuard.assertDestructiveTargetAllowed in ai/services/shared/DestructiveOperationGuard.mjs
Context
A second Memory Core wipe incident has occurred: live MC went from 11,286 memories → 1,102 memories between the
2026-05-16T13-08-06.565Zand2026-05-17T...backup snapshots. Summaries went from 980 → 81 over the same window. Restored from the 5/16 bundle this turn (12,266 records back vianpm run ai:restore -- ... --mode merge --only-substrate=mc).Forensic context: prior wipe (#10867) was solved by adding a hardcoded safety check at the test-helper layer (
test/playwright/unit/ai/services/memory-core/util.mjs:16-18throws on canonical-collection-name match). That guard fires only when the test routes throughcleanupChromaManager(). The current recurrence happened despite that guard — most plausible attack surface: an agent invokingnpx playwright <spec>directly (noplaywright.config.unit.mjsloaded), soprocess.env.UNIT_TEST_MODEis undefined,ai/mcp/server/memory-core/config.mjs:228-229falls through to canonical names, and any test (or service-startup path) that touches Chroma destructive APIs operates on the live collection.Operator framing (2026-05-19 ~15:30Z): "gemini does frequently use npx playwright without our config files. so our tests must get hardened to still never delete live dbs."
The Problem
Defense-in-depth is currently asymmetric:
config.mjs:228-229) — toggles collection names onUNIT_TEST_MODE. Correct undernpm run test-unit. Bypassed undernpx playwright(config file not loaded).util.mjs:16-18) — guardscleanupChromaManager(). Bypassed if any code path callsChromaManager.client.deleteCollection()directly, or if the test forgets the helper.ai/services/memory-core/managers/ChromaManager.mjs) — NO GUARD. The underlying chromadb-js client is exposed unwrapped (ChromaManager.client.deleteCollection({name})). Any caller — production code, test, daemon, ingest path — can drop any collection by name, including the canonical ones.The pattern works for the happy path but has zero defense against a caller who acquires
ChromaManager.clientand operates on it without going through the cleanup helper.The Architectural Reality
ai/mcp/server/memory-core/config.mjs:228-229process.env.UNIT_TEST_MODE === 'true' ? 'test-...' : 'neo-agent-memory'npx playwrightbypassestest/playwright/unit/ai/services/memory-core/util.mjs:16-18collectionsConfig.memory === 'neo-agent-memory'client.deleteCollectionskipsai/services/memory-core/managers/ChromaManager.mjsdeleteCollectionmethod; client exposed directlyShared_DestructiveOperationGuard(#10845)restore.mjs, etc.)KB side has the same shape:
ai/services/knowledge-base/DatabaseService.mjs:372 truncateDatabasedoes route throughDestructiveOperationGuardfor the wipe-for-restore path, butChromaManager.client.deleteCollectionis still reachable bare.The Fix
Add a substrate-layer invariant at
ChromaManager: refusedeleteCollectionagainst canonical collection names UNLESSprocess.env.UNIT_TEST_MODE === 'true'is set in the environment of the calling process.Concretely:
ai/services/memory-core/managers/ChromaManager.mjs— add a guardedasync deleteCollection({name})method that:CANONICAL_COLLECTION_GUARDEDifnamematches the canonical set (neo-agent-memory,neo-agent-sessions,neo-agent-knowledge-base, plus any other canonical names sourced fromconfig.mjs).process.env.UNIT_TEST_MODE === 'true'(test path).restore.mjs --mode replacealready gates viaDestructiveOperationGuard).ChromaManager.client.deleteCollectionaccess — either wrap the client behind a Proxy in the manager so the underlyingdeleteCollectionis intercepted at the JS level, OR add a CI lint rule (greptest) refusing the patternChromaManager.client.deleteCollectionoutside the manager itself.ai/services/knowledge-base/managers/ChromaManager.mjsif it has one — verify during impl) so the invariant applies uniformly across memory-core + knowledge-base subsystems.cleanupChromaManager— currentutil.mjs:16-18only checks the collection name; also checkprocess.env.UNIT_TEST_MODE === 'true'. If not set, refuse even non-canonical names (covers thenpx playwright-without-config scenario where the collection name falls through to canonical and the test author thought they were on a test-prefixed name).test/playwright/unit/ai/services/memory-core/managers/ChromaManager.canonicalGuard.spec.mjsthat:UNIT_TEST_MODE→ expects throwUNIT_TEST_MODE=true→ expects passUNIT_TEST_MODE→ expects throw (defensive: the prefix-rule already gives test isolation, but if env var is missing the caller shouldn't be in a test path)Acceptance Criteria
ChromaManager.deleteCollection({name})method exists and routes all destructive collection-delete operations through a single guarded path.CANONICAL_COLLECTION_GUARDEDwhennamematches the canonical set ANDprocess.env.UNIT_TEST_MODE !== 'true', regardless of caller.ChromaManager.clienteither Proxy-wrapped orclient.deleteCollectionmade unreachable from outside the manager (lint rule + CI grep gate accepted as alternative if Proxy adds too much overhead).cleanupChromaManager()addsprocess.env.UNIT_TEST_MODE === 'true'precondition; throws if absent.ChromaManager.canonicalGuard.spec.mjscovers 3 scenarios above.npx playwright test/playwright/unit/ai/services/memory-core/<any-spec>.spec.mjswithoutnpm run test-unitwrapper → tests refuse to touch canonical collections (the guard fires).config.mjsand never invokesdeleteCollection; verify production path).Out of Scope
DestructiveOperationGuard).Avoided Traps
util.mjstest-helper checkChromaManager.client.deleteCollectioncallers. Substrate-layer invariant is required.ChromaManager.clientaccessor entirely--forceCLI flag instead of env var--forcerequires CLI wiring; tests don't have it.process.env.UNIT_TEST_MODE === 'true'is the existing convention and applies symmetrically.Related
DestructiveOperationGuardinfrastructure to extend)buildScripts/ai/restore.mjs(#10129 / #11141 / #11144)Origin Session ID
7360e917-1733-4cdd-a6f3-5ac51c34b838Handoff Retrieval Hints
query_raw_memories({query: 'memory core wipe npx playwright canonical collection guard'})ask_knowledge_base({query: 'ChromaManager deleteCollection guard canonical names UNIT_TEST_MODE', type: 'src'})backup-2026-05-16T13-08-06.565Zcontained 11,286+980 recordsDestructiveOperationGuard.assertDestructiveTargetAllowedinai/services/shared/DestructiveOperationGuard.mjs