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.
package/dist/api.d.ts CHANGED
@@ -11,6 +11,7 @@ import { deleteEntry, loadAllEntries, type TaskSnapshot, type SessionEvent } fro
11
11
  import { type RejectedValueRow } from './rejection.js';
12
12
  import { type DormantMemory, type ListDormantOpts } from './dormant.js';
13
13
  import { type TokenSummary, type TokenSurface } from './token-ledger.js';
14
+ import { type QuarantineStatus } from './quarantine.js';
14
15
  import { type FailureSummary } from './failure-log.js';
15
16
  import { type SessionHandoff } from './handoff.js';
16
17
  import { type MemoryKind, type MemoryEntry } from './memory.js';
@@ -40,6 +41,8 @@ export interface Actor {
40
41
  /** 'cli' | 'localhost:cli' | 'api_key:<key_id>' | 'mcp' | 'connector:slack' | 'connector:github' */
41
42
  subject: string;
42
43
  role: 'admin' | 'member';
44
+ /** EI2: restricted scopes a member key may read (auth.ts grantScope). Unused for admin actors. */
45
+ scopes?: readonly string[];
43
46
  }
44
47
  export interface Context {
45
48
  hippoRoot: string;
@@ -108,11 +111,17 @@ export interface RememberOpts {
108
111
  * back and the error is rethrown.
109
112
  */
110
113
  afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void;
114
+ /** CD5: connector-ingested content an agent doesn't control; gates detectInstruction. CLI/HTTP/MCP never set this. */
115
+ untrusted?: boolean;
111
116
  }
112
117
  export interface RememberResult {
113
118
  id: string;
114
119
  kind: MemoryKind;
115
120
  tenantId: string;
121
+ /** Set only when untrusted content was flagged and quarantined instead of stored under its requested scope. */
122
+ quarantined?: {
123
+ reason: string;
124
+ };
116
125
  }
117
126
  export declare function remember(ctx: Context, opts: RememberOpts): RememberResult;
118
127
  export interface RecallOpts {
@@ -875,6 +884,14 @@ export interface AuthRevokeResult {
875
884
  revokedAt: string;
876
885
  }
877
886
  export declare function authRevoke(ctx: Context, keyId: string): AuthRevokeResult;
887
+ /** Shared result shape for authGrant/authUngrant, named per the file's oxlint anti-slop rule. */
888
+ export interface AuthGrantResult {
889
+ ok: true;
890
+ }
891
+ /** Grant `keyId` read access to one restricted `scope` (ROADMAP Part VIII EI2). Admin only. */
892
+ export declare function authGrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
893
+ /** Revoke `keyId`'s grant on `scope`. Same authorization and lookup rules as authGrant. */
894
+ export declare function authUngrant(ctx: Context, keyId: string, scope: string): AuthGrantResult;
878
895
  export interface AuditListOpts {
879
896
  op?: AuditOp;
880
897
  /** ISO timestamp lower bound. */
@@ -1051,6 +1068,25 @@ export declare function restoreDormant(ctx: Context, id: string): MemoryEntry;
1051
1068
  export declare function forgetDormant(ctx: Context, id: string): void;
1052
1069
  /** Whether the tenant holds a dormant memory with this id (for "not found" hints). */
1053
1070
  export declare function isDormant(ctx: Context, id: string): boolean;
1071
+ export interface QuarantineListItem {
1072
+ id: string;
1073
+ originalScope: string | null;
1074
+ reason: string;
1075
+ status: QuarantineStatus;
1076
+ quarantinedAt: string;
1077
+ decidedAt: string | null;
1078
+ decidedBy: string | null;
1079
+ contentPreview: string;
1080
+ }
1081
+ /** A tenant's quarantined memories, newest first. Default `status` is 'pending' (the review queue). */
1082
+ export declare function quarantineList(ctx: Context, opts?: {
1083
+ status?: QuarantineStatus | 'all';
1084
+ limit?: number;
1085
+ }): QuarantineListItem[];
1086
+ /** Release a quarantined memory to its original scope. Admin only; the scope guard refuses a row moved since (mirrors restoreDormant). */
1087
+ export declare function quarantineApprove(ctx: Context, id: string): void;
1088
+ /** Keep a quarantined memory hidden for good. Admin only; the raw row is untouched (append-only). */
1089
+ export declare function quarantineReject(ctx: Context, id: string): void;
1054
1090
  export interface SleepResult {
1055
1091
  active: number;
1056
1092
  removed: number;
package/dist/api.js CHANGED
@@ -13,6 +13,8 @@ import { RejectedValueError } from './rejection.js';
13
13
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
14
14
  import { listDormantRows, readDormantSnapshot, deleteDormantRow, hasDormantRow, } from './dormant.js';
15
15
  import { recordTokenUse, summarizeTokenUse } from './token-ledger.js';
16
+ import { detectInstruction } from './instruction-detect.js';
17
+ import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
16
18
  import { summarizeFailures } from './failure-log.js';
17
19
  import { formatHandoffEvidenceLine } from './handoff.js';
18
20
  import { createMemory, applyOutcome, calculateStrength, Layer, } from './memory.js';
@@ -21,7 +23,7 @@ import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './s
21
23
  import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
22
24
  import { evalNow } from './ablation.js';
23
25
  import { archiveRawMemory } from './raw-archive.js';
24
- import { createApiKey, listApiKeys, revokeApiKey, } from './auth.js';
26
+ import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
25
27
  import { applyGoalStackBoost } from './goals.js';
26
28
  import { markRetrieved, estimateTokens, hybridSearch, physicsSearch } from './search.js';
27
29
  import { compareEntryIdentity, compareScoredResults } from './compare.js';
@@ -87,7 +89,7 @@ export class ForbiddenError extends Error {
87
89
  // back-compat (`api.isPrivateScope`, test imports). NOTE: the import statement
88
90
  // is required — a bare `export { x } from` re-export does not bind the local
89
91
  // names this module's ~9 call sites use.
90
- import { isPrivateScope, passesScopeFilterForRecall, assertScopeRequestAllowed } from './recall-scope.js';
92
+ import { isPrivateScope, passesScopeFilterForRecall, assertScopeRequestAllowed, isRestrictedScope } from './recall-scope.js';
91
93
  export { isPrivateScope, passesScopeFilterForRecall };
92
94
  export { passesCliRecallScopeFilter, ScopeForbiddenError } from './recall-scope.js';
93
95
  // v39: classifyOriginProject lives in project-identity.ts (leaf) so
@@ -140,9 +142,11 @@ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admi
140
142
  return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient);
141
143
  }
142
144
  export function remember(ctx, opts) {
145
+ const detection = opts.untrusted ? detectInstruction(opts.content) : { flagged: false, reason: null };
146
+ const requestedScope = opts.scope ?? null;
143
147
  const entry = createMemory(opts.content, {
144
148
  kind: opts.kind ?? 'distilled',
145
- scope: opts.scope ?? null,
149
+ scope: detection.flagged ? quarantineScopeFor(requestedScope) : requestedScope,
146
150
  owner: opts.owner ?? null,
147
151
  artifact_ref: opts.artifactRef ?? null,
148
152
  tags: opts.tags,
@@ -151,8 +155,23 @@ export function remember(ctx, opts) {
151
155
  });
152
156
  // writeEntry threads ctx.actor.subject into its internal audit hook, so exactly
153
157
  // one 'remember' event lands in the log with the supplied actor.
154
- writeEntry(ctx.hippoRoot, entry, { actor: ctx.actor.subject, afterWrite: opts.afterWrite });
155
- return { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
158
+ const afterWrite = detection.flagged
159
+ ? (db, memoryId) => {
160
+ recordQuarantine(db, {
161
+ tenantId: ctx.tenantId,
162
+ memoryId,
163
+ originalScope: requestedScope,
164
+ reason: detection.reason ?? 'unknown',
165
+ actor: ctx.actor.subject,
166
+ });
167
+ opts.afterWrite?.(db, memoryId);
168
+ }
169
+ : opts.afterWrite;
170
+ writeEntry(ctx.hippoRoot, entry, { actor: ctx.actor.subject, afterWrite });
171
+ const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
172
+ if (detection.flagged)
173
+ result.quarantined = { reason: detection.reason ?? 'unknown' };
174
+ return result;
156
175
  }
157
176
  /**
158
177
  * Shared construction helper for `RecallSuppressionSummary`. Used by
@@ -188,13 +207,13 @@ export function buildSuppressionSummary(counts) {
188
207
  */
189
208
  export function recall(ctx, opts) {
190
209
  // A member key may not unlock a private or quarantined scope by naming it.
191
- assertScopeRequestAllowed(ctx.actor.role, opts.scope);
210
+ assertScopeRequestAllowed(ctx.actor, opts.scope);
192
211
  const windowSize = recallWindowSize(opts);
193
212
  return recallFrom(ctx, opts, windowSize, loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false));
194
213
  }
195
214
  /** Mode-aware recall that strengthens each returned row; never writes last_retrieval_ids (v1.11.5 lock). */
196
215
  export async function retrieve(ctx, opts) {
197
- assertScopeRequestAllowed(ctx.actor.role, opts.scope);
216
+ assertScopeRequestAllowed(ctx.actor, opts.scope);
198
217
  const windowSize = recallWindowSize(opts);
199
218
  let candidates = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false);
200
219
  if (opts.mode === 'hybrid' || opts.mode === 'physics') {
@@ -288,7 +307,7 @@ function recallFrom(ctx, opts, windowSize, all) {
288
307
  // anchored `<source>:private:*` rule (v1.2.1 generalization) and
289
308
  // defense-in-depth: connector authors cannot silently surface private
290
309
  // rows to no-scope callers even if the SQL clause regresses.
291
- entries = current.filter((e) => !isPrivateScope(e.scope ?? null));
310
+ entries = current.filter((e) => !isRestrictedScope(e.scope ?? null));
292
311
  }
293
312
  // v1.12.13 / C5 — WYSIATI dropped_pre_rank counter (JS scope filter drops
294
313
  // for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
@@ -725,7 +744,7 @@ function recallFrom(ctx, opts, windowSize, all) {
725
744
  * - all rows fail the scope/tenant filter
726
745
  */
727
746
  export function assemble(ctx, sessionId, opts = {}) {
728
- assertScopeRequestAllowed(ctx.actor.role, opts.scope);
747
+ assertScopeRequestAllowed(ctx.actor, opts.scope);
729
748
  const budget = opts.budget ?? 4000;
730
749
  const freshTailCount = opts.freshTailCount ?? 10;
731
750
  const summarizeOlder = opts.summarizeOlder ?? true;
@@ -1152,6 +1171,7 @@ export function supersede(ctx, oldId, newContent) {
1152
1171
  source: old.source,
1153
1172
  confidence: 'verified',
1154
1173
  tenantId: ctx.tenantId,
1174
+ scope: old.scope,
1155
1175
  });
1156
1176
  // Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
1157
1177
  // three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row)
@@ -1401,6 +1421,50 @@ export function authRevoke(ctx, keyId) {
1401
1421
  closeHippoDb(db);
1402
1422
  }
1403
1423
  }
1424
+ /** Grant `keyId` read access to one restricted `scope` (ROADMAP Part VIII EI2). Admin only. */
1425
+ export function authGrant(ctx, keyId, scope) {
1426
+ return changeScopeGrant(ctx, keyId, scope, 'auth_grant');
1427
+ }
1428
+ /** Revoke `keyId`'s grant on `scope`. Same authorization and lookup rules as authGrant. */
1429
+ export function authUngrant(ctx, keyId, scope) {
1430
+ return changeScopeGrant(ctx, keyId, scope, 'auth_ungrant');
1431
+ }
1432
+ function changeScopeGrant(ctx, keyId, scope, op) {
1433
+ if (ctx.actor.role !== 'admin') {
1434
+ throw new ForbiddenError('Only an admin key can change scope grants');
1435
+ }
1436
+ const db = openHippoDb(ctx.hippoRoot);
1437
+ try {
1438
+ // SAFETY: row's shape matches the single tenant_id column in the SELECT.
1439
+ const row = db
1440
+ .prepare(`SELECT tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
1441
+ .get(keyId);
1442
+ if (!row || row.tenant_id !== ctx.tenantId) {
1443
+ throw new Error(`Unknown key_id: ${keyId}`);
1444
+ }
1445
+ if (op === 'auth_grant' && row.revoked_at) {
1446
+ throw new Error(`${keyId} is revoked; a grant on it would never apply`);
1447
+ }
1448
+ if (!isRestrictedScope(scope)) {
1449
+ throw new Error(`${scope} is not a restricted scope; it is already readable by default`);
1450
+ }
1451
+ if (op === 'auth_grant')
1452
+ grantScope(db, keyId, scope);
1453
+ else
1454
+ ungrantScope(db, keyId, scope);
1455
+ try {
1456
+ appendAuditEvent(db, { tenantId: ctx.tenantId, actor: ctx.actor.subject, op, targetId: keyId, metadata: { scope } });
1457
+ }
1458
+ catch (err) {
1459
+ // Audit must not undo a grant change that already committed; surface it instead.
1460
+ console.error(`auth: audit write failed for ${op} ${keyId}: ${err instanceof Error ? err.message : String(err)}`);
1461
+ }
1462
+ return { ok: true };
1463
+ }
1464
+ finally {
1465
+ closeHippoDb(db);
1466
+ }
1467
+ }
1404
1468
  /**
1405
1469
  * Read audit events scoped to `ctx.tenantId`. Read-only — no audit emit (matches
1406
1470
  * A5: cmdAuditList does not record a 'recall'-style read event).
@@ -2045,6 +2109,118 @@ export function isDormant(ctx, id) {
2045
2109
  closeHippoDb(db);
2046
2110
  }
2047
2111
  }
2112
+ const QUARANTINE_PREVIEW_CHARS = 200;
2113
+ /** A tenant's quarantined memories, newest first. Default `status` is 'pending' (the review queue). */
2114
+ export function quarantineList(ctx, opts = {}) {
2115
+ const db = openHippoDb(ctx.hippoRoot);
2116
+ try {
2117
+ const rows = listQuarantineRows(db, ctx.tenantId, opts.status ?? 'pending', opts.limit);
2118
+ return rows.map((row) => {
2119
+ const entry = readEntry(ctx.hippoRoot, row.memoryId, ctx.tenantId);
2120
+ return {
2121
+ id: row.memoryId,
2122
+ originalScope: row.originalScope,
2123
+ reason: row.reason,
2124
+ status: row.status,
2125
+ quarantinedAt: row.quarantinedAt,
2126
+ decidedAt: row.decidedAt,
2127
+ decidedBy: row.decidedBy,
2128
+ contentPreview: entry ? entry.content.slice(0, QUARANTINE_PREVIEW_CHARS) : '',
2129
+ };
2130
+ });
2131
+ }
2132
+ finally {
2133
+ closeHippoDb(db);
2134
+ }
2135
+ }
2136
+ function loadPendingQuarantineRow(db, tenantId, id) {
2137
+ const row = getQuarantineRow(db, tenantId, id);
2138
+ if (!row)
2139
+ throw new Error(`not quarantined: ${id}`);
2140
+ if (row.status !== 'pending')
2141
+ throw new Error(`${id} is already ${row.status}`);
2142
+ return row;
2143
+ }
2144
+ /** Release a quarantined memory to its original scope. Admin only; the scope guard refuses a row moved since (mirrors restoreDormant). */
2145
+ export function quarantineApprove(ctx, id) {
2146
+ if (ctx.actor.role !== 'admin') {
2147
+ throw new ForbiddenError('Only an admin key can approve a quarantined memory');
2148
+ }
2149
+ const db = openHippoDb(ctx.hippoRoot);
2150
+ try {
2151
+ db.exec('BEGIN IMMEDIATE');
2152
+ try {
2153
+ const row = loadPendingQuarantineRow(db, ctx.tenantId, id);
2154
+ const quarantineScope = quarantineScopeFor(row.originalScope);
2155
+ const updated = db
2156
+ .prepare(`UPDATE memories SET scope = ? WHERE id = ? AND tenant_id = ? AND scope = ?`)
2157
+ .run(row.originalScope, id, ctx.tenantId, quarantineScope);
2158
+ if (Number(updated.changes ?? 0) !== 1) {
2159
+ throw new Error(`memory ${id} scope changed since quarantine; refusing to approve`);
2160
+ }
2161
+ approveQuarantineRow(db, ctx.tenantId, id, ctx.actor.subject);
2162
+ appendAuditEvent(db, {
2163
+ tenantId: ctx.tenantId,
2164
+ actor: ctx.actor.subject,
2165
+ op: 'quarantine_approve',
2166
+ targetId: id,
2167
+ metadata: { originalScope: row.originalScope },
2168
+ });
2169
+ db.exec('COMMIT');
2170
+ }
2171
+ catch (err) {
2172
+ try {
2173
+ db.exec('ROLLBACK');
2174
+ }
2175
+ catch { /* already rolled back */ }
2176
+ throw err;
2177
+ }
2178
+ }
2179
+ finally {
2180
+ closeHippoDb(db);
2181
+ }
2182
+ // Post-commit, best-effort: a failed rewrite leaves the mirror showing the quarantine scope (fail-closed).
2183
+ try {
2184
+ const restored = readEntry(ctx.hippoRoot, id, ctx.tenantId);
2185
+ if (restored)
2186
+ writeEntryMirrors(ctx.hippoRoot, restored);
2187
+ }
2188
+ catch (err) {
2189
+ console.error(`quarantine: mirror rewrite failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
2190
+ }
2191
+ }
2192
+ /** Keep a quarantined memory hidden for good. Admin only; the raw row is untouched (append-only). */
2193
+ export function quarantineReject(ctx, id) {
2194
+ if (ctx.actor.role !== 'admin') {
2195
+ throw new ForbiddenError('Only an admin key can reject a quarantined memory');
2196
+ }
2197
+ const db = openHippoDb(ctx.hippoRoot);
2198
+ try {
2199
+ db.exec('BEGIN IMMEDIATE');
2200
+ try {
2201
+ loadPendingQuarantineRow(db, ctx.tenantId, id);
2202
+ rejectQuarantineRow(db, ctx.tenantId, id, ctx.actor.subject);
2203
+ appendAuditEvent(db, {
2204
+ tenantId: ctx.tenantId,
2205
+ actor: ctx.actor.subject,
2206
+ op: 'quarantine_reject',
2207
+ targetId: id,
2208
+ metadata: {},
2209
+ });
2210
+ db.exec('COMMIT');
2211
+ }
2212
+ catch (err) {
2213
+ try {
2214
+ db.exec('ROLLBACK');
2215
+ }
2216
+ catch { /* already rolled back */ }
2217
+ throw err;
2218
+ }
2219
+ }
2220
+ finally {
2221
+ closeHippoDb(db);
2222
+ }
2223
+ }
2048
2224
  const DEFAULT_SLEEP_PHASES = {
2049
2225
  consolidate,
2050
2226
  deduplicateStore,
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' | 'quarantine' | 'quarantine_approve' | 'quarantine_reject';
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
@@ -3896,6 +3897,56 @@ function cmdDormant(hippoRoot, args, flags) {
3896
3897
  }
3897
3898
  console.log('Bring one back: hippo dormant restore <id> Delete for good: hippo dormant forget <id>');
3898
3899
  }
3900
+ /** `hippo quarantine [list] [--all] [--json] [--global]`, `quarantine approve <id>`, `quarantine reject <id>` (CD5 poisoning defence). */
3901
+ function cmdQuarantine(hippoRoot, args, flags) {
3902
+ const root = resolveAuthRoot(hippoRoot, flags);
3903
+ const ctx = {
3904
+ hippoRoot: root,
3905
+ tenantId: resolveTenantId({}),
3906
+ actor: api.adminActor('cli'),
3907
+ };
3908
+ const sub = args[0];
3909
+ if (sub === 'approve' || sub === 'reject') {
3910
+ const id = (args[1] ?? '').trim();
3911
+ if (!id) {
3912
+ console.error(`Usage: hippo quarantine ${sub} <id>`);
3913
+ process.exit(1);
3914
+ }
3915
+ try {
3916
+ if (sub === 'approve') {
3917
+ api.quarantineApprove(ctx, id);
3918
+ console.log(`Approved ${id}: restored to its original scope.`);
3919
+ }
3920
+ else {
3921
+ api.quarantineReject(ctx, id);
3922
+ console.log(`Rejected ${id}: stays quarantined.`);
3923
+ }
3924
+ }
3925
+ catch (err) {
3926
+ console.error(`Could not ${sub} ${id}: ${err instanceof Error ? err.message : String(err)}`);
3927
+ process.exit(1);
3928
+ }
3929
+ return;
3930
+ }
3931
+ const status = flags['all'] ? 'all' : 'pending';
3932
+ const rows = api.quarantineList(ctx, { status });
3933
+ if (flags['json']) {
3934
+ console.log(JSON.stringify({ quarantine: rows }, null, 2));
3935
+ return;
3936
+ }
3937
+ if (rows.length === 0) {
3938
+ console.log(status === 'all' ? 'No quarantined memories.' : 'No pending quarantined memories.');
3939
+ return;
3940
+ }
3941
+ console.log(`${rows.length} quarantined memor${rows.length === 1 ? 'y' : 'ies'} (newest first):\n`);
3942
+ for (const row of rows) {
3943
+ console.log(`--- ${row.id} [${row.status}]`);
3944
+ console.log(` ${row.contentPreview}`);
3945
+ console.log(` ${row.reason}, original scope ${row.originalScope ?? '(none)'}, quarantined ${row.quarantinedAt.slice(0, 10)}`);
3946
+ console.log('');
3947
+ }
3948
+ console.log('Approve: hippo quarantine approve <id> Reject: hippo quarantine reject <id>');
3949
+ }
3899
3950
  /**
3900
3951
  * `hippo tokens [--days <n>] [--json] [--global]`: the token ledger
3901
3952
  * (ROADMAP TE0). Tokens of memory text handed to agents per surface, blocks
@@ -7885,6 +7936,41 @@ function cmdAuthRevoke(hippoRoot, keyId, flags) {
7885
7936
  }
7886
7937
  console.log(`Revoked ${keyId} at ${revokedAt}`);
7887
7938
  }
7939
+ /** EI2: `hippo auth grant|ungrant <key_id> <scope>`, routed through api so the tenant, restricted-scope and audit checks live in one place. */
7940
+ function cmdAuthScopeGrant(hippoRoot, keyId, scope, grant, flags) {
7941
+ const root = resolveAuthRoot(hippoRoot, flags);
7942
+ // The local CLI owns every tenant (as auth revoke does), so the grant runs in the key's own tenant.
7943
+ const db = openHippoDb(root);
7944
+ let keyTenant;
7945
+ try {
7946
+ // SAFETY: row's shape matches the single tenant_id column in the SELECT.
7947
+ const row = db.prepare(`SELECT tenant_id FROM api_keys WHERE key_id = ?`).get(keyId);
7948
+ keyTenant = row?.tenant_id;
7949
+ }
7950
+ finally {
7951
+ closeHippoDb(db);
7952
+ }
7953
+ if (keyTenant === undefined) {
7954
+ console.error(`Unknown key_id: ${keyId}`);
7955
+ process.exit(1);
7956
+ }
7957
+ const ctx = { hippoRoot: root, tenantId: keyTenant, actor: api.adminActor('cli') };
7958
+ try {
7959
+ if (grant)
7960
+ api.authGrant(ctx, keyId, scope);
7961
+ else
7962
+ api.authUngrant(ctx, keyId, scope);
7963
+ }
7964
+ catch (err) {
7965
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
7966
+ process.exit(1);
7967
+ }
7968
+ if (flags['json']) {
7969
+ console.log(JSON.stringify({ keyId, scope, granted: grant }));
7970
+ return;
7971
+ }
7972
+ console.log(grant ? `Granted ${keyId} read access to ${scope}` : `Removed ${keyId}'s grant on ${scope}`);
7973
+ }
7888
7974
  // ---------------------------------------------------------------------------
7889
7975
  // Audit log subcommands (A5 stub auth — `hippo audit list`)
7890
7976
  // ---------------------------------------------------------------------------
@@ -7941,6 +8027,11 @@ const VALID_AUDIT_OPS = new Set([
7941
8027
  'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
7942
8028
  'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
7943
8029
  'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8030
+ 'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8031
+ 'auth_ungrant', // EI2: emitted by api.authUngrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8032
+ 'quarantine', // CD5: emitted by recordQuarantine; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
8033
+ 'quarantine_approve', // CD5: emitted by api.quarantineApprove; lockstep
8034
+ 'quarantine_reject', // CD5: emitted by api.quarantineReject; lockstep
7944
8035
  ]);
7945
8036
  function formatAuditRow(ev) {
7946
8037
  const target = ev.targetId ?? '-';
@@ -8264,7 +8355,7 @@ function cmdGoal(hippoRoot, args, flags) {
8264
8355
  function cmdAuth(hippoRoot, args, flags) {
8265
8356
  const sub = args[0];
8266
8357
  if (!sub) {
8267
- console.error('Usage: hippo auth <create|list|revoke> [options]');
8358
+ console.error('Usage: hippo auth <create|list|revoke|grant|ungrant> [options]');
8268
8359
  process.exit(1);
8269
8360
  }
8270
8361
  const subArgs = args.slice(1);
@@ -8284,8 +8375,18 @@ function cmdAuth(hippoRoot, args, flags) {
8284
8375
  cmdAuthRevoke(hippoRoot, keyId, flags);
8285
8376
  return;
8286
8377
  }
8378
+ case 'grant':
8379
+ case 'ungrant': {
8380
+ const [keyId, scope] = subArgs;
8381
+ if (!keyId || !scope) {
8382
+ console.error(`Usage: hippo auth ${sub} <key_id> <scope>`);
8383
+ process.exit(1);
8384
+ }
8385
+ cmdAuthScopeGrant(hippoRoot, keyId, scope, sub === 'grant', flags);
8386
+ return;
8387
+ }
8287
8388
  default:
8288
- console.error(`Unknown auth subcommand: ${sub}. Expected: create | list | revoke.`);
8389
+ console.error(`Unknown auth subcommand: ${sub}. Expected: create | list | revoke | grant | ungrant.`);
8289
8390
  process.exit(1);
8290
8391
  }
8291
8392
  }
@@ -8673,6 +8774,12 @@ Commands:
8673
8774
  --global Operate on the global store
8674
8775
  dormant restore <id> Bring a dormant memory back to active memory
8675
8776
  dormant forget <id> Delete a dormant memory permanently
8777
+ quarantine [list] List memories a connector flagged as an instruction attempt, pending review
8778
+ --all Include approved and rejected rows too (default: pending only)
8779
+ --json Output as JSON
8780
+ --global Operate on the global store
8781
+ quarantine approve <id> Restore a quarantined memory to its original scope
8782
+ quarantine reject <id> Keep a quarantined memory hidden for good
8676
8783
  capture-error Store a failed tool call as an error memory (reads the Claude Code
8677
8784
  PostToolUseFailure hook payload on stdin; skips routine failures)
8678
8785
  doctor Check the install: Node, store, schema, sleep, agent hooks
@@ -8966,6 +9073,12 @@ Commands:
8966
9073
  auth revoke <key_id> Revoke an API key (subsequent validate fails)
8967
9074
  --json Output as JSON
8968
9075
  --global Operate on the global store
9076
+ auth grant <key_id> <scope> Let a member key read one restricted scope
9077
+ --json Output as JSON
9078
+ --global Operate on the global store
9079
+ auth ungrant <key_id> <scope> Remove a scope grant
9080
+ --json Output as JSON
9081
+ --global Operate on the global store
8969
9082
  audit <sub> Query the append-only audit log (A5 stub auth)
8970
9083
  audit list List audit events for the active tenant
8971
9084
  --op <op> Filter by op (remember | recall | promote |
@@ -9424,6 +9537,9 @@ async function main(command, args, flags, hippoRoot) {
9424
9537
  case 'dormant':
9425
9538
  cmdDormant(hippoRoot, args, flags);
9426
9539
  break;
9540
+ case 'quarantine':
9541
+ cmdQuarantine(hippoRoot, args, flags);
9542
+ break;
9427
9543
  case 'tokens':
9428
9544
  cmdTokens(hippoRoot, flags);
9429
9545
  break;
@@ -108,6 +108,7 @@ export function ingestEvent(ctx, input) {
108
108
  // v1.12.0: drop the legacy `|| 'connector:github'` fallback (see slack/ingest.ts:73 for rationale).
109
109
  const result = remember(ctx, {
110
110
  ...opts,
111
+ untrusted: true,
111
112
  afterWrite: (innerDb, memoryId) => {
112
113
  if (input.__testInjectBeforeLog) {
113
114
  input.__testInjectBeforeLog(innerDb, idempotencyKey);
@@ -61,6 +61,7 @@ export function ingestMessage(ctx, input) {
61
61
  // ever missing); explicit reliance on the caller is safer.
62
62
  const result = remember(ctx, {
63
63
  ...opts,
64
+ untrusted: true,
64
65
  afterWrite: (innerDb, memoryId) => {
65
66
  const inserted = innerDb
66
67
  .prepare(`INSERT OR IGNORE INTO slack_event_log (event_id, ingested_at, memory_id) VALUES (?, ?, ?)`)
@@ -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;