hippo-memory 1.49.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';
@@ -110,11 +111,17 @@ export interface RememberOpts {
110
111
  * back and the error is rethrown.
111
112
  */
112
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;
113
116
  }
114
117
  export interface RememberResult {
115
118
  id: string;
116
119
  kind: MemoryKind;
117
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
+ };
118
125
  }
119
126
  export declare function remember(ctx: Context, opts: RememberOpts): RememberResult;
120
127
  export interface RecallOpts {
@@ -1061,6 +1068,25 @@ export declare function restoreDormant(ctx: Context, id: string): MemoryEntry;
1061
1068
  export declare function forgetDormant(ctx: Context, id: string): void;
1062
1069
  /** Whether the tenant holds a dormant memory with this id (for "not found" hints). */
1063
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;
1064
1090
  export interface SleepResult {
1065
1091
  active: number;
1066
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';
@@ -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
@@ -2090,6 +2109,118 @@ export function isDormant(ctx, id) {
2090
2109
  closeHippoDb(db);
2091
2110
  }
2092
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
+ }
2093
2224
  const DEFAULT_SLEEP_PHASES = {
2094
2225
  consolidate,
2095
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' | 'auth_grant' | 'auth_ungrant';
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/cli.js CHANGED
@@ -3897,6 +3897,56 @@ function cmdDormant(hippoRoot, args, flags) {
3897
3897
  }
3898
3898
  console.log('Bring one back: hippo dormant restore <id> Delete for good: hippo dormant forget <id>');
3899
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
+ }
3900
3950
  /**
3901
3951
  * `hippo tokens [--days <n>] [--json] [--global]`: the token ledger
3902
3952
  * (ROADMAP TE0). Tokens of memory text handed to agents per surface, blocks
@@ -7979,6 +8029,9 @@ const VALID_AUDIT_OPS = new Set([
7979
8029
  'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7980
8030
  'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + server.ts VALID_AUDIT_OPS
7981
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
7982
8035
  ]);
7983
8036
  function formatAuditRow(ev) {
7984
8037
  const target = ev.targetId ?? '-';
@@ -8721,6 +8774,12 @@ Commands:
8721
8774
  --global Operate on the global store
8722
8775
  dormant restore <id> Bring a dormant memory back to active memory
8723
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
8724
8783
  capture-error Store a failed tool call as an error memory (reads the Claude Code
8725
8784
  PostToolUseFailure hook payload on stdin; skips routine failures)
8726
8785
  doctor Check the install: Node, store, schema, sleep, agent hooks
@@ -9478,6 +9537,9 @@ async function main(command, args, flags, hippoRoot) {
9478
9537
  case 'dormant':
9479
9538
  cmdDormant(hippoRoot, args, flags);
9480
9539
  break;
9540
+ case 'quarantine':
9541
+ cmdQuarantine(hippoRoot, args, flags);
9542
+ break;
9481
9543
  case 'tokens':
9482
9544
  cmdTokens(hippoRoot, flags);
9483
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 (?, ?, ?)`)
@@ -26,6 +26,7 @@ import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-wei
26
26
  import { appendAuditEvent } from './audit.js';
27
27
  import { migrateDefaultHalfLife } from './half-life-migration.js';
28
28
  import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
29
+ import { isQuarantineScope } from './quarantine.js';
29
30
  const DECAY_THRESHOLD = 0.05;
30
31
  const MERGE_OVERLAP_THRESHOLD = 0.35; // Jaccard similarity for "related"
31
32
  const MERGE_MIN_CLUSTER = 2; // minimum cluster size to merge
@@ -945,6 +946,8 @@ function detectConflicts(entries, now, decayOpts = {},
945
946
  // Default empty set: flag-off behavior is unchanged.
946
947
  rescuedIds = new Set()) {
947
948
  const survivors = entries.filter((entry) => entry.layer !== Layer.Semantic
949
+ // CD5: an unreviewed quarantined row must not taint a visible memory as conflicted.
950
+ && !isQuarantineScope(entry.scope ?? null)
948
951
  && (rescuedIds.has(entry.id) || calculateStrength(entry, now, decayOpts) >= DECAY_THRESHOLD));
949
952
  const detected = [];
950
953
  for (let i = 0; i < survivors.length; i++) {
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 = 47;
14
+ const CURRENT_SCHEMA_VERSION = 48;
15
15
  const MIGRATIONS = [
16
16
  {
17
17
  version: 1,
@@ -2463,6 +2463,14 @@ const MIGRATIONS = [
2463
2463
  `);
2464
2464
  },
2465
2465
  },
2466
+ {
2467
+ version: 48,
2468
+ up: (db) => {
2469
+ // Quarantine (src/quarantine.ts, CD5): a poisoned or suspect memory sits here pending
2470
+ // admin review instead of being hidden with no record. Additive only: no min_compatible_binary bump.
2471
+ db.exec(MEMORY_QUARANTINE_DDL);
2472
+ },
2473
+ },
2466
2474
  ];
2467
2475
  function tableHasColumn(db, tableName, columnName) {
2468
2476
  if (!/^[a-z_]+$/i.test(tableName))
@@ -2628,6 +2636,22 @@ function setSchemaVersion(db, version) {
2628
2636
  db.prepare(`INSERT INTO meta(key, value) VALUES('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(version));
2629
2637
  db.exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
2630
2638
  }
2639
+ // Shared by migration v48 and the stamped-store self-heal below.
2640
+ const MEMORY_QUARANTINE_DDL = `
2641
+ CREATE TABLE IF NOT EXISTS memory_quarantine (
2642
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2643
+ memory_id TEXT NOT NULL,
2644
+ original_scope TEXT,
2645
+ reason TEXT NOT NULL,
2646
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
2647
+ quarantined_at TEXT NOT NULL,
2648
+ decided_at TEXT,
2649
+ decided_by TEXT,
2650
+ PRIMARY KEY (tenant_id, memory_id)
2651
+ ) WITHOUT ROWID;
2652
+ CREATE INDEX IF NOT EXISTS idx_memory_quarantine_status
2653
+ ON memory_quarantine(tenant_id, status, quarantined_at DESC);
2654
+ `;
2631
2655
  // Before the loop on stamped stores: a table lost after its migration stamped (2026-08-15
2632
2656
  // incident) is never re-migrated, and v4/v16/v22 ALTER or read it. Fresh stores use the chain.
2633
2657
  function ensureContinuityTables(db) {
@@ -2729,6 +2753,7 @@ function ensureContinuityTables(db) {
2729
2753
  tenant_id TEXT NOT NULL DEFAULT 'default'
2730
2754
  )
2731
2755
  `);
2756
+ db.exec(MEMORY_QUARANTINE_DDL);
2732
2757
  }
2733
2758
  // After the loop: tenant_id (v16) and scope (v23) do not exist yet on a genuine old store.
2734
2759
  function ensureContinuityIndexes(db) {
@@ -0,0 +1,7 @@
1
+ /** Prompt-injection detection for untrusted memory content (CD5). Same shape as secret-detect.ts; leaf module, no store/api/shared imports. */
2
+ export interface InstructionDetection {
3
+ flagged: boolean;
4
+ reason: string | null;
5
+ }
6
+ export declare function detectInstruction(content: string): InstructionDetection;
7
+ //# sourceMappingURL=instruction-detect.d.ts.map
@@ -0,0 +1,27 @@
1
+ /** Prompt-injection detection for untrusted memory content (CD5). Same shape as secret-detect.ts; leaf module, no store/api/shared imports. */
2
+ const INSTRUCTION_PATTERNS = [
3
+ { name: 'override-instructions', re: /\b(?:ignore|disregard|forget)\b[^.\n]{0,40}\b(?:previous|prior|above)\b[^.\n]{0,40}\binstructions?\b/i },
4
+ { name: 'role-reassignment', re: /\b(?:you are now|act as|pretend to be)\b[^.\n]{0,40}\b(?:an?\s+)?(?:ai|assistant|agent|system)\b/i },
5
+ { name: 'chat-role-markup', re: /<\|im_start\|>|<\|system\|>|\[INST\]|<\/?system>|###\s*system\s*:/i },
6
+ { name: 'system-prompt-reference', re: /\b(?:(?:override|ignore|bypass|replace|reveal)\b[^.\n]{0,40}\b(?:system prompt|developer message)|(?:system prompt|developer message)\b[^.\n]{0,40}\b(?:override|ignore|bypass|replace|reveal))\b/i },
7
+ // "always"/"never" alone (ordinary PR prose) doesn't flag without an agent-facing target nearby too.
8
+ { name: 'standing-order', re: /\b(?:from now on|whenever you|always|never)\b[^.\n]{0,50}\b(?:you must|(?:the ai|the assistant|the agent|claude|copilot)\s+(?:must|should|will)\b)/i },
9
+ { name: 'concealment', re: /\b(?:do not|never|don't)\b[^.\n]{0,40}\b(?:tell|mention|reveal)\b[^.\n]{0,40}\bthe user\b/i },
10
+ { name: 'remote-script-exec', re: /\b(?:curl|wget)\b[^\n|]{0,80}\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b|\biex\s*\(\s*(?:iwr|invoke-webrequest|new-object\s+net\.webclient)/i },
11
+ { name: 'exfiltration', re: /\b(?:send|post|upload|exfiltrate)\b[^.\n]{0,40}\b(?:secrets?|tokens?|api[- ]?keys?|credentials?|env(?:ironment)? variables?)\b/i },
12
+ { name: 'unicode-tag-chars', re: /[\u{E0000}-\u{E007F}]/u },
13
+ ];
14
+ // A stray zero-width char has legitimate uses (ZWJ); only a run of 3+ adjacent ones reads as smuggling.
15
+ const ZERO_WIDTH_RUN_RE = /[\u200B-\u200F\u2060-\u2064]{3,}/;
16
+ export function detectInstruction(content) {
17
+ for (const { name, re } of INSTRUCTION_PATTERNS) {
18
+ if (re.test(content)) {
19
+ return { flagged: true, reason: `pattern:${name}` };
20
+ }
21
+ }
22
+ if (ZERO_WIDTH_RUN_RE.test(content)) {
23
+ return { flagged: true, reason: 'pattern:zero-width-smuggling' };
24
+ }
25
+ return { flagged: false, reason: null };
26
+ }
27
+ //# sourceMappingURL=instruction-detect.js.map
@@ -0,0 +1,31 @@
1
+ /** Quarantine tier (CD5, AT3): pending/approved/rejected record for a memory `remember` flagged as untrusted. */
2
+ import type { DatabaseSyncLike } from './db.js';
3
+ export declare const QUARANTINE_SCOPE_PREFIX = "quarantine:private:";
4
+ /** Scope a quarantined memory is stored under; matches PRIVATE_SCOPE_RE so every default-deny site hides it. */
5
+ export declare function quarantineScopeFor(original: string | null): string;
6
+ export declare function isQuarantineScope(scope: string | null | undefined): boolean;
7
+ export type QuarantineStatus = 'pending' | 'approved' | 'rejected';
8
+ export interface QuarantineRow {
9
+ tenantId: string;
10
+ memoryId: string;
11
+ originalScope: string | null;
12
+ reason: string;
13
+ status: QuarantineStatus;
14
+ quarantinedAt: string;
15
+ decidedAt: string | null;
16
+ decidedBy: string | null;
17
+ }
18
+ export interface RecordQuarantineOpts {
19
+ tenantId: string;
20
+ memoryId: string;
21
+ originalScope: string | null;
22
+ reason: string;
23
+ actor: string;
24
+ }
25
+ /** Insert the quarantine row + its audit event; caller runs this inside the memory's own write transaction. */
26
+ export declare function recordQuarantine(db: DatabaseSyncLike, opts: RecordQuarantineOpts): void;
27
+ export declare function getQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string): QuarantineRow | null;
28
+ export declare function listQuarantineRows(db: DatabaseSyncLike, tenantId: string, status?: QuarantineStatus | 'all', limit?: number): QuarantineRow[];
29
+ export declare function approveQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string, decidedBy: string): void;
30
+ export declare function rejectQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string, decidedBy: string): void;
31
+ //# sourceMappingURL=quarantine.d.ts.map
@@ -0,0 +1,63 @@
1
+ /** Quarantine tier (CD5, AT3): pending/approved/rejected record for a memory `remember` flagged as untrusted. */
2
+ import { appendAuditEvent } from './audit.js';
3
+ export const QUARANTINE_SCOPE_PREFIX = 'quarantine:private:';
4
+ /** Scope a quarantined memory is stored under; matches PRIVATE_SCOPE_RE so every default-deny site hides it. */
5
+ export function quarantineScopeFor(original) {
6
+ return `${QUARANTINE_SCOPE_PREFIX}${original ?? 'unscoped'}`;
7
+ }
8
+ export function isQuarantineScope(scope) {
9
+ return scope != null && scope.startsWith(QUARANTINE_SCOPE_PREFIX);
10
+ }
11
+ const SELECT_COLUMNS = 'tenant_id, memory_id, original_scope, reason, status, quarantined_at, decided_at, decided_by';
12
+ function fromDbRow(row) {
13
+ return {
14
+ tenantId: row.tenant_id,
15
+ memoryId: row.memory_id,
16
+ originalScope: row.original_scope,
17
+ reason: row.reason,
18
+ // SAFETY: this module's own INSERT/UPDATE statements are the only writers of status.
19
+ status: row.status,
20
+ quarantinedAt: row.quarantined_at,
21
+ decidedAt: row.decided_at,
22
+ decidedBy: row.decided_by,
23
+ };
24
+ }
25
+ /** Insert the quarantine row + its audit event; caller runs this inside the memory's own write transaction. */
26
+ export function recordQuarantine(db, opts) {
27
+ db.prepare(`INSERT INTO memory_quarantine (tenant_id, memory_id, original_scope, reason, status, quarantined_at)
28
+ VALUES (?, ?, ?, ?, 'pending', ?)`).run(opts.tenantId, opts.memoryId, opts.originalScope, opts.reason, new Date().toISOString());
29
+ appendAuditEvent(db, {
30
+ tenantId: opts.tenantId,
31
+ actor: opts.actor,
32
+ op: 'quarantine',
33
+ targetId: opts.memoryId,
34
+ metadata: { reason: opts.reason, originalScope: opts.originalScope },
35
+ });
36
+ }
37
+ export function getQuarantineRow(db, tenantId, memoryId) {
38
+ const row = db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? AND memory_id = ?`)
39
+ .get(tenantId, memoryId);
40
+ return row ? fromDbRow(row) : null;
41
+ }
42
+ export function listQuarantineRows(db, tenantId, status = 'pending', limit = 100) {
43
+ // A pending row whose memory was deleted (e.g. Slack message_deleted) is dead; keep its history, drop it from the queue.
44
+ const live = status === 'pending'
45
+ ? ' AND EXISTS (SELECT 1 FROM memories m WHERE m.id = memory_quarantine.memory_id AND m.tenant_id = memory_quarantine.tenant_id)'
46
+ : '';
47
+ const rows = status === 'all'
48
+ ? db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? ORDER BY quarantined_at DESC LIMIT ?`)
49
+ .all(tenantId, limit)
50
+ : db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? AND status = ?${live} ORDER BY quarantined_at DESC LIMIT ?`)
51
+ .all(tenantId, status, limit);
52
+ // SAFETY: both branches select SELECT_COLUMNS, matching QuarantineDbRow's field set.
53
+ return rows.map(fromDbRow);
54
+ }
55
+ export function approveQuarantineRow(db, tenantId, memoryId, decidedBy) {
56
+ db.prepare(`UPDATE memory_quarantine SET status = 'approved', decided_at = ?, decided_by = ? WHERE tenant_id = ? AND memory_id = ?`)
57
+ .run(new Date().toISOString(), decidedBy, tenantId, memoryId);
58
+ }
59
+ export function rejectQuarantineRow(db, tenantId, memoryId, decidedBy) {
60
+ db.prepare(`UPDATE memory_quarantine SET status = 'rejected', decided_at = ?, decided_by = ? WHERE tenant_id = ? AND memory_id = ?`)
61
+ .run(new Date().toISOString(), decidedBy, tenantId, memoryId);
62
+ }
63
+ //# sourceMappingURL=quarantine.js.map
package/dist/server.js CHANGED
@@ -22,7 +22,7 @@ export function __resetSessionRecallHistoryHttp() {
22
22
  import { PACKAGE_VERSION } from './version.js';
23
23
  import { validateApiKey } from './auth.js';
24
24
  import { createRateLimiter } from './rate-limit.js';
25
- import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, recordTokens, } from './api.js';
25
+ import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, recordTokens, quarantineList, quarantineApprove, quarantineReject, } from './api.js';
26
26
  import { buildGraphModel } from './graph-view.js';
27
27
  import { MAX_ENTITY_NAME_LEN } from './graph.js';
28
28
  import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
@@ -118,6 +118,9 @@ const VALID_AUDIT_OPS = new Set([
118
118
  'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
119
119
  'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
120
120
  'auth_ungrant', // EI2: emitted by api.authUngrant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
121
+ 'quarantine', // CD5: emitted by recordQuarantine; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
122
+ 'quarantine_approve', // CD5: emitted by api.quarantineApprove; lockstep
123
+ 'quarantine_reject', // CD5: emitted by api.quarantineReject; lockstep
121
124
  ]);
122
125
  // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
123
126
  // enough to dump a small deployment's full audit log without paginating, but
@@ -1163,6 +1166,61 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1163
1166
  sendJson(res, 200, result);
1164
1167
  return;
1165
1168
  }
1169
+ // GET /v1/quarantine?status=: CD5 review queue. quarantineList carries no role gate itself, so it's checked here.
1170
+ if (method === 'GET' && path === '/v1/quarantine') {
1171
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1172
+ if (ctx.actor.role !== 'admin') {
1173
+ throw new HttpError(403, '/v1/quarantine requires admin role');
1174
+ }
1175
+ const statusRaw = query.get('status');
1176
+ let status = 'pending';
1177
+ if (statusRaw !== null) {
1178
+ if (statusRaw !== 'pending' && statusRaw !== 'approved' && statusRaw !== 'rejected' && statusRaw !== 'all') {
1179
+ throw new HttpError(400, 'status must be one of: pending | approved | rejected | all');
1180
+ }
1181
+ status = statusRaw;
1182
+ }
1183
+ sendJson(res, 200, { quarantine: quarantineList(ctx, { status }) });
1184
+ return;
1185
+ }
1186
+ // POST /v1/quarantine/:id/approve: admin only; ForbiddenError falls through to mapApiError's 403.
1187
+ const quarantineApproveMatch = matchPath('/v1/quarantine/:id/approve', path);
1188
+ if (method === 'POST' && quarantineApproveMatch) {
1189
+ validateIdSegment(quarantineApproveMatch.id, 'memory id');
1190
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1191
+ try {
1192
+ quarantineApprove(ctx, quarantineApproveMatch.id);
1193
+ sendJson(res, 200, { approved: quarantineApproveMatch.id });
1194
+ }
1195
+ catch (e) {
1196
+ const msg = e instanceof Error ? e.message : String(e);
1197
+ if (msg.includes('not quarantined'))
1198
+ throw new HttpError(404, msg);
1199
+ if (msg.includes('is already') || msg.includes('scope changed'))
1200
+ throw new HttpError(409, msg);
1201
+ throw e;
1202
+ }
1203
+ return;
1204
+ }
1205
+ // POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
1206
+ const quarantineRejectMatch = matchPath('/v1/quarantine/:id/reject', path);
1207
+ if (method === 'POST' && quarantineRejectMatch) {
1208
+ validateIdSegment(quarantineRejectMatch.id, 'memory id');
1209
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1210
+ try {
1211
+ quarantineReject(ctx, quarantineRejectMatch.id);
1212
+ sendJson(res, 200, { rejected: quarantineRejectMatch.id });
1213
+ }
1214
+ catch (e) {
1215
+ const msg = e instanceof Error ? e.message : String(e);
1216
+ if (msg.includes('not quarantined'))
1217
+ throw new HttpError(404, msg);
1218
+ if (msg.includes('is already'))
1219
+ throw new HttpError(409, msg);
1220
+ throw e;
1221
+ }
1222
+ return;
1223
+ }
1166
1224
  // GET /v1/audit?op=&since=&limit= — read audit events. All three filters
1167
1225
  // validated at the route boundary so an invalid value lands a 400 before
1168
1226
  // we hit the DB.
package/dist/shared.js CHANGED
@@ -13,6 +13,7 @@ import { search, hybridSearch } from './search.js';
13
13
  import { evalNow } from './ablation.js';
14
14
  import { deriveOriginProject, classifyOriginProject, resolveGlobalRootDir } from './project-identity.js';
15
15
  import { detectSecret } from './secret-detect.js';
16
+ import { isQuarantineScope } from './quarantine.js';
16
17
  import { RejectedValueError } from './rejection.js';
17
18
  import { embedMemory, embedAll } from './embeddings.js';
18
19
  /**
@@ -46,6 +47,10 @@ export function promoteToGlobal(localRoot, id, opts) {
46
47
  const entry = readEntry(localRoot, id, opts?.tenantId);
47
48
  if (!entry)
48
49
  throw new Error(`Memory not found: ${id}`);
50
+ // CD5: same veto as shareMemory; a promoted copy would have no quarantine record to review.
51
+ if (isQuarantineScope(entry.scope)) {
52
+ throw new Error(`Refusing to promote ${id}: it is quarantined pending review. Approve it first via 'hippo quarantine approve ${id}'.`);
53
+ }
49
54
  // v39 S4 producer veto: promote is a producer path to the global store
50
55
  // exactly like shareMemory - same hard rule (codex gating review P2).
51
56
  const promoteSecret = detectSecret(entry);
@@ -272,6 +277,10 @@ export function shareMemory(localRoot, id, options = {}) {
272
277
  throw new Error(`Refusing to share ${id} to the global store: content matches secret material (${secret.reason}). ` +
273
278
  `Secrets stay in their owning project's store.`);
274
279
  }
280
+ // CD5: a quarantined row is unreviewed input, not a lesson; sharing it would spread poison globally.
281
+ if (isQuarantineScope(entry.scope)) {
282
+ throw new Error(`Refusing to share ${id}: it is quarantined pending review. Approve it first via 'hippo quarantine approve ${id}'.`);
283
+ }
275
284
  const score = transferScore(entry);
276
285
  if (score < 0.3 && !options.force)
277
286
  return null;
@@ -385,6 +394,9 @@ export function autoShare(localRoot, options = {}) {
385
394
  // Build set of global content hashes to avoid duplicates
386
395
  const globalContentSet = new Set(globalEntries.map((e) => e.content.toLowerCase().trim().slice(0, 200)));
387
396
  const candidates = localEntries.filter((entry) => {
397
+ // CD5: shareMemory refuses quarantined rows; filtering here keeps sleep from aborting on one.
398
+ if (isQuarantineScope(entry.scope ?? null))
399
+ return false;
388
400
  const score = transferScore(entry);
389
401
  if (score < minScore)
390
402
  return false;
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.49.0";
19
+ export declare const PACKAGE_VERSION = "1.50.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.49.0';
19
+ export const PACKAGE_VERSION = '1.50.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.49.0",
5
+ "version": "1.50.0",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.49.0",
3
+ "version": "1.50.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.49.0",
5
+ "version": "1.50.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.49.0",
3
+ "version": "1.50.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",