hippo-memory 1.48.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/api.d.ts CHANGED
@@ -40,6 +40,8 @@ export interface Actor {
40
40
  /** 'cli' | 'localhost:cli' | 'api_key:<key_id>' | 'mcp' | 'connector:slack' | 'connector:github' */
41
41
  subject: string;
42
42
  role: 'admin' | 'member';
43
+ /** EI2: restricted scopes a member key may read (auth.ts grantScope). Unused for admin actors. */
44
+ scopes?: readonly string[];
43
45
  }
44
46
  export interface Context {
45
47
  hippoRoot: string;
@@ -875,6 +877,14 @@ export interface AuthRevokeResult {
875
877
  revokedAt: string;
876
878
  }
877
879
  export declare function authRevoke(ctx: Context, keyId: string): AuthRevokeResult;
880
+ /** Shared result shape for authGrant/authUngrant, named per the file's oxlint anti-slop rule. */
881
+ export interface AuthGrantResult {
882
+ ok: true;
883
+ }
884
+ /** Grant `keyId` read access to one restricted `scope` (ROADMAP Part VIII EI2). Admin only. */
885
+ export declare function authGrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
886
+ /** Revoke `keyId`'s grant on `scope`. Same authorization and lookup rules as authGrant. */
887
+ export declare function authUngrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
878
888
  export interface AuditListOpts {
879
889
  op?: AuditOp;
880
890
  /** ISO timestamp lower bound. */
package/dist/api.js CHANGED
@@ -21,7 +21,7 @@ import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './s
21
21
  import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
22
22
  import { evalNow } from './ablation.js';
23
23
  import { archiveRawMemory } from './raw-archive.js';
24
- import { createApiKey, listApiKeys, revokeApiKey, } from './auth.js';
24
+ import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
25
25
  import { applyGoalStackBoost } from './goals.js';
26
26
  import { markRetrieved, estimateTokens, hybridSearch, physicsSearch } from './search.js';
27
27
  import { compareEntryIdentity, compareScoredResults } from './compare.js';
@@ -87,7 +87,7 @@ export class ForbiddenError extends Error {
87
87
  // back-compat (`api.isPrivateScope`, test imports). NOTE: the import statement
88
88
  // is required — a bare `export { x } from` re-export does not bind the local
89
89
  // names this module's ~9 call sites use.
90
- import { isPrivateScope, passesScopeFilterForRecall, assertScopeRequestAllowed } from './recall-scope.js';
90
+ import { isPrivateScope, passesScopeFilterForRecall, assertScopeRequestAllowed, isRestrictedScope } from './recall-scope.js';
91
91
  export { isPrivateScope, passesScopeFilterForRecall };
92
92
  export { passesCliRecallScopeFilter, ScopeForbiddenError } from './recall-scope.js';
93
93
  // v39: classifyOriginProject lives in project-identity.ts (leaf) so
@@ -188,13 +188,13 @@ export function buildSuppressionSummary(counts) {
188
188
  */
189
189
  export function recall(ctx, opts) {
190
190
  // A member key may not unlock a private or quarantined scope by naming it.
191
- assertScopeRequestAllowed(ctx.actor.role, opts.scope);
191
+ assertScopeRequestAllowed(ctx.actor, opts.scope);
192
192
  const windowSize = recallWindowSize(opts);
193
193
  return recallFrom(ctx, opts, windowSize, loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false));
194
194
  }
195
195
  /** Mode-aware recall that strengthens each returned row; never writes last_retrieval_ids (v1.11.5 lock). */
196
196
  export async function retrieve(ctx, opts) {
197
- assertScopeRequestAllowed(ctx.actor.role, opts.scope);
197
+ assertScopeRequestAllowed(ctx.actor, opts.scope);
198
198
  const windowSize = recallWindowSize(opts);
199
199
  let candidates = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false);
200
200
  if (opts.mode === 'hybrid' || opts.mode === 'physics') {
@@ -288,7 +288,7 @@ function recallFrom(ctx, opts, windowSize, all) {
288
288
  // anchored `<source>:private:*` rule (v1.2.1 generalization) and
289
289
  // defense-in-depth: connector authors cannot silently surface private
290
290
  // rows to no-scope callers even if the SQL clause regresses.
291
- entries = current.filter((e) => !isPrivateScope(e.scope ?? null));
291
+ entries = current.filter((e) => !isRestrictedScope(e.scope ?? null));
292
292
  }
293
293
  // v1.12.13 / C5 — WYSIATI dropped_pre_rank counter (JS scope filter drops
294
294
  // for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
@@ -725,7 +725,7 @@ function recallFrom(ctx, opts, windowSize, all) {
725
725
  * - all rows fail the scope/tenant filter
726
726
  */
727
727
  export function assemble(ctx, sessionId, opts = {}) {
728
- assertScopeRequestAllowed(ctx.actor.role, opts.scope);
728
+ assertScopeRequestAllowed(ctx.actor, opts.scope);
729
729
  const budget = opts.budget ?? 4000;
730
730
  const freshTailCount = opts.freshTailCount ?? 10;
731
731
  const summarizeOlder = opts.summarizeOlder ?? true;
@@ -1152,6 +1152,7 @@ export function supersede(ctx, oldId, newContent) {
1152
1152
  source: old.source,
1153
1153
  confidence: 'verified',
1154
1154
  tenantId: ctx.tenantId,
1155
+ scope: old.scope,
1155
1156
  });
1156
1157
  // Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
1157
1158
  // three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row)
@@ -1401,6 +1402,50 @@ export function authRevoke(ctx, keyId) {
1401
1402
  closeHippoDb(db);
1402
1403
  }
1403
1404
  }
1405
+ /** Grant `keyId` read access to one restricted `scope` (ROADMAP Part VIII EI2). Admin only. */
1406
+ export function authGrant(ctx, keyId, scope) {
1407
+ return changeScopeGrant(ctx, keyId, scope, 'auth_grant');
1408
+ }
1409
+ /** Revoke `keyId`'s grant on `scope`. Same authorization and lookup rules as authGrant. */
1410
+ export function authUngrant(ctx, keyId, scope) {
1411
+ return changeScopeGrant(ctx, keyId, scope, 'auth_ungrant');
1412
+ }
1413
+ function changeScopeGrant(ctx, keyId, scope, op) {
1414
+ if (ctx.actor.role !== 'admin') {
1415
+ throw new ForbiddenError('Only an admin key can change scope grants');
1416
+ }
1417
+ const db = openHippoDb(ctx.hippoRoot);
1418
+ try {
1419
+ // SAFETY: row's shape matches the single tenant_id column in the SELECT.
1420
+ const row = db
1421
+ .prepare(`SELECT tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
1422
+ .get(keyId);
1423
+ if (!row || row.tenant_id !== ctx.tenantId) {
1424
+ throw new Error(`Unknown key_id: ${keyId}`);
1425
+ }
1426
+ if (op === 'auth_grant' && row.revoked_at) {
1427
+ throw new Error(`${keyId} is revoked; a grant on it would never apply`);
1428
+ }
1429
+ if (!isRestrictedScope(scope)) {
1430
+ throw new Error(`${scope} is not a restricted scope; it is already readable by default`);
1431
+ }
1432
+ if (op === 'auth_grant')
1433
+ grantScope(db, keyId, scope);
1434
+ else
1435
+ ungrantScope(db, keyId, scope);
1436
+ try {
1437
+ appendAuditEvent(db, { tenantId: ctx.tenantId, actor: ctx.actor.subject, op, targetId: keyId, metadata: { scope } });
1438
+ }
1439
+ catch (err) {
1440
+ // Audit must not undo a grant change that already committed; surface it instead.
1441
+ console.error(`auth: audit write failed for ${op} ${keyId}: ${err instanceof Error ? err.message : String(err)}`);
1442
+ }
1443
+ return { ok: true };
1444
+ }
1445
+ finally {
1446
+ closeHippoDb(db);
1447
+ }
1448
+ }
1404
1449
  /**
1405
1450
  * Read audit events scoped to `ctx.tenantId`. Read-only — no audit emit (matches
1406
1451
  * A5: cmdAuditList does not record a 'recall'-style read event).
package/dist/audit.d.ts CHANGED
@@ -16,7 +16,7 @@ export interface AuditResult {
16
16
  export declare function auditMemory(entry: MemoryEntry): AuditIssue | null;
17
17
  export declare function auditMemories(entries: MemoryEntry[]): AuditResult;
18
18
  export declare function isContentWorthStoring(content: string): boolean;
19
- export type AuditOp = 'remember' | 'recall' | 'promote' | 'supersede' | 'forget' | 'archive_raw' | 'auth_revoke' | 'auth_create' | 'outcome' | 'consolidate' | 'audit_prune' | 'summary_marked_dirty' | 'summary_marked_clean' | 'summary_rebuilt' | 'predict_create' | 'predict_close' | 'predict_baserate' | 'recall_autodebias_hint' | 'recall_autodebias_hint_no_class_match' | 'recall_autodebias_hint_tiebreak' | 'recall_anchor_detected_query_repeat' | 'recall_anchor_detected_memory_dominance' | 'recall_anchor_skipped_no_session' | 'recall_availability_detected' | 'decision_create' | 'decision_supersede' | 'decision_close' | 'incident_open' | 'incident_resolve' | 'incident_close' | 'process_create' | 'process_supersede' | 'process_close' | 'policy_create' | 'policy_supersede' | 'policy_close' | 'skill_create' | 'skill_supersede' | 'skill_close' | 'project_brief_create' | 'project_brief_supersede' | 'project_brief_close' | 'customer_note_create' | 'customer_note_supersede' | 'customer_note_close' | 'mv_rescue' | 'reject_value' | 'reject_refusal' | 'unreject_value' | 'half_life_migrate' | 'dormant_restore' | 'conflict_resolve';
19
+ export type AuditOp = 'remember' | 'recall' | 'promote' | 'supersede' | 'forget' | 'archive_raw' | 'auth_revoke' | 'auth_create' | 'outcome' | 'consolidate' | 'audit_prune' | 'summary_marked_dirty' | 'summary_marked_clean' | 'summary_rebuilt' | 'predict_create' | 'predict_close' | 'predict_baserate' | 'recall_autodebias_hint' | 'recall_autodebias_hint_no_class_match' | 'recall_autodebias_hint_tiebreak' | 'recall_anchor_detected_query_repeat' | 'recall_anchor_detected_memory_dominance' | 'recall_anchor_skipped_no_session' | 'recall_availability_detected' | 'decision_create' | 'decision_supersede' | 'decision_close' | 'incident_open' | 'incident_resolve' | 'incident_close' | 'process_create' | 'process_supersede' | 'process_close' | 'policy_create' | 'policy_supersede' | 'policy_close' | 'skill_create' | 'skill_supersede' | 'skill_close' | 'project_brief_create' | 'project_brief_supersede' | 'project_brief_close' | 'customer_note_create' | 'customer_note_supersede' | 'customer_note_close' | 'mv_rescue' | 'reject_value' | 'reject_refusal' | 'unreject_value' | 'half_life_migrate' | 'dormant_restore' | 'conflict_resolve' | 'auth_grant' | 'auth_ungrant';
20
20
  export interface AppendAuditOpts {
21
21
  tenantId: string;
22
22
  actor: string;
package/dist/auth.d.ts CHANGED
@@ -16,9 +16,17 @@ export interface ValidateResult {
16
16
  keyId?: string;
17
17
  /** v1.12.0 A5 v2 sub-1: 'admin' | 'member'. Present only when valid=true. */
18
18
  role?: 'admin' | 'member';
19
+ /** EI2: scope grants for this key. Present only when valid=true. */
20
+ scopes?: string[];
19
21
  }
20
22
  export declare function validateApiKey(db: DatabaseSyncLike, plaintext: string): ValidateResult;
21
23
  export declare function revokeApiKey(db: DatabaseSyncLike, keyId: string): void;
24
+ /** EI2: grant `keyId` read access to one restricted `scope`. Idempotent. */
25
+ export declare function grantScope(db: DatabaseSyncLike, keyId: string, scope: string): void;
26
+ /** EI2: revoke `keyId`'s grant on `scope`. Not an error when no such grant exists. */
27
+ export declare function ungrantScope(db: DatabaseSyncLike, keyId: string, scope: string): void;
28
+ /** EI2: every restricted scope `keyId` may read. */
29
+ export declare function listScopeGrants(db: DatabaseSyncLike, keyId: string): string[];
22
30
  export interface ApiKeyListItem {
23
31
  keyId: string;
24
32
  tenantId: string;
@@ -31,6 +39,8 @@ export interface ApiKeyListItem {
31
39
  * Fail-safe-to-member cast: any non-'admin' value reads as 'member'.
32
40
  */
33
41
  role: 'admin' | 'member';
42
+ /** EI2: restricted scopes this key may read. */
43
+ scopes: string[];
34
44
  }
35
45
  export declare function listApiKeys(db: DatabaseSyncLike, opts: {
36
46
  active: boolean;
package/dist/auth.js CHANGED
@@ -74,12 +74,30 @@ export function validateApiKey(db, plaintext) {
74
74
  // 'superuser', or a NULL slipped past the NOT NULL constraint) downgrades to
75
75
  // 'member'. The migration constrains to 'admin' DEFAULT, but defense-in-depth.
76
76
  const role = row.role === 'admin' ? 'admin' : 'member';
77
- return { valid: true, tenantId: row.tenant_id, keyId, role };
77
+ const scopes = listScopeGrants(db, keyId);
78
+ return { valid: true, tenantId: row.tenant_id, keyId, role, scopes };
78
79
  }
79
80
  export function revokeApiKey(db, keyId) {
80
81
  db.prepare(`UPDATE api_keys SET revoked_at = ? WHERE key_id = ? AND revoked_at IS NULL`)
81
82
  .run(new Date().toISOString(), keyId);
82
83
  }
84
+ /** EI2: grant `keyId` read access to one restricted `scope`. Idempotent. */
85
+ export function grantScope(db, keyId, scope) {
86
+ db.prepare(`INSERT INTO api_key_scope_grants (key_id, scope, granted_at) VALUES (?, ?, ?)
87
+ ON CONFLICT(key_id, scope) DO NOTHING`).run(keyId, scope, new Date().toISOString());
88
+ }
89
+ /** EI2: revoke `keyId`'s grant on `scope`. Not an error when no such grant exists. */
90
+ export function ungrantScope(db, keyId, scope) {
91
+ db.prepare(`DELETE FROM api_key_scope_grants WHERE key_id = ? AND scope = ?`).run(keyId, scope);
92
+ }
93
+ /** EI2: every restricted scope `keyId` may read. */
94
+ export function listScopeGrants(db, keyId) {
95
+ // SAFETY: rows' shape matches the single `scope` column named in the SELECT above.
96
+ const rows = db
97
+ .prepare(`SELECT scope FROM api_key_scope_grants WHERE key_id = ? ORDER BY scope`)
98
+ .all(keyId);
99
+ return rows.map((r) => r.scope);
100
+ }
83
101
  export function listApiKeys(db, opts) {
84
102
  const sql = opts.active
85
103
  ? `SELECT key_id, tenant_id, label, created_at, revoked_at, role FROM api_keys WHERE revoked_at IS NULL ORDER BY id DESC`
@@ -92,6 +110,7 @@ export function listApiKeys(db, opts) {
92
110
  keyId: r.key_id, tenantId: r.tenant_id, label: r.label,
93
111
  createdAt: r.created_at, revokedAt: r.revoked_at,
94
112
  role: r.role === 'admin' ? 'admin' : 'member',
113
+ scopes: listScopeGrants(db, r.key_id),
95
114
  }));
96
115
  }
97
116
  //# sourceMappingURL=auth.js.map
package/dist/cli.js CHANGED
@@ -852,6 +852,7 @@ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
852
852
  source: old.source,
853
853
  confidence: 'verified',
854
854
  tenantId: old.tenantId,
855
+ scope: old.scope,
855
856
  });
856
857
  // AT1: write the SUCCESSOR first. The rejection guard fires on the new
857
858
  // content — if it refuses, nothing has been mutated yet (the old ordering
@@ -7885,6 +7886,41 @@ function cmdAuthRevoke(hippoRoot, keyId, flags) {
7885
7886
  }
7886
7887
  console.log(`Revoked ${keyId} at ${revokedAt}`);
7887
7888
  }
7889
+ /** EI2: `hippo auth grant|ungrant <key_id> <scope>`, routed through api so the tenant, restricted-scope and audit checks live in one place. */
7890
+ function cmdAuthScopeGrant(hippoRoot, keyId, scope, grant, flags) {
7891
+ const root = resolveAuthRoot(hippoRoot, flags);
7892
+ // The local CLI owns every tenant (as auth revoke does), so the grant runs in the key's own tenant.
7893
+ const db = openHippoDb(root);
7894
+ let keyTenant;
7895
+ try {
7896
+ // SAFETY: row's shape matches the single tenant_id column in the SELECT.
7897
+ const row = db.prepare(`SELECT tenant_id FROM api_keys WHERE key_id = ?`).get(keyId);
7898
+ keyTenant = row?.tenant_id;
7899
+ }
7900
+ finally {
7901
+ closeHippoDb(db);
7902
+ }
7903
+ if (keyTenant === undefined) {
7904
+ console.error(`Unknown key_id: ${keyId}`);
7905
+ process.exit(1);
7906
+ }
7907
+ const ctx = { hippoRoot: root, tenantId: keyTenant, actor: api.adminActor('cli') };
7908
+ try {
7909
+ if (grant)
7910
+ api.authGrant(ctx, keyId, scope);
7911
+ else
7912
+ api.authUngrant(ctx, keyId, scope);
7913
+ }
7914
+ catch (err) {
7915
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
7916
+ process.exit(1);
7917
+ }
7918
+ if (flags['json']) {
7919
+ console.log(JSON.stringify({ keyId, scope, granted: grant }));
7920
+ return;
7921
+ }
7922
+ console.log(grant ? `Granted ${keyId} read access to ${scope}` : `Removed ${keyId}'s grant on ${scope}`);
7923
+ }
7888
7924
  // ---------------------------------------------------------------------------
7889
7925
  // Audit log subcommands (A5 stub auth — `hippo audit list`)
7890
7926
  // ---------------------------------------------------------------------------
@@ -7941,6 +7977,8 @@ const VALID_AUDIT_OPS = new Set([
7941
7977
  'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
7942
7978
  'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
7943
7979
  'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7980
+ 'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7981
+ 'auth_ungrant', // EI2: emitted by api.authUngrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7944
7982
  ]);
7945
7983
  function formatAuditRow(ev) {
7946
7984
  const target = ev.targetId ?? '-';
@@ -8264,7 +8302,7 @@ function cmdGoal(hippoRoot, args, flags) {
8264
8302
  function cmdAuth(hippoRoot, args, flags) {
8265
8303
  const sub = args[0];
8266
8304
  if (!sub) {
8267
- console.error('Usage: hippo auth <create|list|revoke> [options]');
8305
+ console.error('Usage: hippo auth <create|list|revoke|grant|ungrant> [options]');
8268
8306
  process.exit(1);
8269
8307
  }
8270
8308
  const subArgs = args.slice(1);
@@ -8284,8 +8322,18 @@ function cmdAuth(hippoRoot, args, flags) {
8284
8322
  cmdAuthRevoke(hippoRoot, keyId, flags);
8285
8323
  return;
8286
8324
  }
8325
+ case 'grant':
8326
+ case 'ungrant': {
8327
+ const [keyId, scope] = subArgs;
8328
+ if (!keyId || !scope) {
8329
+ console.error(`Usage: hippo auth ${sub} <key_id> <scope>`);
8330
+ process.exit(1);
8331
+ }
8332
+ cmdAuthScopeGrant(hippoRoot, keyId, scope, sub === 'grant', flags);
8333
+ return;
8334
+ }
8287
8335
  default:
8288
- console.error(`Unknown auth subcommand: ${sub}. Expected: create | list | revoke.`);
8336
+ console.error(`Unknown auth subcommand: ${sub}. Expected: create | list | revoke | grant | ungrant.`);
8289
8337
  process.exit(1);
8290
8338
  }
8291
8339
  }
@@ -8966,6 +9014,12 @@ Commands:
8966
9014
  auth revoke <key_id> Revoke an API key (subsequent validate fails)
8967
9015
  --json Output as JSON
8968
9016
  --global Operate on the global store
9017
+ auth grant <key_id> <scope> Let a member key read one restricted scope
9018
+ --json Output as JSON
9019
+ --global Operate on the global store
9020
+ auth ungrant <key_id> <scope> Remove a scope grant
9021
+ --json Output as JSON
9022
+ --global Operate on the global store
8969
9023
  audit <sub> Query the append-only audit log (A5 stub auth)
8970
9024
  audit list List audit events for the active tenant
8971
9025
  --op <op> Filter by op (remember | recall | promote |
@@ -19,6 +19,8 @@ export interface ConsolidationResult {
19
19
  semanticCreated: number;
20
20
  replayed: number;
21
21
  promotedTraces: number;
22
+ /** T7: sessions skipped because their events span two derivation scopes. */
23
+ tracesSkippedMixedScope: number;
22
24
  extractionCandidates: number;
23
25
  extracted: number;
24
26
  dagCandidateClusters: number;
@@ -25,6 +25,7 @@ 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';
28
29
  const DECAY_THRESHOLD = 0.05;
29
30
  const MERGE_OVERLAP_THRESHOLD = 0.35; // Jaccard similarity for "related"
30
31
  const MERGE_MIN_CLUSTER = 2; // minimum cluster size to merge
@@ -108,6 +109,7 @@ export async function consolidate(hippoRoot, options = {}) {
108
109
  semanticCreated: 0,
109
110
  replayed: 0,
110
111
  promotedTraces: 0,
112
+ tracesSkippedMixedScope: 0,
111
113
  extractionCandidates: 0,
112
114
  extracted: 0,
113
115
  dagCandidateClusters: 0,
@@ -368,6 +370,13 @@ export async function consolidate(hippoRoot, options = {}) {
368
370
  session_id: session.session_id,
369
371
  limit: 1000,
370
372
  });
373
+ // T7: a mixed-scope session would otherwise leak into one trace.
374
+ const sessionScope = commonDerivationScope(events.map((e) => e.scope));
375
+ if (!sessionScope.ok) {
376
+ result.tracesSkippedMixedScope++;
377
+ result.details.push(` ⏭ skipped session ${session.session_id}: events span mixed scopes`);
378
+ continue;
379
+ }
371
380
  const completeEvent = events.find((e) => e.event_type === 'session_complete');
372
381
  if (!completeEvent)
373
382
  continue; // defence-in-depth; findPromotableSessions filters already.
@@ -391,6 +400,7 @@ export async function consolidate(hippoRoot, options = {}) {
391
400
  source_session_id: session.session_id,
392
401
  tags: ['auto-promoted'],
393
402
  source: 'auto-promote',
403
+ scope: sessionScope.scope,
394
404
  // T1 fix (2026-08-15 hardening pass): stamp the trace into the SAME
395
405
  // tenant the traceExistsForSession idempotency check (above) runs
396
406
  // under. Before
@@ -671,11 +681,12 @@ export async function consolidate(hippoRoot, options = {}) {
671
681
  // before this fix — byte-identical behavior there.
672
682
  const mergeCandidatesByTenant = new Map();
673
683
  for (const entry of mergeCandidates) {
674
- const bucket = mergeCandidatesByTenant.get(entry.tenantId);
684
+ const key = derivationPartitionKey(entry.tenantId, entry.scope);
685
+ const bucket = mergeCandidatesByTenant.get(key);
675
686
  if (bucket)
676
687
  bucket.push(entry);
677
688
  else
678
- mergeCandidatesByTenant.set(entry.tenantId, [entry]);
689
+ mergeCandidatesByTenant.set(key, [entry]);
679
690
  }
680
691
  // AT1 consolidation-loop fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
681
692
  // reuses the single consolidateDb handle opened once above (before the
@@ -686,7 +697,9 @@ export async function consolidate(hippoRoot, options = {}) {
686
697
  // declared up at the try's opening above 1.4, not here — it has to
687
698
  // survive the try/finally that now wraps this whole section; see the
688
699
  // handle-leak restructure comment there.)
689
- for (const [mergeTenant, tenantCandidates] of mergeCandidatesByTenant) {
700
+ for (const [, tenantCandidates] of mergeCandidatesByTenant) {
701
+ const mergeTenant = tenantCandidates[0].tenantId;
702
+ const mergeScope = derivationScope(tenantCandidates[0].scope);
690
703
  for (let i = 0; i < tenantCandidates.length; i++) {
691
704
  if (used.has(tenantCandidates[i].id))
692
705
  continue;
@@ -722,6 +735,7 @@ export async function consolidate(hippoRoot, options = {}) {
722
735
  source: 'consolidation',
723
736
  confidence: 'inferred',
724
737
  tenantId: mergeTenant,
738
+ scope: mergeScope,
725
739
  });
726
740
  }
727
741
  // mergeContents is DETERMINISTIC CONCATENATION (not an LLM paraphrase)
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 = 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))
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
@@ -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
@@ -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
  /**
package/dist/server.js CHANGED
@@ -116,6 +116,8 @@ const VALID_AUDIT_OPS = new Set([
116
116
  'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
117
117
  'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
118
118
  'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
119
+ 'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
120
+ 'auth_ungrant', // EI2: emitted by api.authUngrant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
119
121
  ]);
120
122
  // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
121
123
  // enough to dump a small deployment's full audit log without paginating, but
@@ -469,6 +471,7 @@ function buildContextWithAuth(req, hippoRoot) {
469
471
  actor: {
470
472
  subject: `api_key:${result.keyId}`,
471
473
  role: result.role,
474
+ scopes: result.scopes,
472
475
  },
473
476
  };
474
477
  }
@@ -1191,16 +1194,13 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1191
1194
  limit = parsed;
1192
1195
  }
1193
1196
  const ctx = buildContextWithAuth(req, opts.hippoRoot);
1194
- // D2 v1.12.10: optional ?tenant=<t> param. Defaults to ctx.tenantId
1195
- // (caller's own tenant). Lets admins query the '__host__' synthetic
1196
- // tenant where host-wide ops like 'consolidate' are recorded. Today
1197
- // the audit route inherits the auth gate on /v1/audit (Bearer auth);
1198
- // a future per-tenant authz check should ensure non-admin Bearers
1199
- // can only pass their own tenant_id here.
1197
+ // ?tenant=<t> reads another tenant (e.g. '__host__' for consolidate rows); admin only.
1200
1198
  const tenantOverride = query.get('tenant');
1201
- const effectiveCtx = tenantOverride !== null && tenantOverride !== ''
1202
- ? { ...ctx, tenantId: tenantOverride }
1203
- : ctx;
1199
+ const crossTenant = tenantOverride !== null && tenantOverride !== '' && tenantOverride !== ctx.tenantId;
1200
+ if (crossTenant && ctx.actor.role !== 'admin') {
1201
+ throw new HttpError(403, '/v1/audit?tenant= for another tenant requires admin role');
1202
+ }
1203
+ const effectiveCtx = crossTenant ? { ...ctx, tenantId: tenantOverride } : ctx;
1204
1204
  const result = auditList(effectiveCtx, { op, since, limit });
1205
1205
  sendJson(res, 200, result);
1206
1206
  return;
@@ -3021,6 +3021,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
3021
3021
  actor: ctx.actor.subject,
3022
3022
  // The caller's real role: MCP tools must not run a member key as admin.
3023
3023
  role: ctx.actor.role,
3024
+ scopes: ctx.actor.scopes,
3024
3025
  clientKey: buildMcpClientKey(req),
3025
3026
  });
3026
3027
  }
package/dist/version.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export declare const PACKAGE_VERSION = "1.48.0";
19
+ export declare const PACKAGE_VERSION = "1.49.0";
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export declare function compareSemver(a: string, b: string): number;
22
22
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.48.0';
19
+ export const PACKAGE_VERSION = '1.49.0';
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export function compareSemver(a, b) {
22
22
  const parse = (v) => {
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.48.0",
5
+ "version": "1.49.0",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "type": "module",
5
5
  "description": "Hippo Memory plugin for OpenClaw - biologically-inspired agent memory",
6
6
  "main": "index.ts",
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.48.0",
5
+ "version": "1.49.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "description": "Biologically-inspired memory for AI agents. Zero runtime deps, SQLite, MCP server, and an opt-in hosted TypeSafe Jev reranker. Decay, retrieval strengthening, consolidation.",
5
5
  "mcpName": "io.github.kitfunso/hippo-memory",
6
6
  "type": "module",