hippo-memory 1.47.0 → 1.49.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.
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 = 47;
15
15
  const MIGRATIONS = [
16
16
  {
17
17
  version: 1,
@@ -2447,6 +2447,22 @@ 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
+ },
2450
2466
  ];
2451
2467
  function tableHasColumn(db, tableName, columnName) {
2452
2468
  if (!/^[a-z_]+$/i.test(tableName))
@@ -2469,11 +2485,14 @@ export function getHippoDbPath(hippoRoot) {
2469
2485
  export function getCurrentSchemaVersion() {
2470
2486
  return CURRENT_SCHEMA_VERSION;
2471
2487
  }
2488
+ /** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
2489
+ export class IncompatibleBinaryError extends Error {
2490
+ }
2472
2491
  /** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
2473
2492
  function assertBinaryCompatible(db) {
2474
2493
  const minRequired = getMeta(db, 'min_compatible_binary');
2475
2494
  if (minRequired && compareSemver(minRequired, PACKAGE_VERSION) > 0) {
2476
- throw new Error(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
2495
+ throw new IncompatibleBinaryError(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
2477
2496
  `Upgrade hippo-memory to open it; an older binary does not know this schema and could expose private rows or damage the store.`);
2478
2497
  }
2479
2498
  }
@@ -2530,6 +2549,25 @@ export function openHippoDb(hippoRoot) {
2530
2549
  throw error;
2531
2550
  }
2532
2551
  }
2552
+ /** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
2553
+ export function openHippoDbReadOnly(hippoRoot) {
2554
+ const db = new DatabaseSync(getHippoDbPath(hippoRoot), { readOnly: true });
2555
+ try {
2556
+ db.exec('PRAGMA busy_timeout = 5000');
2557
+ if (tableExists(db, 'meta'))
2558
+ assertBinaryCompatible(db);
2559
+ return db;
2560
+ }
2561
+ catch (error) {
2562
+ try {
2563
+ db.close();
2564
+ }
2565
+ catch {
2566
+ // Best effort only.
2567
+ }
2568
+ throw error;
2569
+ }
2570
+ }
2533
2571
  function runMigrations(db, hippoRoot) {
2534
2572
  ensureMetaTable(db);
2535
2573
  // Before anything writes, so a stale binary never repairs or migrates a store it does not understand.
@@ -2578,6 +2616,8 @@ function ensureMetaTable(db) {
2578
2616
  `);
2579
2617
  }
2580
2618
  export function getSchemaVersion(db) {
2619
+ if (!tableExists(db, 'meta'))
2620
+ return 0;
2581
2621
  // SAFETY: row's shape matches the single `value` column named in the
2582
2622
  // SELECT above.
2583
2623
  const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
@@ -2784,6 +2824,18 @@ export function getMeta(db, key, fallback = '') {
2784
2824
  export function setMeta(db, key, value) {
2785
2825
  db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
2786
2826
  }
2827
+ /** Row count of one table; null when the table is missing or unreadable, which callers show as unknown. */
2828
+ export function countTableRows(db, table) {
2829
+ try {
2830
+ // SAFETY: COUNT(*) returns one row with one numeric column.
2831
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM "${table.replace(/"/g, '""')}"`).get();
2832
+ return Number(row?.n ?? 0);
2833
+ }
2834
+ catch {
2835
+ // Callers treat an uncountable table as unknown; the bundle and doctor still finish.
2836
+ return null;
2837
+ }
2838
+ }
2787
2839
  export function isFtsAvailable(db) {
2788
2840
  return getMeta(db, 'fts5_available', '0') === '1';
2789
2841
  }
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/doctor.js CHANGED
@@ -9,8 +9,8 @@ import * as os from 'node:os';
9
9
  import * as path from 'node:path';
10
10
  import { findHippoStoreDir } from './project-identity.js';
11
11
  import { getGlobalRoot } from './shared.js';
12
- import { isInitialized, loadStats } from './store.js';
13
- import { openHippoDb, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion } from './db.js';
12
+ import { isInitialized } from './store.js';
13
+ import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
14
14
  import { isEmbeddingAvailable } from './embeddings.js';
15
15
  /** Minimum Node.js version hippo supports (package.json engines). */
16
16
  export const MIN_NODE = '22.16.0';
@@ -34,14 +34,42 @@ function readJson(file) {
34
34
  return null;
35
35
  }
36
36
  }
37
- function countRows(db, table) {
37
+ // Migration 46 creates failure_log; a read-only open no longer creates it on an older store.
38
+ const FAILURE_LOG_SCHEMA = 46;
39
+ /** The failed-tool-call count over the last 7 days, or why it could not be read. */
40
+ function failuresCheck(db, since, schemaVersion) {
38
41
  try {
39
- // SAFETY: COUNT(*) returns one row with one numeric column.
40
- const row = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
41
- return Number(row?.n ?? 0);
42
+ // SAFETY: COUNT aggregate row.
43
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM failure_log WHERE ts >= ?`).get(since);
44
+ return { id: 'failures', status: 'info', detail: `${Number(row?.n ?? 0)} failed tool calls logged in 7 days (hippo failures for detail)` };
42
45
  }
43
- catch {
44
- return null;
46
+ catch (err) {
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ if (!message.includes('no such table')) {
49
+ return { id: 'failures', status: 'warn', detail: `cannot read the failure log: ${message}` };
50
+ }
51
+ return schemaVersion < FAILURE_LOG_SCHEMA
52
+ ? { id: 'failures', status: 'info', detail: 'no failure log yet (hippo creates it on the next write)' }
53
+ : { id: 'failures', status: 'warn', detail: 'the failure_log table is missing, so failed tool calls are not being logged' };
54
+ }
55
+ }
56
+ /** How long ago the store last slept (consolidated), or why that history could not be read. */
57
+ function sleepCheck(db, now) {
58
+ try {
59
+ // SAFETY: row's shape matches the single `timestamp` column named in the SELECT above.
60
+ const row = db.prepare(`SELECT timestamp FROM consolidation_runs ORDER BY timestamp DESC, id DESC LIMIT 1`).get();
61
+ const when = row?.timestamp !== undefined ? Date.parse(row.timestamp) : Number.NaN;
62
+ if (Number.isNaN(when)) {
63
+ return { id: 'sleep', status: 'warn', detail: 'hippo has never slept (consolidated) in this store', fix: 'hippo sleep (the session-end hook runs it automatically)' };
64
+ }
65
+ const days = Math.floor((now.getTime() - when) / 86_400_000);
66
+ return days > 7
67
+ ? { id: 'sleep', status: 'warn', detail: `last sleep ${days} days ago`, fix: 'hippo sleep, and check the session-end hook is installed' }
68
+ : { id: 'sleep', status: 'pass', detail: `last sleep ${days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`}` };
69
+ }
70
+ catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ return { id: 'sleep', status: 'info', detail: `sleep history unavailable (${message})` };
45
73
  }
46
74
  }
47
75
  /** Run every check. Never throws for a broken install; broken parts become failed checks. */
@@ -58,10 +86,14 @@ export function runDoctor(opts) {
58
86
  const globalRoot = getGlobalRoot();
59
87
  const hasGlobal = isInitialized(globalRoot);
60
88
  let store = null;
61
- if (local !== null) {
89
+ if (local !== null && isInitialized(local)) {
62
90
  store = local;
63
91
  checks.push({ id: 'store', status: 'pass', detail: `project store at ${local}${hasGlobal ? ` (global store at ${globalRoot} too)` : ''}` });
64
92
  }
93
+ else if (local !== null) {
94
+ // The walk stops at the first .hippo it finds, so a bare one (no hippo.db) blocks a parent or global store too.
95
+ checks.push({ id: 'store', status: 'fail', detail: `${local} has no hippo.db, so hippo commands run here stop at it`, fix: `run hippo init in ${path.dirname(local)}, or remove that .hippo folder` });
96
+ }
65
97
  else if (hasGlobal) {
66
98
  store = globalRoot;
67
99
  checks.push({ id: 'store', status: 'warn', detail: `no project store here; using the global store at ${globalRoot}`, fix: 'hippo init (in the project root)' });
@@ -72,7 +104,7 @@ export function runDoctor(opts) {
72
104
  if (store !== null) {
73
105
  let db = null;
74
106
  try {
75
- db = openHippoDb(store);
107
+ db = openHippoDbReadOnly(store);
76
108
  const have = getSchemaVersion(db);
77
109
  const want = getCurrentSchemaVersion();
78
110
  checks.push(have === want
@@ -80,8 +112,8 @@ export function runDoctor(opts) {
80
112
  : have > want
81
113
  ? { id: 'schema', status: 'fail', detail: `database schema v${have} is newer than this hippo (v${want})`, fix: 'npm install -g hippo-memory@latest' }
82
114
  : { id: 'schema', status: 'info', detail: `database schema v${have}; hippo migrates it to v${want} on the next write` });
83
- const memories = countRows(db, 'memories');
84
- const dormant = countRows(db, 'dormant_memories');
115
+ const memories = countTableRows(db, 'memories');
116
+ const dormant = countTableRows(db, 'dormant_memories');
85
117
  const memoryCheck = { id: 'memories', status: 'info', detail: `${memories ?? '?'} memories${dormant !== null ? `, ${dormant} dormant` : ''}` };
86
118
  if (memories === 0) {
87
119
  memoryCheck.status = 'warn';
@@ -97,42 +129,21 @@ export function runDoctor(opts) {
97
129
  catch {
98
130
  checks.push({ id: 'tokens', status: 'info', detail: 'no token ledger yet (created on the next write)' });
99
131
  }
100
- try {
101
- // SAFETY: COUNT aggregate row.
102
- const row = db.prepare(`SELECT COUNT(*) AS n FROM failure_log WHERE ts >= ?`).get(since);
103
- checks.push({ id: 'failures', status: 'info', detail: `${Number(row?.n ?? 0)} failed tool calls logged in 7 days (hippo failures for detail)` });
104
- }
105
- catch {
106
- // Opening the store migrates it, so a missing table was dropped: the hook is logging nothing.
107
- checks.push({ id: 'failures', status: 'warn', detail: 'the failure_log table is missing, so failed tool calls are not being logged' });
108
- }
132
+ checks.push(failuresCheck(db, since, have));
133
+ checks.push(sleepCheck(db, now));
109
134
  }
110
135
  catch (err) {
111
- checks.push({ id: 'schema', status: 'fail', detail: `cannot open the database: ${err instanceof Error ? err.message : String(err)}`, fix: 'check file permissions on the .hippo folder' });
136
+ checks.push({
137
+ id: 'schema',
138
+ status: 'fail',
139
+ detail: `cannot open the database: ${err instanceof Error ? err.message : String(err)}`,
140
+ fix: err instanceof IncompatibleBinaryError ? 'npm install -g hippo-memory@latest' : 'check file permissions on the .hippo folder',
141
+ });
112
142
  }
113
143
  finally {
114
144
  if (db !== null)
115
145
  closeHippoDb(db);
116
146
  }
117
- try {
118
- const runs = loadStats(store)['consolidation_runs'];
119
- const last = Array.isArray(runs) && runs.length > 0 ? runs[runs.length - 1] : null;
120
- // SAFETY: the constructor check above narrows `last` to a plain JSON object.
121
- const ts = last !== null && last !== undefined && !Array.isArray(last) && last.constructor === Object ? last.timestamp : undefined;
122
- const when = ts !== undefined && ts !== null ? Date.parse(String(ts)) : Number.NaN;
123
- if (Number.isNaN(when)) {
124
- checks.push({ id: 'sleep', status: 'warn', detail: 'hippo has never slept (consolidated) in this store', fix: 'hippo sleep (the session-end hook runs it automatically)' });
125
- }
126
- else {
127
- const days = Math.floor((now.getTime() - when) / 86_400_000);
128
- checks.push(days > 7
129
- ? { id: 'sleep', status: 'warn', detail: `last sleep ${days} days ago`, fix: 'hippo sleep, and check the session-end hook is installed' }
130
- : { id: 'sleep', status: 'pass', detail: `last sleep ${days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`}` });
131
- }
132
- }
133
- catch {
134
- checks.push({ id: 'sleep', status: 'info', detail: 'sleep history unavailable' });
135
- }
136
147
  }
137
148
  const claudeDir = path.join(home, '.claude');
138
149
  if (fs.existsSync(claudeDir)) {
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
@@ -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);
@@ -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
@@ -41,8 +41,8 @@ export interface RejectFlowResult {
41
41
  * normalized digest matches (kind-aware), one aggregate `reject_value`
42
42
  * audit, COMMIT. Then post-commit (mirrors the existing purge+reaper
43
43
  * pattern verbatim from api.archiveRaw, api.ts:1913-1938): best-effort
44
- * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids, one
45
- * index mirror rewrite.
44
+ * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids.
45
+ * index.json itself is only refreshed by `rebuildIndex()`.
46
46
  */
47
47
  export declare function rejectValue(opts: RejectFlowOpts): RejectFlowResult;
48
48
  export type UnrejectOutcome = {
@@ -15,7 +15,7 @@ import { openHippoDb, closeHippoDb } from './db.js';
15
15
  import { appendAuditEvent } from './audit.js';
16
16
  import { archiveRawMemory } from './raw-archive.js';
17
17
  import { purgeDormantByDigest } from './dormant.js';
18
- import { initStore, deleteEntryCore, purgeMirrorBestEffort, writeIndexMirror, buildIndexFromDb, } from './store.js';
18
+ import { initStore, deleteEntryCore, purgeMirrorBestEffort, } from './store.js';
19
19
  import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, deleteRejectedValue, listRejectedValues, } from './rejection.js';
20
20
  /**
21
21
  * `hippo reject` / `api.reject` core flow. ONE connection, one transaction:
@@ -23,8 +23,8 @@ import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, delet
23
23
  * normalized digest matches (kind-aware), one aggregate `reject_value`
24
24
  * audit, COMMIT. Then post-commit (mirrors the existing purge+reaper
25
25
  * pattern verbatim from api.archiveRaw, api.ts:1913-1938): best-effort
26
- * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids, one
27
- * index mirror rewrite.
26
+ * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids.
27
+ * index.json itself is only refreshed by `rebuildIndex()`.
28
28
  */
29
29
  export function rejectValue(opts) {
30
30
  if (!opts.reason.trim()) {
@@ -138,7 +138,7 @@ export function rejectValue(opts) {
138
138
  }
139
139
  // Post-commit, db handle still open (same pattern as api.archiveRaw):
140
140
  // best-effort mirror purge per removed id, reaper-backstop stamp for
141
- // raw ids, one index mirror rewrite.
141
+ // raw ids.
142
142
  for (const id of removedIds) {
143
143
  // AT1 fix: purgeMirrorBestEffort retries once, then — for non-raw ids,
144
144
  // which cleanupArchivedMirrors' reaper never scans — reports the
@@ -150,9 +150,6 @@ export function rejectValue(opts) {
150
150
  db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
151
151
  }
152
152
  }
153
- if (removedIds.length > 0) {
154
- writeIndexMirror(opts.hippoRoot, buildIndexFromDb(db));
155
- }
156
153
  return { digest, content, removedIds, removedRawIds };
157
154
  }
158
155
  finally {
@@ -38,4 +38,6 @@ export declare function detectSecret(entry: {
38
38
  * CS1 pre-compact snapshot fields — can scrub it in place instead.
39
39
  */
40
40
  export declare function redactSecrets(text: string): string;
41
+ /** Stricter redaction for text that leaves the machine: no co-occurrence guard, plus Bearer headers and JWTs. */
42
+ export declare function redactSecretsStrict(text: string): string;
41
43
  //# sourceMappingURL=secret-detect.d.ts.map
@@ -48,6 +48,11 @@ const SECRET_PATTERNS = [
48
48
  ];
49
49
  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
+ // redactSecretsStrict-only: too noisy for whole-entry memory scanning, worth hiding once text leaves the machine.
52
+ const STRICT_ONLY_PATTERNS = [
53
+ /\bbearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
54
+ /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g,
55
+ ];
51
56
  /**
52
57
  * Scan a memory's tags + content for secret material.
53
58
  * Pure and deterministic; no filesystem or store access.
@@ -75,21 +80,28 @@ export function detectSecret(entry) {
75
80
  * CS1 pre-compact snapshot fields — can scrub it in place instead.
76
81
  */
77
82
  export function redactSecrets(text) {
83
+ return redactText(text, false);
84
+ }
85
+ /** Stricter redaction for text that leaves the machine: no co-occurrence guard, plus Bearer headers and JWTs. */
86
+ export function redactSecretsStrict(text) {
87
+ return redactText(text, true);
88
+ }
89
+ function redactText(text, strict) {
78
90
  if (!text)
79
91
  return text;
80
92
  let result = text;
81
- // PEM/OpenSSH blocks first: the pattern-table entry matches only the
82
- // BEGIN delimiter, which is fine for detectSecret's flag-or-not decision
83
- // but would leave the base64 payload behind here. Consume through the
84
- // matching END delimiter; a truncated block with no END is redacted to
85
- // the end of the text (codex round 3).
93
+ // PEM/OpenSSH blocks first: SECRET_PATTERNS only matches BEGIN; consume through END, or to the end of a truncated block.
86
94
  result = result.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----|[\s\S]*$)/g, '[REDACTED]');
87
95
  for (const { name, re } of SECRET_PATTERNS) {
88
- if (CO_OCCURRENCE_GUARDED.has(name) && !KEYISH_CONTEXT_RE.test(text))
96
+ if (!strict && CO_OCCURRENCE_GUARDED.has(name) && !KEYISH_CONTEXT_RE.test(text))
89
97
  continue;
90
98
  const flags = re.flags.includes('g') ? re.flags : `${re.flags}g`;
91
99
  result = result.replace(new RegExp(re.source, flags), '[REDACTED]');
92
100
  }
101
+ if (strict) {
102
+ for (const re of STRICT_ONLY_PATTERNS)
103
+ result = result.replace(re, '[REDACTED]');
104
+ }
93
105
  return result;
94
106
  }
95
107
  //# sourceMappingURL=secret-detect.js.map