hippo-memory 1.48.0 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,8 @@ import { rescueSet, rankNonPinnedByTenant, validateWeights } from './memory-valu
25
25
  import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-weights.js';
26
26
  import { appendAuditEvent } from './audit.js';
27
27
  import { migrateDefaultHalfLife } from './half-life-migration.js';
28
+ import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
29
+ import { isQuarantineScope } from './quarantine.js';
28
30
  const DECAY_THRESHOLD = 0.05;
29
31
  const MERGE_OVERLAP_THRESHOLD = 0.35; // Jaccard similarity for "related"
30
32
  const MERGE_MIN_CLUSTER = 2; // minimum cluster size to merge
@@ -108,6 +110,7 @@ export async function consolidate(hippoRoot, options = {}) {
108
110
  semanticCreated: 0,
109
111
  replayed: 0,
110
112
  promotedTraces: 0,
113
+ tracesSkippedMixedScope: 0,
111
114
  extractionCandidates: 0,
112
115
  extracted: 0,
113
116
  dagCandidateClusters: 0,
@@ -368,6 +371,13 @@ export async function consolidate(hippoRoot, options = {}) {
368
371
  session_id: session.session_id,
369
372
  limit: 1000,
370
373
  });
374
+ // T7: a mixed-scope session would otherwise leak into one trace.
375
+ const sessionScope = commonDerivationScope(events.map((e) => e.scope));
376
+ if (!sessionScope.ok) {
377
+ result.tracesSkippedMixedScope++;
378
+ result.details.push(` ⏭ skipped session ${session.session_id}: events span mixed scopes`);
379
+ continue;
380
+ }
371
381
  const completeEvent = events.find((e) => e.event_type === 'session_complete');
372
382
  if (!completeEvent)
373
383
  continue; // defence-in-depth; findPromotableSessions filters already.
@@ -391,6 +401,7 @@ export async function consolidate(hippoRoot, options = {}) {
391
401
  source_session_id: session.session_id,
392
402
  tags: ['auto-promoted'],
393
403
  source: 'auto-promote',
404
+ scope: sessionScope.scope,
394
405
  // T1 fix (2026-08-15 hardening pass): stamp the trace into the SAME
395
406
  // tenant the traceExistsForSession idempotency check (above) runs
396
407
  // under. Before
@@ -671,11 +682,12 @@ export async function consolidate(hippoRoot, options = {}) {
671
682
  // before this fix — byte-identical behavior there.
672
683
  const mergeCandidatesByTenant = new Map();
673
684
  for (const entry of mergeCandidates) {
674
- const bucket = mergeCandidatesByTenant.get(entry.tenantId);
685
+ const key = derivationPartitionKey(entry.tenantId, entry.scope);
686
+ const bucket = mergeCandidatesByTenant.get(key);
675
687
  if (bucket)
676
688
  bucket.push(entry);
677
689
  else
678
- mergeCandidatesByTenant.set(entry.tenantId, [entry]);
690
+ mergeCandidatesByTenant.set(key, [entry]);
679
691
  }
680
692
  // AT1 consolidation-loop fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
681
693
  // reuses the single consolidateDb handle opened once above (before the
@@ -686,7 +698,9 @@ export async function consolidate(hippoRoot, options = {}) {
686
698
  // declared up at the try's opening above 1.4, not here — it has to
687
699
  // survive the try/finally that now wraps this whole section; see the
688
700
  // handle-leak restructure comment there.)
689
- for (const [mergeTenant, tenantCandidates] of mergeCandidatesByTenant) {
701
+ for (const [, tenantCandidates] of mergeCandidatesByTenant) {
702
+ const mergeTenant = tenantCandidates[0].tenantId;
703
+ const mergeScope = derivationScope(tenantCandidates[0].scope);
690
704
  for (let i = 0; i < tenantCandidates.length; i++) {
691
705
  if (used.has(tenantCandidates[i].id))
692
706
  continue;
@@ -722,6 +736,7 @@ export async function consolidate(hippoRoot, options = {}) {
722
736
  source: 'consolidation',
723
737
  confidence: 'inferred',
724
738
  tenantId: mergeTenant,
739
+ scope: mergeScope,
725
740
  });
726
741
  }
727
742
  // mergeContents is DETERMINISTIC CONCATENATION (not an LLM paraphrase)
@@ -931,6 +946,8 @@ function detectConflicts(entries, now, decayOpts = {},
931
946
  // Default empty set: flag-off behavior is unchanged.
932
947
  rescuedIds = new Set()) {
933
948
  const survivors = entries.filter((entry) => entry.layer !== Layer.Semantic
949
+ // CD5: an unreviewed quarantined row must not taint a visible memory as conflicted.
950
+ && !isQuarantineScope(entry.scope ?? null)
934
951
  && (rescuedIds.has(entry.id) || calculateStrength(entry, now, decayOpts) >= DECAY_THRESHOLD));
935
952
  const detected = [];
936
953
  for (let i = 0; i < survivors.length; i++) {
package/dist/dag.js CHANGED
@@ -2,6 +2,7 @@ import { createMemory, Layer } from './memory.js';
2
2
  import { writeEntry, loadAllDirtySummaries, loadChildrenOfSummary, applyRebuildResult, clearSummaryDirtyAfterBuild, } from './store.js';
3
3
  import { RejectedValueError } from './rejection.js';
4
4
  import { redactSecrets } from './secret-detect.js';
5
+ import { derivationScope, derivationPartitionKey } from './recall-scope.js';
5
6
  export function clusterFacts(facts) {
6
7
  if (facts.length === 0)
7
8
  return [];
@@ -93,13 +94,16 @@ export async function buildDag(hippoRoot, facts, opts) {
93
94
  // behavior there.
94
95
  const unparentedByTenant = new Map();
95
96
  for (const fact of unparented) {
96
- const bucket = unparentedByTenant.get(fact.tenantId);
97
+ const key = derivationPartitionKey(fact.tenantId, fact.scope);
98
+ const bucket = unparentedByTenant.get(key);
97
99
  if (bucket)
98
100
  bucket.push(fact);
99
101
  else
100
- unparentedByTenant.set(fact.tenantId, [fact]);
102
+ unparentedByTenant.set(key, [fact]);
101
103
  }
102
- for (const [factTenant, tenantFacts] of unparentedByTenant) {
104
+ for (const [, tenantFacts] of unparentedByTenant) {
105
+ const factTenant = tenantFacts[0].tenantId;
106
+ const factScope = derivationScope(tenantFacts[0].scope);
103
107
  const clusters = clusterFacts(tenantFacts);
104
108
  const eligibleClusters = clusters.filter((c) => c.members.length >= 3);
105
109
  result.candidateClusters += eligibleClusters.length;
@@ -118,6 +122,7 @@ export async function buildDag(hippoRoot, facts, opts) {
118
122
  confidence: 'inferred',
119
123
  dag_level: 2,
120
124
  tenantId: factTenant,
125
+ scope: factScope,
121
126
  });
122
127
  // Schema v25: cache descendant_count + earliest/latest_at on the summary
123
128
  // row so DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2)
@@ -299,11 +304,14 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
299
304
  const byTenant = new Map();
300
305
  for (const l2 of unparented) {
301
306
  const tid = l2.tenantId ?? 'default';
302
- const list = byTenant.get(tid) ?? [];
307
+ const key = derivationPartitionKey(tid, l2.scope);
308
+ const list = byTenant.get(key) ?? [];
303
309
  list.push(l2);
304
- byTenant.set(tid, list);
310
+ byTenant.set(key, list);
305
311
  }
306
- for (const [tenantId, tenantL2s] of byTenant) {
312
+ for (const [, tenantL2s] of byTenant) {
313
+ const tenantId = tenantL2s[0].tenantId ?? 'default';
314
+ const scope = derivationScope(tenantL2s[0].scope);
307
315
  const clusters = clusterFacts(tenantL2s);
308
316
  const eligible = clusters.filter((c) => c.members.length >= 2);
309
317
  result.candidateClusters += eligible.length;
@@ -321,6 +329,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
321
329
  confidence: 'inferred',
322
330
  dag_level: 3,
323
331
  tenantId, // HIGH #1 fold: thread tenant explicitly
332
+ scope,
324
333
  });
325
334
  profileEntry.descendant_count = cluster.members.length;
326
335
  profileEntry.earliest_at = memberCreatedAts[0];
package/dist/db.js CHANGED
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  // runtime (Node's built-in synchronous SQLite module); there are no bundled
12
12
  // types for it here, so this require + cast is the module's documented boundary.
13
13
  const { DatabaseSync } = require('node:sqlite');
14
- const CURRENT_SCHEMA_VERSION = 46;
14
+ const CURRENT_SCHEMA_VERSION = 48;
15
15
  const MIGRATIONS = [
16
16
  {
17
17
  version: 1,
@@ -2447,6 +2447,30 @@ const MIGRATIONS = [
2447
2447
  `);
2448
2448
  },
2449
2449
  },
2450
+ {
2451
+ version: 47,
2452
+ up: (db) => {
2453
+ // Scope grants (src/auth.ts, ROADMAP EI2): a member key reads a
2454
+ // restricted scope only by an explicit row here. Additive only: no
2455
+ // min_compatible_binary bump.
2456
+ db.exec(`
2457
+ CREATE TABLE IF NOT EXISTS api_key_scope_grants (
2458
+ key_id TEXT NOT NULL,
2459
+ scope TEXT NOT NULL,
2460
+ granted_at TEXT NOT NULL,
2461
+ PRIMARY KEY(key_id, scope)
2462
+ );
2463
+ `);
2464
+ },
2465
+ },
2466
+ {
2467
+ version: 48,
2468
+ up: (db) => {
2469
+ // Quarantine (src/quarantine.ts, CD5): a poisoned or suspect memory sits here pending
2470
+ // admin review instead of being hidden with no record. Additive only: no min_compatible_binary bump.
2471
+ db.exec(MEMORY_QUARANTINE_DDL);
2472
+ },
2473
+ },
2450
2474
  ];
2451
2475
  function tableHasColumn(db, tableName, columnName) {
2452
2476
  if (!/^[a-z_]+$/i.test(tableName))
@@ -2612,6 +2636,22 @@ function setSchemaVersion(db, version) {
2612
2636
  db.prepare(`INSERT INTO meta(key, value) VALUES('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(version));
2613
2637
  db.exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
2614
2638
  }
2639
+ // Shared by migration v48 and the stamped-store self-heal below.
2640
+ const MEMORY_QUARANTINE_DDL = `
2641
+ CREATE TABLE IF NOT EXISTS memory_quarantine (
2642
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2643
+ memory_id TEXT NOT NULL,
2644
+ original_scope TEXT,
2645
+ reason TEXT NOT NULL,
2646
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
2647
+ quarantined_at TEXT NOT NULL,
2648
+ decided_at TEXT,
2649
+ decided_by TEXT,
2650
+ PRIMARY KEY (tenant_id, memory_id)
2651
+ ) WITHOUT ROWID;
2652
+ CREATE INDEX IF NOT EXISTS idx_memory_quarantine_status
2653
+ ON memory_quarantine(tenant_id, status, quarantined_at DESC);
2654
+ `;
2615
2655
  // Before the loop on stamped stores: a table lost after its migration stamped (2026-08-15
2616
2656
  // incident) is never re-migrated, and v4/v16/v22 ALTER or read it. Fresh stores use the chain.
2617
2657
  function ensureContinuityTables(db) {
@@ -2713,6 +2753,7 @@ function ensureContinuityTables(db) {
2713
2753
  tenant_id TEXT NOT NULL DEFAULT 'default'
2714
2754
  )
2715
2755
  `);
2756
+ db.exec(MEMORY_QUARANTINE_DDL);
2716
2757
  }
2717
2758
  // After the loop: tenant_id (v16) and scope (v23) do not exist yet on a genuine old store.
2718
2759
  function ensureContinuityIndexes(db) {
package/dist/dedupe.js CHANGED
@@ -24,6 +24,7 @@ import { textOverlap } from './search.js';
24
24
  import { loadAllEntries, deleteEntry } from './store.js';
25
25
  import { compareEntryIdentity } from './compare.js';
26
26
  import { canAutoDelete } from './memory.js';
27
+ import { derivationPartitionKey } from './recall-scope.js';
27
28
  /** Quantization step for strength-tie comparisons. The historical 0.01
28
29
  * epsilon (see `strengthBucket` below) applied via rounding instead of a
29
30
  * raw abs-diff threshold, so the tiebreak is transitive. */
@@ -76,11 +77,13 @@ export function deduplicateStore(hippoRoot, options = {}) {
76
77
  // pre-fix global pass.
77
78
  const entriesByTenant = new Map();
78
79
  for (const entry of entries) {
79
- const bucket = entriesByTenant.get(entry.tenantId);
80
+ // EI2: also split by restricted scope, else a private copy can delete the readable one.
81
+ const key = derivationPartitionKey(entry.tenantId, entry.scope);
82
+ const bucket = entriesByTenant.get(key);
80
83
  if (bucket)
81
84
  bucket.push(entry);
82
85
  else
83
- entriesByTenant.set(entry.tenantId, [entry]);
86
+ entriesByTenant.set(key, [entry]);
84
87
  }
85
88
  // finiteCount mirrors strengthBucket's non-finite hardening on the
86
89
  // retrieval leg: a NaN retrieval_count would make the comparator return
package/dist/extract.js CHANGED
@@ -92,6 +92,7 @@ export function storeExtractedFacts(hippoRoot, source, facts) {
92
92
  confidence: 'inferred',
93
93
  source: source.source,
94
94
  extracted_from: source.id,
95
+ scope: source.scope,
95
96
  // T1 executor check (2026-08-15 hardening pass): same defect as the
96
97
  // consolidate.ts merge/trace passes — createMemory with no tenantId
97
98
  // option stamps 'default' (memory.ts:535) regardless of the source
@@ -0,0 +1,7 @@
1
+ /** Prompt-injection detection for untrusted memory content (CD5). Same shape as secret-detect.ts; leaf module, no store/api/shared imports. */
2
+ export interface InstructionDetection {
3
+ flagged: boolean;
4
+ reason: string | null;
5
+ }
6
+ export declare function detectInstruction(content: string): InstructionDetection;
7
+ //# sourceMappingURL=instruction-detect.d.ts.map
@@ -0,0 +1,27 @@
1
+ /** Prompt-injection detection for untrusted memory content (CD5). Same shape as secret-detect.ts; leaf module, no store/api/shared imports. */
2
+ const INSTRUCTION_PATTERNS = [
3
+ { name: 'override-instructions', re: /\b(?:ignore|disregard|forget)\b[^.\n]{0,40}\b(?:previous|prior|above)\b[^.\n]{0,40}\binstructions?\b/i },
4
+ { name: 'role-reassignment', re: /\b(?:you are now|act as|pretend to be)\b[^.\n]{0,40}\b(?:an?\s+)?(?:ai|assistant|agent|system)\b/i },
5
+ { name: 'chat-role-markup', re: /<\|im_start\|>|<\|system\|>|\[INST\]|<\/?system>|###\s*system\s*:/i },
6
+ { name: 'system-prompt-reference', re: /\b(?:(?:override|ignore|bypass|replace|reveal)\b[^.\n]{0,40}\b(?:system prompt|developer message)|(?:system prompt|developer message)\b[^.\n]{0,40}\b(?:override|ignore|bypass|replace|reveal))\b/i },
7
+ // "always"/"never" alone (ordinary PR prose) doesn't flag without an agent-facing target nearby too.
8
+ { name: 'standing-order', re: /\b(?:from now on|whenever you|always|never)\b[^.\n]{0,50}\b(?:you must|(?:the ai|the assistant|the agent|claude|copilot)\s+(?:must|should|will)\b)/i },
9
+ { name: 'concealment', re: /\b(?:do not|never|don't)\b[^.\n]{0,40}\b(?:tell|mention|reveal)\b[^.\n]{0,40}\bthe user\b/i },
10
+ { name: 'remote-script-exec', re: /\b(?:curl|wget)\b[^\n|]{0,80}\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b|\biex\s*\(\s*(?:iwr|invoke-webrequest|new-object\s+net\.webclient)/i },
11
+ { name: 'exfiltration', re: /\b(?:send|post|upload|exfiltrate)\b[^.\n]{0,40}\b(?:secrets?|tokens?|api[- ]?keys?|credentials?|env(?:ironment)? variables?)\b/i },
12
+ { name: 'unicode-tag-chars', re: /[\u{E0000}-\u{E007F}]/u },
13
+ ];
14
+ // A stray zero-width char has legitimate uses (ZWJ); only a run of 3+ adjacent ones reads as smuggling.
15
+ const ZERO_WIDTH_RUN_RE = /[\u200B-\u200F\u2060-\u2064]{3,}/;
16
+ export function detectInstruction(content) {
17
+ for (const { name, re } of INSTRUCTION_PATTERNS) {
18
+ if (re.test(content)) {
19
+ return { flagged: true, reason: `pattern:${name}` };
20
+ }
21
+ }
22
+ if (ZERO_WIDTH_RUN_RE.test(content)) {
23
+ return { flagged: true, reason: 'pattern:zero-width-smuggling' };
24
+ }
25
+ return { flagged: false, reason: null };
26
+ }
27
+ //# sourceMappingURL=instruction-detect.js.map
@@ -48,6 +48,8 @@ export interface McpContext {
48
48
  * assuming admin, or a member key over HTTP-MCP would act as admin.
49
49
  */
50
50
  role?: 'admin' | 'member';
51
+ /** EI2: scope grants for the HTTP-MCP caller's key. Absent for stdio (admin, needs none). */
52
+ scopes?: readonly string[];
51
53
  /**
52
54
  * Per-client key for state isolation under HTTP-MCP. For stdio: 'stdio-${pid}'
53
55
  * (one process = one client). For HTTP-SSE / HTTP MCP: hash(bearer + remoteAddr)
@@ -12,7 +12,7 @@ import * as path from 'path';
12
12
  import { createMemory, Layer, calculateStrength, } from '../memory.js';
13
13
  import { hybridSearch, physicsSearch, estimateTokens } from '../search.js';
14
14
  import { evalNow } from '../ablation.js';
15
- import { loadAllEntries, writeEntry, strengthenRetrieved, readEntry, loadFreshActiveTaskSnapshot, listMemoryConflicts, resolveConflict, RECALL_DEFAULT_DENY_SCOPES, countCreatedSinceLastSleep } from '../store.js';
15
+ import { loadAllEntries, writeEntry, strengthenRetrieved, readEntry, loadFreshActiveTaskSnapshot, listMemoryConflicts, resolveConflict, countCreatedSinceLastSleep } from '../store.js';
16
16
  import { shareMemory, listPeers, getGlobalRoot, initGlobal } from '../shared.js';
17
17
  import { consolidate } from '../consolidate.js';
18
18
  import { execSync } from 'child_process';
@@ -20,7 +20,7 @@ import { fetchGitLog, extractLessons, partitionLessons, deduplicateLesson, isGit
20
20
  import { loadConfig } from '../config.js';
21
21
  import { confidenceLabel } from '../memory.js';
22
22
  import { resolveTenantId } from '../tenant.js';
23
- import { recall as apiRecall, remember as apiRemember, outcome as apiOutcome, drillDown as apiDrillDown, assemble as apiAssemble, isPrivateScope, passesScopeFilterForRecall, buildSuppressionSummary, ambientSecretAdmit } from '../api.js';
23
+ import { recall as apiRecall, remember as apiRemember, outcome as apiOutcome, drillDown as apiDrillDown, assemble as apiAssemble, passesScopeFilterForRecall, buildSuppressionSummary, ambientSecretAdmit } from '../api.js';
24
24
  import { assertScopeRequestAllowed } from '../recall-scope.js';
25
25
  import { resolveProjectIdentity, classifyOriginProject, findHippoStoreDir } from '../project-identity.js';
26
26
  import { computePredictionBaserate } from '../predictions.js';
@@ -57,7 +57,7 @@ export function findHippoRoot(cwd = process.cwd(), opts) {
57
57
  * a member key never acts as admin through MCP.
58
58
  */
59
59
  function mcpActor(ctx) {
60
- return { subject: ctx?.actor ?? 'mcp', role: ctx?.role ?? 'admin' };
60
+ return { subject: ctx?.actor ?? 'mcp', role: ctx?.role ?? 'admin', scopes: ctx?.scopes };
61
61
  }
62
62
  // MCP stdio transport spec: messages are newline-delimited JSON-RPC, no embedded newlines.
63
63
  // https://modelcontextprotocol.io/specification/.../basic/transports#stdio
@@ -518,7 +518,7 @@ async function executeTool(name, args, ctx) {
518
518
  // ordering and the strength bump on retrieval. Apply the same scope
519
519
  // rule as api.recall: explicit scope = exact match; no scope =
520
520
  // default-deny on ANY `<source>:private:*` AND 'unknown:legacy'.
521
- // v1.2.1: generic-private check via api.isPrivateScope.
521
+ // EI2: one shared predicate (passesScopeFilterForRecall) so MCP never admits what SQL hides.
522
522
  const allEntries = loadAllEntries(hippoRoot, tenantId);
523
523
  // v1.12.13 / C5 — WYSIATI counters for the MCP physics/hybrid pipeline.
524
524
  // Per the plan-eng-critic round 1 CRIT resolution: MCP's user-visible
@@ -530,18 +530,7 @@ async function executeTool(name, args, ctx) {
530
530
  const totalCandidatesCountMcp = allEntries.length;
531
531
  const entries = explicitScope
532
532
  ? allEntries.filter((e) => e.scope === explicitScope)
533
- : allEntries.filter((e) => {
534
- const s = e.scope ?? null;
535
- if (s === null)
536
- return true;
537
- if (isPrivateScope(s))
538
- return false;
539
- // v1.7.2: read from RECALL_DEFAULT_DENY_SCOPES (single source of truth
540
- // shared with SQL clause + passesScopeFilterForRecall).
541
- if (RECALL_DEFAULT_DENY_SCOPES.some((deny) => deny === s))
542
- return false;
543
- return true;
544
- });
533
+ : allEntries.filter((e) => passesScopeFilterForRecall(e.scope ?? null, undefined));
545
534
  const droppedPreRankCountMcp = allEntries.length - entries.length;
546
535
  const usePhysics = config.physics?.enabled !== false;
547
536
  let results = usePhysics
@@ -1012,7 +1001,7 @@ async function executeTool(name, args, ctx) {
1012
1001
  // results and the snapshot. Pre-v1.2 this surface returned all memories
1013
1002
  // and the snapshot unfiltered, which would have leaked private-channel
1014
1003
  // content to no-scope MCP callers once scope writers shipped.
1015
- assertScopeRequestAllowed(mcpActor(ctx).role, explicitScope);
1004
+ assertScopeRequestAllowed(mcpActor(ctx), explicitScope);
1016
1005
  const allEntries = loadAllEntries(hippoRoot, tenantId);
1017
1006
  // v39 memory scope isolation: this surface reads the LOCAL store only,
1018
1007
  // but synced-down or legacy rows can still carry another project's
@@ -22,7 +22,7 @@
22
22
  * closed (retired).
23
23
  */
24
24
  import { openHippoDb, closeHippoDb } from './db.js';
25
- import { writeEntry, assertTenantId } from './store.js';
25
+ import { writeEntry, assertTenantId, RECALL_DEFAULT_DENY_SCOPES } from './store.js';
26
26
  import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
27
27
  import { createMemory, Layer, PROJECT_BRIEF_HALF_LIFE_DAYS } from './memory.js';
28
28
  import { appendAuditEvent } from './audit.js';
@@ -386,6 +386,7 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
386
386
  }
387
387
  const tag = `path:${normalizedRepo.toLowerCase()}`;
388
388
  const likeParam = `%"${escapeLike(tag)}"%`;
389
+ const denyPlaceholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
389
390
  const db = openHippoDb(hippoRoot);
390
391
  let receipts;
391
392
  try {
@@ -396,9 +397,10 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
396
397
  WHERE tenant_id = ?
397
398
  AND source != 'project_brief'
398
399
  AND LOWER(tags_json) LIKE ? ESCAPE '\\'
400
+ AND (scope IS NULL OR (scope NOT IN (${denyPlaceholders}) AND scope NOT LIKE '%:private:%'))
399
401
  ORDER BY created DESC, id DESC
400
402
  LIMIT ?
401
- `).all(tenantId, likeParam, MAX_BRIEF_RECEIPTS);
403
+ `).all(tenantId, likeParam, ...RECALL_DEFAULT_DENY_SCOPES, MAX_BRIEF_RECEIPTS);
402
404
  }
403
405
  finally {
404
406
  closeHippoDb(db);
@@ -0,0 +1,31 @@
1
+ /** Quarantine tier (CD5, AT3): pending/approved/rejected record for a memory `remember` flagged as untrusted. */
2
+ import type { DatabaseSyncLike } from './db.js';
3
+ export declare const QUARANTINE_SCOPE_PREFIX = "quarantine:private:";
4
+ /** Scope a quarantined memory is stored under; matches PRIVATE_SCOPE_RE so every default-deny site hides it. */
5
+ export declare function quarantineScopeFor(original: string | null): string;
6
+ export declare function isQuarantineScope(scope: string | null | undefined): boolean;
7
+ export type QuarantineStatus = 'pending' | 'approved' | 'rejected';
8
+ export interface QuarantineRow {
9
+ tenantId: string;
10
+ memoryId: string;
11
+ originalScope: string | null;
12
+ reason: string;
13
+ status: QuarantineStatus;
14
+ quarantinedAt: string;
15
+ decidedAt: string | null;
16
+ decidedBy: string | null;
17
+ }
18
+ export interface RecordQuarantineOpts {
19
+ tenantId: string;
20
+ memoryId: string;
21
+ originalScope: string | null;
22
+ reason: string;
23
+ actor: string;
24
+ }
25
+ /** Insert the quarantine row + its audit event; caller runs this inside the memory's own write transaction. */
26
+ export declare function recordQuarantine(db: DatabaseSyncLike, opts: RecordQuarantineOpts): void;
27
+ export declare function getQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string): QuarantineRow | null;
28
+ export declare function listQuarantineRows(db: DatabaseSyncLike, tenantId: string, status?: QuarantineStatus | 'all', limit?: number): QuarantineRow[];
29
+ export declare function approveQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string, decidedBy: string): void;
30
+ export declare function rejectQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string, decidedBy: string): void;
31
+ //# sourceMappingURL=quarantine.d.ts.map
@@ -0,0 +1,63 @@
1
+ /** Quarantine tier (CD5, AT3): pending/approved/rejected record for a memory `remember` flagged as untrusted. */
2
+ import { appendAuditEvent } from './audit.js';
3
+ export const QUARANTINE_SCOPE_PREFIX = 'quarantine:private:';
4
+ /** Scope a quarantined memory is stored under; matches PRIVATE_SCOPE_RE so every default-deny site hides it. */
5
+ export function quarantineScopeFor(original) {
6
+ return `${QUARANTINE_SCOPE_PREFIX}${original ?? 'unscoped'}`;
7
+ }
8
+ export function isQuarantineScope(scope) {
9
+ return scope != null && scope.startsWith(QUARANTINE_SCOPE_PREFIX);
10
+ }
11
+ const SELECT_COLUMNS = 'tenant_id, memory_id, original_scope, reason, status, quarantined_at, decided_at, decided_by';
12
+ function fromDbRow(row) {
13
+ return {
14
+ tenantId: row.tenant_id,
15
+ memoryId: row.memory_id,
16
+ originalScope: row.original_scope,
17
+ reason: row.reason,
18
+ // SAFETY: this module's own INSERT/UPDATE statements are the only writers of status.
19
+ status: row.status,
20
+ quarantinedAt: row.quarantined_at,
21
+ decidedAt: row.decided_at,
22
+ decidedBy: row.decided_by,
23
+ };
24
+ }
25
+ /** Insert the quarantine row + its audit event; caller runs this inside the memory's own write transaction. */
26
+ export function recordQuarantine(db, opts) {
27
+ db.prepare(`INSERT INTO memory_quarantine (tenant_id, memory_id, original_scope, reason, status, quarantined_at)
28
+ VALUES (?, ?, ?, ?, 'pending', ?)`).run(opts.tenantId, opts.memoryId, opts.originalScope, opts.reason, new Date().toISOString());
29
+ appendAuditEvent(db, {
30
+ tenantId: opts.tenantId,
31
+ actor: opts.actor,
32
+ op: 'quarantine',
33
+ targetId: opts.memoryId,
34
+ metadata: { reason: opts.reason, originalScope: opts.originalScope },
35
+ });
36
+ }
37
+ export function getQuarantineRow(db, tenantId, memoryId) {
38
+ const row = db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? AND memory_id = ?`)
39
+ .get(tenantId, memoryId);
40
+ return row ? fromDbRow(row) : null;
41
+ }
42
+ export function listQuarantineRows(db, tenantId, status = 'pending', limit = 100) {
43
+ // A pending row whose memory was deleted (e.g. Slack message_deleted) is dead; keep its history, drop it from the queue.
44
+ const live = status === 'pending'
45
+ ? ' AND EXISTS (SELECT 1 FROM memories m WHERE m.id = memory_quarantine.memory_id AND m.tenant_id = memory_quarantine.tenant_id)'
46
+ : '';
47
+ const rows = status === 'all'
48
+ ? db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? ORDER BY quarantined_at DESC LIMIT ?`)
49
+ .all(tenantId, limit)
50
+ : db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? AND status = ?${live} ORDER BY quarantined_at DESC LIMIT ?`)
51
+ .all(tenantId, status, limit);
52
+ // SAFETY: both branches select SELECT_COLUMNS, matching QuarantineDbRow's field set.
53
+ return rows.map(fromDbRow);
54
+ }
55
+ export function approveQuarantineRow(db, tenantId, memoryId, decidedBy) {
56
+ db.prepare(`UPDATE memory_quarantine SET status = 'approved', decided_at = ?, decided_by = ? WHERE tenant_id = ? AND memory_id = ?`)
57
+ .run(new Date().toISOString(), decidedBy, tenantId, memoryId);
58
+ }
59
+ export function rejectQuarantineRow(db, tenantId, memoryId, decidedBy) {
60
+ db.prepare(`UPDATE memory_quarantine SET status = 'rejected', decided_at = ?, decided_by = ? WHERE tenant_id = ? AND memory_id = ?`)
61
+ .run(new Date().toISOString(), decidedBy, tenantId, memoryId);
62
+ }
63
+ //# sourceMappingURL=quarantine.js.map
@@ -71,14 +71,26 @@ export declare class ScopeForbiddenError extends Error {
71
71
  * is the act that needs authorization.
72
72
  */
73
73
  export declare function isRestrictedScope(scope: string | null | undefined): boolean;
74
- /**
75
- * Authorize an explicitly requested scope before any read honours it.
76
- *
77
- * An admin (tenant owner, the local CLI, loopback without a key) may unlock
78
- * any scope in its tenant. A member key may not unlock a restricted scope by
79
- * naming it: before this check, any key in a tenant could read every private
80
- * channel or repo by passing its scope string. Per-scope grants for member
81
- * keys belong to ROADMAP Part VIII EI2 (permission-aware recall).
82
- */
83
- export declare function assertScopeRequestAllowed(role: 'admin' | 'member', requested: string | undefined): void;
74
+ /** The identity a scope check runs against: a role plus any scope grants. */
75
+ export interface ScopeActor {
76
+ role: 'admin' | 'member';
77
+ scopes?: readonly string[];
78
+ }
79
+ /** True when `actor` may read `scope`: admin always; member needs an exact grant on a restricted scope. */
80
+ export declare function canReadScope(actor: ScopeActor, scope: string): boolean;
81
+ /** Authorize an explicitly requested scope before any read honours it (ROADMAP Part VIII EI2: member scope grants). */
82
+ export declare function assertScopeRequestAllowed(actor: ScopeActor, requested: string | undefined): void;
83
+ /** Scope a derived row keeps from one source: the restricted scope itself, else null. */
84
+ export declare function derivationScope(scope: string | null | undefined): string | null;
85
+ /** The one derivation scope shared by every source, or `{ ok: false }` when
86
+ * two disagree, so the caller skips the derived row instead of under-scoping it. */
87
+ export declare function commonDerivationScope(scopes: readonly (string | null | undefined)[]): {
88
+ ok: true;
89
+ scope: string | null;
90
+ } | {
91
+ ok: false;
92
+ };
93
+ /** Map-partition key for consolidate/dag producers: tenant + derivation scope,
94
+ * so a derived row never blends two restricted scopes or a mixed pair. */
95
+ export declare function derivationPartitionKey(tenantId: string, scope: string | null | undefined): string;
84
96
  //# sourceMappingURL=recall-scope.d.ts.map
@@ -50,18 +50,7 @@ export function passesScopeFilterForRecall(scope, requested) {
50
50
  if (requested !== undefined && requested !== '') {
51
51
  return scope === requested;
52
52
  }
53
- if (scope === null)
54
- return true;
55
- if (isPrivateScope(scope))
56
- return false;
57
- // SAFETY: RECALL_DEFAULT_DENY_SCOPES (v1.7.2, single source of truth
58
- // shared with the SQL clause in loadSearchRows) is a readonly tuple of
59
- // string literals. Cast the array to readonly string[] so .includes()
60
- // accepts arbitrary string scopes without a cast on the input (codex
61
- // P0-2: casting `scope` would defeat the constant's safety).
62
- if (RECALL_DEFAULT_DENY_SCOPES.includes(scope))
63
- return false;
64
- return true;
53
+ return !isRestrictedScope(scope);
65
54
  }
66
55
  /**
67
56
  * v1.25.0 — the CLI `--scope` variant of the recall filter (JS half of the
@@ -108,23 +97,49 @@ export function isRestrictedScope(scope) {
108
97
  return false;
109
98
  // SAFETY: RECALL_DEFAULT_DENY_SCOPES is a readonly tuple of string
110
99
  // literals; widening the array (not the input) lets .includes() take any scope.
111
- return isPrivateScope(scope) || RECALL_DEFAULT_DENY_SCOPES.includes(scope);
100
+ // `:private:` anywhere, any case, matches the store's SQL default-deny (store.ts:894) so JS never admits what SQL hides.
101
+ return isPrivateScope(scope) || /:private:/i.test(scope) || RECALL_DEFAULT_DENY_SCOPES.includes(scope);
112
102
  }
113
- /**
114
- * Authorize an explicitly requested scope before any read honours it.
115
- *
116
- * An admin (tenant owner, the local CLI, loopback without a key) may unlock
117
- * any scope in its tenant. A member key may not unlock a restricted scope by
118
- * naming it: before this check, any key in a tenant could read every private
119
- * channel or repo by passing its scope string. Per-scope grants for member
120
- * keys belong to ROADMAP Part VIII EI2 (permission-aware recall).
121
- */
122
- export function assertScopeRequestAllowed(role, requested) {
103
+ /** True when `actor` may read `scope`: admin always; member needs an exact grant on a restricted scope. */
104
+ export function canReadScope(actor, scope) {
105
+ if (actor.role === 'admin')
106
+ return true;
107
+ if (!isRestrictedScope(scope))
108
+ return true;
109
+ return (actor.scopes ?? []).includes(scope);
110
+ }
111
+ /** Authorize an explicitly requested scope before any read honours it (ROADMAP Part VIII EI2: member scope grants). */
112
+ export function assertScopeRequestAllowed(actor, requested) {
123
113
  if (requested === undefined || requested === '')
124
114
  return;
125
- if (role === 'admin')
115
+ if (canReadScope(actor, requested))
126
116
  return;
127
- if (isRestrictedScope(requested))
128
- throw new ScopeForbiddenError(requested);
117
+ throw new ScopeForbiddenError(requested);
118
+ }
119
+ /** Scope a derived row keeps from one source: the restricted scope itself, else null. */
120
+ export function derivationScope(scope) {
121
+ return isRestrictedScope(scope) ? (scope ?? null) : null;
122
+ }
123
+ /** The one derivation scope shared by every source, or `{ ok: false }` when
124
+ * two disagree, so the caller skips the derived row instead of under-scoping it. */
125
+ export function commonDerivationScope(scopes) {
126
+ let common = null;
127
+ let seen = false;
128
+ for (const raw of scopes) {
129
+ const scope = derivationScope(raw);
130
+ if (!seen) {
131
+ common = scope;
132
+ seen = true;
133
+ }
134
+ else if (scope !== common) {
135
+ return { ok: false };
136
+ }
137
+ }
138
+ return { ok: true, scope: common };
139
+ }
140
+ /** Map-partition key for consolidate/dag producers: tenant + derivation scope,
141
+ * so a derived row never blends two restricted scopes or a mixed pair. */
142
+ export function derivationPartitionKey(tenantId, scope) {
143
+ return `${tenantId}\u0000${derivationScope(scope) ?? ''}`;
129
144
  }
130
145
  //# sourceMappingURL=recall-scope.js.map
@@ -50,7 +50,7 @@ const KEYISH_CONTEXT_RE = /key|token|secret|credential|bearer|auth|password/i;
50
50
  const CO_OCCURRENCE_GUARDED = new Set(['sk-style-key', 'sk-underscore-key']);
51
51
  // redactSecretsStrict-only: too noisy for whole-entry memory scanning, worth hiding once text leaves the machine.
52
52
  const STRICT_ONLY_PATTERNS = [
53
- /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{16,}=*/g,
53
+ /\bbearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
54
54
  /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g,
55
55
  ];
56
56
  /**