diff --git a/dotty-pi-ext/package.json b/dotty-pi-ext/package.json index 822982e..0e18b81 100644 --- a/dotty-pi-ext/package.json +++ b/dotty-pi-ext/package.json @@ -15,8 +15,9 @@ "@earendil-works/pi-coding-agent": "^0.74.0" }, "scripts": { - "test": "npm run test:memory && npm run test:recall && npm run test:remember && npm run test:rememberperson && npm run test:turnlog && npm run test:think && npm run test:play && npm run test:adminauth", + "test": "npm run test:memory && npm run test:memgate && npm run test:recall && npm run test:remember && npm run test:rememberperson && npm run test:turnlog && npm run test:think && npm run test:play && npm run test:adminauth", "test:memory": "node --experimental-strip-types tests/memory_lookup.test.ts", + "test:memgate": "node --experimental-strip-types tests/memory_gate.test.ts", "test:recall": "node --experimental-strip-types tests/recall_person.test.ts", "test:remember": "node --experimental-strip-types tests/remember.test.ts", "test:rememberperson": "node --experimental-strip-types tests/remember_person.test.ts", diff --git a/dotty-pi-ext/src/lib/brain_db.ts b/dotty-pi-ext/src/lib/brain_db.ts index 43fd65f..d5ce60a 100644 --- a/dotty-pi-ext/src/lib/brain_db.ts +++ b/dotty-pi-ext/src/lib/brain_db.ts @@ -79,11 +79,17 @@ export function searchMemories( try { const db = openReadOnly(path); + // Kid-safety gate (#53): never surface unreviewed `person_pending:` + // facts about a minor through the free-text search — fetchPersonMemories + // is namespace-scoped to approved `person:` for the same reason, but a + // bare FTS MATCH would otherwise leak a pending fact whenever a query + // phrase-matched it. const stmt = db.prepare(` SELECT m.key, m.content, m.category, m.namespace, m.created_at FROM memories_fts JOIN memories m ON m.rowid = memories_fts.rowid WHERE memories_fts MATCH ? + AND m.namespace NOT LIKE 'person_pending:%' ORDER BY rank LIMIT ? `); diff --git a/dotty-pi-ext/tests/memory_gate.test.ts b/dotty-pi-ext/tests/memory_gate.test.ts new file mode 100644 index 0000000..07fda4d --- /dev/null +++ b/dotty-pi-ext/tests/memory_gate.test.ts @@ -0,0 +1,63 @@ +// Regression: memory_lookup's FTS search must NOT surface unreviewed +// `person_pending:` facts about a minor (#53 kid-safety gate). Builds a +// throwaway brain.db (real schema) with one approved and one pending row that +// both phrase-match the query, and asserts only the approved row comes back. +// +// Hermetic — no DOTTY_BRAIN_DB_SNAPSHOT needed (unlike memory_lookup.test.ts). + +import Database from "better-sqlite3"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { searchMemories, _resetForTests } from "../src/lib/brain_db.ts"; + +const dir = mkdtempSync(join(tmpdir(), "dotty-memgate-")); +const dbPath = join(dir, "brain.db"); + +const db = new Database(dbPath); +db.exec(` + CREATE TABLE memories ( + id INTEGER PRIMARY KEY, key TEXT, content TEXT, category TEXT, + embedding BLOB, created_at TEXT, updated_at TEXT, session_id TEXT, + namespace TEXT, importance INTEGER, superseded_by TEXT + ); + CREATE VIRTUAL TABLE memories_fts + USING fts5(key, content, content='memories', content_rowid='id'); +`); +const ins = db.prepare( + "INSERT INTO memories (key, content, category, namespace, created_at) VALUES (?,?,?,?,?)", +); +ins.run("k1", "alice likes peanuts", "fact", "person:alice", "2026-01-01"); +ins.run("k2", "kiddo is allergic to peanuts", "fact", "person_pending:bob", "2026-01-01"); +db.exec("INSERT INTO memories_fts(rowid, key, content) SELECT id, key, content FROM memories;"); +db.close(); + +let failures = 0; +function check(label: string, cond: boolean): void { + if (cond) { + process.stdout.write(`ok - ${label}\n`); + } else { + process.stderr.write(`FAIL - ${label}\n`); + failures++; + } +} + +try { + _resetForTests(); + const rows = searchMemories("peanuts", { dbPath, limit: 5 }); + const namespaces = rows.map((r) => r.namespace); + + check("approved person: row is returned", namespaces.includes("person:alice")); + check( + "pending minor fact is excluded", + !namespaces.some((n) => n.startsWith("person_pending:")), + ); + check("only the approved row remains", rows.length === 1); +} finally { + _resetForTests(); + rmSync(dir, { recursive: true, force: true }); +} + +if (failures > 0) process.exit(1); +process.stdout.write("memory_gate.test.ts passed\n"); diff --git a/dotty-pi-ext/tests/oracle.py b/dotty-pi-ext/tests/oracle.py index c021d20..80544fe 100644 --- a/dotty-pi-ext/tests/oracle.py +++ b/dotty-pi-ext/tests/oracle.py @@ -23,8 +23,11 @@ from pathlib import Path -# Copied verbatim from bridge.py:_voice_memory_search_blocking (lines -# ~3909-3942). Do NOT refactor — this is the spec. +# Ported from the (now-removed, #111) bridge.py:_voice_memory_search_blocking +# and kept as the spec the TS port must match. The ONE intentional change vs +# the original is the `namespace NOT LIKE 'person_pending:%'` kid-safety gate +# (#53) — added in both this oracle and brain_db.ts together. Don't otherwise +# refactor; if the TS port diverges, fix the TS, not this. def _voice_memory_search_blocking(db: Path, query: str, limit: int = 5) -> list[dict]: if not db.exists(): return [] @@ -37,11 +40,14 @@ def _voice_memory_search_blocking(db: Path, query: str, limit: int = 5) -> list[ try: conn.row_factory = sqlite3.Row cur = conn.execute( + # Kid-safety gate (#53): exclude unreviewed person_pending: + # facts about a minor from free-text search (mirrors brain_db.ts). """ SELECT m.key, m.content, m.category, m.namespace, m.created_at FROM memories_fts JOIN memories m ON m.rowid = memories_fts.rowid WHERE memories_fts MATCH ? + AND m.namespace NOT LIKE 'person_pending:%' ORDER BY rank LIMIT ? """,