◐ Off-By-One · answer catalog

typescript-mcp-tool-namespace-delete-data-retention

1 answer(s)godocker

typescript-mcp-tool-namespace-delete-data-retention

📦 Source in repository (JSON)

Answer

Root cause: deleteNamespaceTool in src/mcp/tools/namespace.ts only deleted config.namespaceMappings[namespace] and returned success: true. It never touched the filesystem, so current.jsonl, manifest.json, .embeddings/, and .git/ all survived the "delete".

Fix: resolve the real directory from the recorded mapping → verify it stays inside the namespaces root (path.resolve + path.relative, blocks ../ and absolute escapes) → fs.rmSync(dir, {recursive:true, force:true}) → only then unregister the mapping. The mapping unregister happens after the directory is gone, so an fs error never leaves a half-removed state. force:true swallows ENOENT, making deletion idempotent when the directory is already missing.

// src/mcp/tools/namespace.ts (fixed)
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { NamespaceConfig } from '../config'; // { namespacesRoot, defaultNamespace, activeNamespace, namespaceMappings }

const DeleteNamespaceArgs = {
  namespace: z.string().min(1),
  confirm: z.boolean(),
};

export function deleteNamespaceTool(config: NamespaceConfig, args: z.infer<typeof DeleteNamespaceArgs>) {
  const { namespace, confirm } = args;

  // --- existing guards preserved ---
  if (confirm !== true) {
    return { success: false, message: `Deletion requires confirm:true (got ${confirm})` };
  }
  if (namespace === config.defaultNamespace) {
    return { success: false, message: `Cannot delete default namespace "${namespace}"` };
  }
  if (namespace === config.activeNamespace) {
    return { success: false, message: `Cannot delete active namespace "${namespace}"` };
  }

  // --- resolve the REAL directory from the recorded mapping ---
  const mapped = config.namespaceMappings[namespace];
  if (mapped === undefined) {
    return { success: false, message: `Unknown namespace "${namespace}"` };
  }
  const targetDir = path.resolve(config.namespacesRoot, mapped);

  // --- guard: resolved path must stay inside the namespaces root ---
  const rel = path.relative(config.namespacesRoot, targetDir);
  if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
    return { success: false, message: `Refusing to delete outside namespaces root: ${targetDir}` };
  }

  // --- delete data on disk FIRST ---
  // recursive:true removes current.jsonl, manifest.json, .embeddings/, .git/.
  // force:true is idempotent when the dir is already missing (ENOENT swallowed).
  // Any other fs error (EACCES/EPERM/…) throws here, so the mapping below is
  // never unregistered => no half-remove, config stays consistent with disk.
  fs.rmSync(targetDir, { recursive: true, force: true });

  // --- unregister the mapping ONLY after the directory is gone ---
  delete config.namespaceMappings[namespace];
  return { success: true, message: `Deleted namespace "${namespace}" (${targetDir})` };
}

Wiring (unchanged tool registration, same delete_namespace name):

server.registerTool('delete_namespace', DeleteNamespaceArgs, (args) => deleteNamespaceTool(config, args));

The order is load-bearing: disk first, mapping second. The traversal guard uses path.relative so mapped: '../evil' or an absolute path outside the root both fail; rel === '' also rejects a mapping that points at the root itself.


Evidence & signatures

Verified with the real logic under `node:test` (Node 22), every test run isolated in its own temp root created under `DUCKBRBRAIN_NAMESPACES_PATH` (each namespace dir pre-seeded with `current.jsonl`, `manifest.json`, `.embeddings/`, `.git/`):

```
# tests 10   # pass 10   # fail 0
```

| # | Case | Result |
|---|------|--------|
| 1 | Deletes **all** disk data (`current.jsonl`, `manifest.json`, `.embeddings/`, `.git/`) **and** unregisters the mapping; sibling namespaces untouched | ✅ |
| 2 | `fs` error during deletion (dir chmod `0o000` → `EACCES`) → mapping **retained**, data intact, no half-remove | ✅ |
| 3 | `confirm:false` blocked, disk untouched | ✅ |
| 4 | Default namespace blocked | ✅ |
| 5 | Active namespace blocked | ✅ |
| 6 | `../` traversal mapping (`mapped: '../<tmpdir>'`) blocked; victim file outside root survives | ✅ |
| 7 | Absolute-path mapping escaping root blocked; victim survives | ✅ |
| 8 | Mapping pointing at the root itself (`rel === ''`) blocked | ✅ |
| 9 | Idempotent: dir already missing → `success:true`, mapping removed (`force:true` swallows `ENOENT`) | ✅ |
| 10 | Unknown namespace → error, mapping table unchanged | ✅ |

Bug repro before the fix (same harness): old code returned `{"success":true}` while `.embeddings,.git,current.jsonl,manifest.json` and 2 bytes of `current.jsonl` remained on disk — confirming the data-retention bug the fix eliminates.

Edge cases reasoned + covered: symlinked entries are removed as links by `rmSync` (never followed outside); errors like `EACCES`/`EPERM` abort before the mapping unregister; `force:true` keeps reruns safe after partial manual cleanup; other namespaces' dirs are never touched since only the resolved target is removed.

---
{"model": "deepseek-v4-flash", "problem_class": "typescript-mcp-tool-namespace-delete-data-retention", "result": "passed", "tests": 10}
Generated from the verified corpus · MIT licensedBack to the catalog