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 +36 -0
- package/dist/api.js +185 -9
- package/dist/audit.d.ts +1 -1
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +20 -1
- package/dist/cli.js +118 -2
- package/dist/connectors/github/ingest.js +1 -0
- package/dist/connectors/slack/ingest.js +1 -0
- package/dist/consolidate.d.ts +2 -0
- package/dist/consolidate.js +20 -3
- package/dist/dag.js +15 -6
- package/dist/db.js +42 -1
- package/dist/dedupe.js +5 -2
- package/dist/extract.js +1 -0
- package/dist/instruction-detect.d.ts +7 -0
- package/dist/instruction-detect.js +27 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +6 -17
- package/dist/project-briefs.js +4 -2
- package/dist/quarantine.d.ts +31 -0
- package/dist/quarantine.js +63 -0
- package/dist/recall-scope.d.ts +22 -10
- package/dist/recall-scope.js +41 -26
- package/dist/secret-detect.js +1 -1
- package/dist/server.js +69 -10
- package/dist/shared.js +12 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/consolidate.js
CHANGED
|
@@ -25,6 +25,8 @@ import { rescueSet, rankNonPinnedByTenant, validateWeights } from './memory-valu
|
|
|
25
25
|
import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-weights.js';
|
|
26
26
|
import { appendAuditEvent } from './audit.js';
|
|
27
27
|
import { migrateDefaultHalfLife } from './half-life-migration.js';
|
|
28
|
+
import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
|
|
29
|
+
import { isQuarantineScope } from './quarantine.js';
|
|
28
30
|
const DECAY_THRESHOLD = 0.05;
|
|
29
31
|
const MERGE_OVERLAP_THRESHOLD = 0.35; // Jaccard similarity for "related"
|
|
30
32
|
const MERGE_MIN_CLUSTER = 2; // minimum cluster size to merge
|
|
@@ -108,6 +110,7 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
108
110
|
semanticCreated: 0,
|
|
109
111
|
replayed: 0,
|
|
110
112
|
promotedTraces: 0,
|
|
113
|
+
tracesSkippedMixedScope: 0,
|
|
111
114
|
extractionCandidates: 0,
|
|
112
115
|
extracted: 0,
|
|
113
116
|
dagCandidateClusters: 0,
|
|
@@ -368,6 +371,13 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
368
371
|
session_id: session.session_id,
|
|
369
372
|
limit: 1000,
|
|
370
373
|
});
|
|
374
|
+
// T7: a mixed-scope session would otherwise leak into one trace.
|
|
375
|
+
const sessionScope = commonDerivationScope(events.map((e) => e.scope));
|
|
376
|
+
if (!sessionScope.ok) {
|
|
377
|
+
result.tracesSkippedMixedScope++;
|
|
378
|
+
result.details.push(` ⏭ skipped session ${session.session_id}: events span mixed scopes`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
371
381
|
const completeEvent = events.find((e) => e.event_type === 'session_complete');
|
|
372
382
|
if (!completeEvent)
|
|
373
383
|
continue; // defence-in-depth; findPromotableSessions filters already.
|
|
@@ -391,6 +401,7 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
391
401
|
source_session_id: session.session_id,
|
|
392
402
|
tags: ['auto-promoted'],
|
|
393
403
|
source: 'auto-promote',
|
|
404
|
+
scope: sessionScope.scope,
|
|
394
405
|
// T1 fix (2026-08-15 hardening pass): stamp the trace into the SAME
|
|
395
406
|
// tenant the traceExistsForSession idempotency check (above) runs
|
|
396
407
|
// under. Before
|
|
@@ -671,11 +682,12 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
671
682
|
// before this fix — byte-identical behavior there.
|
|
672
683
|
const mergeCandidatesByTenant = new Map();
|
|
673
684
|
for (const entry of mergeCandidates) {
|
|
674
|
-
const
|
|
685
|
+
const key = derivationPartitionKey(entry.tenantId, entry.scope);
|
|
686
|
+
const bucket = mergeCandidatesByTenant.get(key);
|
|
675
687
|
if (bucket)
|
|
676
688
|
bucket.push(entry);
|
|
677
689
|
else
|
|
678
|
-
mergeCandidatesByTenant.set(
|
|
690
|
+
mergeCandidatesByTenant.set(key, [entry]);
|
|
679
691
|
}
|
|
680
692
|
// AT1 consolidation-loop fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
|
|
681
693
|
// reuses the single consolidateDb handle opened once above (before the
|
|
@@ -686,7 +698,9 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
686
698
|
// declared up at the try's opening above 1.4, not here — it has to
|
|
687
699
|
// survive the try/finally that now wraps this whole section; see the
|
|
688
700
|
// handle-leak restructure comment there.)
|
|
689
|
-
for (const [
|
|
701
|
+
for (const [, tenantCandidates] of mergeCandidatesByTenant) {
|
|
702
|
+
const mergeTenant = tenantCandidates[0].tenantId;
|
|
703
|
+
const mergeScope = derivationScope(tenantCandidates[0].scope);
|
|
690
704
|
for (let i = 0; i < tenantCandidates.length; i++) {
|
|
691
705
|
if (used.has(tenantCandidates[i].id))
|
|
692
706
|
continue;
|
|
@@ -722,6 +736,7 @@ export async function consolidate(hippoRoot, options = {}) {
|
|
|
722
736
|
source: 'consolidation',
|
|
723
737
|
confidence: 'inferred',
|
|
724
738
|
tenantId: mergeTenant,
|
|
739
|
+
scope: mergeScope,
|
|
725
740
|
});
|
|
726
741
|
}
|
|
727
742
|
// mergeContents is DETERMINISTIC CONCATENATION (not an LLM paraphrase)
|
|
@@ -931,6 +946,8 @@ function detectConflicts(entries, now, decayOpts = {},
|
|
|
931
946
|
// Default empty set: flag-off behavior is unchanged.
|
|
932
947
|
rescuedIds = new Set()) {
|
|
933
948
|
const survivors = entries.filter((entry) => entry.layer !== Layer.Semantic
|
|
949
|
+
// CD5: an unreviewed quarantined row must not taint a visible memory as conflicted.
|
|
950
|
+
&& !isQuarantineScope(entry.scope ?? null)
|
|
934
951
|
&& (rescuedIds.has(entry.id) || calculateStrength(entry, now, decayOpts) >= DECAY_THRESHOLD));
|
|
935
952
|
const detected = [];
|
|
936
953
|
for (let i = 0; i < survivors.length; i++) {
|
package/dist/dag.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createMemory, Layer } from './memory.js';
|
|
|
2
2
|
import { writeEntry, loadAllDirtySummaries, loadChildrenOfSummary, applyRebuildResult, clearSummaryDirtyAfterBuild, } from './store.js';
|
|
3
3
|
import { RejectedValueError } from './rejection.js';
|
|
4
4
|
import { redactSecrets } from './secret-detect.js';
|
|
5
|
+
import { derivationScope, derivationPartitionKey } from './recall-scope.js';
|
|
5
6
|
export function clusterFacts(facts) {
|
|
6
7
|
if (facts.length === 0)
|
|
7
8
|
return [];
|
|
@@ -93,13 +94,16 @@ export async function buildDag(hippoRoot, facts, opts) {
|
|
|
93
94
|
// behavior there.
|
|
94
95
|
const unparentedByTenant = new Map();
|
|
95
96
|
for (const fact of unparented) {
|
|
96
|
-
const
|
|
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(
|
|
102
|
+
unparentedByTenant.set(key, [fact]);
|
|
101
103
|
}
|
|
102
|
-
for (const [
|
|
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
|
|
307
|
+
const key = derivationPartitionKey(tid, l2.scope);
|
|
308
|
+
const list = byTenant.get(key) ?? [];
|
|
303
309
|
list.push(l2);
|
|
304
|
-
byTenant.set(
|
|
310
|
+
byTenant.set(key, list);
|
|
305
311
|
}
|
|
306
|
-
for (const [
|
|
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 =
|
|
14
|
+
const CURRENT_SCHEMA_VERSION = 48;
|
|
15
15
|
const MIGRATIONS = [
|
|
16
16
|
{
|
|
17
17
|
version: 1,
|
|
@@ -2447,6 +2447,30 @@ const MIGRATIONS = [
|
|
|
2447
2447
|
`);
|
|
2448
2448
|
},
|
|
2449
2449
|
},
|
|
2450
|
+
{
|
|
2451
|
+
version: 47,
|
|
2452
|
+
up: (db) => {
|
|
2453
|
+
// Scope grants (src/auth.ts, ROADMAP EI2): a member key reads a
|
|
2454
|
+
// restricted scope only by an explicit row here. Additive only: no
|
|
2455
|
+
// min_compatible_binary bump.
|
|
2456
|
+
db.exec(`
|
|
2457
|
+
CREATE TABLE IF NOT EXISTS api_key_scope_grants (
|
|
2458
|
+
key_id TEXT NOT NULL,
|
|
2459
|
+
scope TEXT NOT NULL,
|
|
2460
|
+
granted_at TEXT NOT NULL,
|
|
2461
|
+
PRIMARY KEY(key_id, scope)
|
|
2462
|
+
);
|
|
2463
|
+
`);
|
|
2464
|
+
},
|
|
2465
|
+
},
|
|
2466
|
+
{
|
|
2467
|
+
version: 48,
|
|
2468
|
+
up: (db) => {
|
|
2469
|
+
// Quarantine (src/quarantine.ts, CD5): a poisoned or suspect memory sits here pending
|
|
2470
|
+
// admin review instead of being hidden with no record. Additive only: no min_compatible_binary bump.
|
|
2471
|
+
db.exec(MEMORY_QUARANTINE_DDL);
|
|
2472
|
+
},
|
|
2473
|
+
},
|
|
2450
2474
|
];
|
|
2451
2475
|
function tableHasColumn(db, tableName, columnName) {
|
|
2452
2476
|
if (!/^[a-z_]+$/i.test(tableName))
|
|
@@ -2612,6 +2636,22 @@ function setSchemaVersion(db, version) {
|
|
|
2612
2636
|
db.prepare(`INSERT INTO meta(key, value) VALUES('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(version));
|
|
2613
2637
|
db.exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
|
|
2614
2638
|
}
|
|
2639
|
+
// Shared by migration v48 and the stamped-store self-heal below.
|
|
2640
|
+
const MEMORY_QUARANTINE_DDL = `
|
|
2641
|
+
CREATE TABLE IF NOT EXISTS memory_quarantine (
|
|
2642
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
2643
|
+
memory_id TEXT NOT NULL,
|
|
2644
|
+
original_scope TEXT,
|
|
2645
|
+
reason TEXT NOT NULL,
|
|
2646
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
|
|
2647
|
+
quarantined_at TEXT NOT NULL,
|
|
2648
|
+
decided_at TEXT,
|
|
2649
|
+
decided_by TEXT,
|
|
2650
|
+
PRIMARY KEY (tenant_id, memory_id)
|
|
2651
|
+
) WITHOUT ROWID;
|
|
2652
|
+
CREATE INDEX IF NOT EXISTS idx_memory_quarantine_status
|
|
2653
|
+
ON memory_quarantine(tenant_id, status, quarantined_at DESC);
|
|
2654
|
+
`;
|
|
2615
2655
|
// Before the loop on stamped stores: a table lost after its migration stamped (2026-08-15
|
|
2616
2656
|
// incident) is never re-migrated, and v4/v16/v22 ALTER or read it. Fresh stores use the chain.
|
|
2617
2657
|
function ensureContinuityTables(db) {
|
|
@@ -2713,6 +2753,7 @@ function ensureContinuityTables(db) {
|
|
|
2713
2753
|
tenant_id TEXT NOT NULL DEFAULT 'default'
|
|
2714
2754
|
)
|
|
2715
2755
|
`);
|
|
2756
|
+
db.exec(MEMORY_QUARANTINE_DDL);
|
|
2716
2757
|
}
|
|
2717
2758
|
// After the loop: tenant_id (v16) and scope (v23) do not exist yet on a genuine old store.
|
|
2718
2759
|
function ensureContinuityIndexes(db) {
|
package/dist/dedupe.js
CHANGED
|
@@ -24,6 +24,7 @@ import { textOverlap } from './search.js';
|
|
|
24
24
|
import { loadAllEntries, deleteEntry } from './store.js';
|
|
25
25
|
import { compareEntryIdentity } from './compare.js';
|
|
26
26
|
import { canAutoDelete } from './memory.js';
|
|
27
|
+
import { derivationPartitionKey } from './recall-scope.js';
|
|
27
28
|
/** Quantization step for strength-tie comparisons. The historical 0.01
|
|
28
29
|
* epsilon (see `strengthBucket` below) applied via rounding instead of a
|
|
29
30
|
* raw abs-diff threshold, so the tiebreak is transitive. */
|
|
@@ -76,11 +77,13 @@ export function deduplicateStore(hippoRoot, options = {}) {
|
|
|
76
77
|
// pre-fix global pass.
|
|
77
78
|
const entriesByTenant = new Map();
|
|
78
79
|
for (const entry of entries) {
|
|
79
|
-
|
|
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(
|
|
86
|
+
entriesByTenant.set(key, [entry]);
|
|
84
87
|
}
|
|
85
88
|
// finiteCount mirrors strengthBucket's non-finite hardening on the
|
|
86
89
|
// retrieval leg: a NaN retrieval_count would make the comparator return
|
package/dist/extract.js
CHANGED
|
@@ -92,6 +92,7 @@ export function storeExtractedFacts(hippoRoot, source, facts) {
|
|
|
92
92
|
confidence: 'inferred',
|
|
93
93
|
source: source.source,
|
|
94
94
|
extracted_from: source.id,
|
|
95
|
+
scope: source.scope,
|
|
95
96
|
// T1 executor check (2026-08-15 hardening pass): same defect as the
|
|
96
97
|
// consolidate.ts merge/trace passes — createMemory with no tenantId
|
|
97
98
|
// option stamps 'default' (memory.ts:535) regardless of the source
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Prompt-injection detection for untrusted memory content (CD5). Same shape as secret-detect.ts; leaf module, no store/api/shared imports. */
|
|
2
|
+
export interface InstructionDetection {
|
|
3
|
+
flagged: boolean;
|
|
4
|
+
reason: string | null;
|
|
5
|
+
}
|
|
6
|
+
export declare function detectInstruction(content: string): InstructionDetection;
|
|
7
|
+
//# sourceMappingURL=instruction-detect.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Prompt-injection detection for untrusted memory content (CD5). Same shape as secret-detect.ts; leaf module, no store/api/shared imports. */
|
|
2
|
+
const INSTRUCTION_PATTERNS = [
|
|
3
|
+
{ name: 'override-instructions', re: /\b(?:ignore|disregard|forget)\b[^.\n]{0,40}\b(?:previous|prior|above)\b[^.\n]{0,40}\binstructions?\b/i },
|
|
4
|
+
{ name: 'role-reassignment', re: /\b(?:you are now|act as|pretend to be)\b[^.\n]{0,40}\b(?:an?\s+)?(?:ai|assistant|agent|system)\b/i },
|
|
5
|
+
{ name: 'chat-role-markup', re: /<\|im_start\|>|<\|system\|>|\[INST\]|<\/?system>|###\s*system\s*:/i },
|
|
6
|
+
{ name: 'system-prompt-reference', re: /\b(?:(?:override|ignore|bypass|replace|reveal)\b[^.\n]{0,40}\b(?:system prompt|developer message)|(?:system prompt|developer message)\b[^.\n]{0,40}\b(?:override|ignore|bypass|replace|reveal))\b/i },
|
|
7
|
+
// "always"/"never" alone (ordinary PR prose) doesn't flag without an agent-facing target nearby too.
|
|
8
|
+
{ name: 'standing-order', re: /\b(?:from now on|whenever you|always|never)\b[^.\n]{0,50}\b(?:you must|(?:the ai|the assistant|the agent|claude|copilot)\s+(?:must|should|will)\b)/i },
|
|
9
|
+
{ name: 'concealment', re: /\b(?:do not|never|don't)\b[^.\n]{0,40}\b(?:tell|mention|reveal)\b[^.\n]{0,40}\bthe user\b/i },
|
|
10
|
+
{ name: 'remote-script-exec', re: /\b(?:curl|wget)\b[^\n|]{0,80}\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b|\biex\s*\(\s*(?:iwr|invoke-webrequest|new-object\s+net\.webclient)/i },
|
|
11
|
+
{ name: 'exfiltration', re: /\b(?:send|post|upload|exfiltrate)\b[^.\n]{0,40}\b(?:secrets?|tokens?|api[- ]?keys?|credentials?|env(?:ironment)? variables?)\b/i },
|
|
12
|
+
{ name: 'unicode-tag-chars', re: /[\u{E0000}-\u{E007F}]/u },
|
|
13
|
+
];
|
|
14
|
+
// A stray zero-width char has legitimate uses (ZWJ); only a run of 3+ adjacent ones reads as smuggling.
|
|
15
|
+
const ZERO_WIDTH_RUN_RE = /[\u200B-\u200F\u2060-\u2064]{3,}/;
|
|
16
|
+
export function detectInstruction(content) {
|
|
17
|
+
for (const { name, re } of INSTRUCTION_PATTERNS) {
|
|
18
|
+
if (re.test(content)) {
|
|
19
|
+
return { flagged: true, reason: `pattern:${name}` };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (ZERO_WIDTH_RUN_RE.test(content)) {
|
|
23
|
+
return { flagged: true, reason: 'pattern:zero-width-smuggling' };
|
|
24
|
+
}
|
|
25
|
+
return { flagged: false, reason: null };
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=instruction-detect.js.map
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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)
|
package/dist/mcp/server.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
//
|
|
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)
|
|
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
|
package/dist/project-briefs.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* closed (retired).
|
|
23
23
|
*/
|
|
24
24
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
25
|
-
import { writeEntry, assertTenantId } from './store.js';
|
|
25
|
+
import { writeEntry, assertTenantId, RECALL_DEFAULT_DENY_SCOPES } from './store.js';
|
|
26
26
|
import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
|
|
27
27
|
import { createMemory, Layer, PROJECT_BRIEF_HALF_LIFE_DAYS } from './memory.js';
|
|
28
28
|
import { appendAuditEvent } from './audit.js';
|
|
@@ -386,6 +386,7 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
|
386
386
|
}
|
|
387
387
|
const tag = `path:${normalizedRepo.toLowerCase()}`;
|
|
388
388
|
const likeParam = `%"${escapeLike(tag)}"%`;
|
|
389
|
+
const denyPlaceholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
|
|
389
390
|
const db = openHippoDb(hippoRoot);
|
|
390
391
|
let receipts;
|
|
391
392
|
try {
|
|
@@ -396,9 +397,10 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
|
396
397
|
WHERE tenant_id = ?
|
|
397
398
|
AND source != 'project_brief'
|
|
398
399
|
AND LOWER(tags_json) LIKE ? ESCAPE '\\'
|
|
400
|
+
AND (scope IS NULL OR (scope NOT IN (${denyPlaceholders}) AND scope NOT LIKE '%:private:%'))
|
|
399
401
|
ORDER BY created DESC, id DESC
|
|
400
402
|
LIMIT ?
|
|
401
|
-
`).all(tenantId, likeParam, MAX_BRIEF_RECEIPTS);
|
|
403
|
+
`).all(tenantId, likeParam, ...RECALL_DEFAULT_DENY_SCOPES, MAX_BRIEF_RECEIPTS);
|
|
402
404
|
}
|
|
403
405
|
finally {
|
|
404
406
|
closeHippoDb(db);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Quarantine tier (CD5, AT3): pending/approved/rejected record for a memory `remember` flagged as untrusted. */
|
|
2
|
+
import type { DatabaseSyncLike } from './db.js';
|
|
3
|
+
export declare const QUARANTINE_SCOPE_PREFIX = "quarantine:private:";
|
|
4
|
+
/** Scope a quarantined memory is stored under; matches PRIVATE_SCOPE_RE so every default-deny site hides it. */
|
|
5
|
+
export declare function quarantineScopeFor(original: string | null): string;
|
|
6
|
+
export declare function isQuarantineScope(scope: string | null | undefined): boolean;
|
|
7
|
+
export type QuarantineStatus = 'pending' | 'approved' | 'rejected';
|
|
8
|
+
export interface QuarantineRow {
|
|
9
|
+
tenantId: string;
|
|
10
|
+
memoryId: string;
|
|
11
|
+
originalScope: string | null;
|
|
12
|
+
reason: string;
|
|
13
|
+
status: QuarantineStatus;
|
|
14
|
+
quarantinedAt: string;
|
|
15
|
+
decidedAt: string | null;
|
|
16
|
+
decidedBy: string | null;
|
|
17
|
+
}
|
|
18
|
+
export interface RecordQuarantineOpts {
|
|
19
|
+
tenantId: string;
|
|
20
|
+
memoryId: string;
|
|
21
|
+
originalScope: string | null;
|
|
22
|
+
reason: string;
|
|
23
|
+
actor: string;
|
|
24
|
+
}
|
|
25
|
+
/** Insert the quarantine row + its audit event; caller runs this inside the memory's own write transaction. */
|
|
26
|
+
export declare function recordQuarantine(db: DatabaseSyncLike, opts: RecordQuarantineOpts): void;
|
|
27
|
+
export declare function getQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string): QuarantineRow | null;
|
|
28
|
+
export declare function listQuarantineRows(db: DatabaseSyncLike, tenantId: string, status?: QuarantineStatus | 'all', limit?: number): QuarantineRow[];
|
|
29
|
+
export declare function approveQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string, decidedBy: string): void;
|
|
30
|
+
export declare function rejectQuarantineRow(db: DatabaseSyncLike, tenantId: string, memoryId: string, decidedBy: string): void;
|
|
31
|
+
//# sourceMappingURL=quarantine.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Quarantine tier (CD5, AT3): pending/approved/rejected record for a memory `remember` flagged as untrusted. */
|
|
2
|
+
import { appendAuditEvent } from './audit.js';
|
|
3
|
+
export const QUARANTINE_SCOPE_PREFIX = 'quarantine:private:';
|
|
4
|
+
/** Scope a quarantined memory is stored under; matches PRIVATE_SCOPE_RE so every default-deny site hides it. */
|
|
5
|
+
export function quarantineScopeFor(original) {
|
|
6
|
+
return `${QUARANTINE_SCOPE_PREFIX}${original ?? 'unscoped'}`;
|
|
7
|
+
}
|
|
8
|
+
export function isQuarantineScope(scope) {
|
|
9
|
+
return scope != null && scope.startsWith(QUARANTINE_SCOPE_PREFIX);
|
|
10
|
+
}
|
|
11
|
+
const SELECT_COLUMNS = 'tenant_id, memory_id, original_scope, reason, status, quarantined_at, decided_at, decided_by';
|
|
12
|
+
function fromDbRow(row) {
|
|
13
|
+
return {
|
|
14
|
+
tenantId: row.tenant_id,
|
|
15
|
+
memoryId: row.memory_id,
|
|
16
|
+
originalScope: row.original_scope,
|
|
17
|
+
reason: row.reason,
|
|
18
|
+
// SAFETY: this module's own INSERT/UPDATE statements are the only writers of status.
|
|
19
|
+
status: row.status,
|
|
20
|
+
quarantinedAt: row.quarantined_at,
|
|
21
|
+
decidedAt: row.decided_at,
|
|
22
|
+
decidedBy: row.decided_by,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Insert the quarantine row + its audit event; caller runs this inside the memory's own write transaction. */
|
|
26
|
+
export function recordQuarantine(db, opts) {
|
|
27
|
+
db.prepare(`INSERT INTO memory_quarantine (tenant_id, memory_id, original_scope, reason, status, quarantined_at)
|
|
28
|
+
VALUES (?, ?, ?, ?, 'pending', ?)`).run(opts.tenantId, opts.memoryId, opts.originalScope, opts.reason, new Date().toISOString());
|
|
29
|
+
appendAuditEvent(db, {
|
|
30
|
+
tenantId: opts.tenantId,
|
|
31
|
+
actor: opts.actor,
|
|
32
|
+
op: 'quarantine',
|
|
33
|
+
targetId: opts.memoryId,
|
|
34
|
+
metadata: { reason: opts.reason, originalScope: opts.originalScope },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function getQuarantineRow(db, tenantId, memoryId) {
|
|
38
|
+
const row = db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? AND memory_id = ?`)
|
|
39
|
+
.get(tenantId, memoryId);
|
|
40
|
+
return row ? fromDbRow(row) : null;
|
|
41
|
+
}
|
|
42
|
+
export function listQuarantineRows(db, tenantId, status = 'pending', limit = 100) {
|
|
43
|
+
// A pending row whose memory was deleted (e.g. Slack message_deleted) is dead; keep its history, drop it from the queue.
|
|
44
|
+
const live = status === 'pending'
|
|
45
|
+
? ' AND EXISTS (SELECT 1 FROM memories m WHERE m.id = memory_quarantine.memory_id AND m.tenant_id = memory_quarantine.tenant_id)'
|
|
46
|
+
: '';
|
|
47
|
+
const rows = status === 'all'
|
|
48
|
+
? db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? ORDER BY quarantined_at DESC LIMIT ?`)
|
|
49
|
+
.all(tenantId, limit)
|
|
50
|
+
: db.prepare(`SELECT ${SELECT_COLUMNS} FROM memory_quarantine WHERE tenant_id = ? AND status = ?${live} ORDER BY quarantined_at DESC LIMIT ?`)
|
|
51
|
+
.all(tenantId, status, limit);
|
|
52
|
+
// SAFETY: both branches select SELECT_COLUMNS, matching QuarantineDbRow's field set.
|
|
53
|
+
return rows.map(fromDbRow);
|
|
54
|
+
}
|
|
55
|
+
export function approveQuarantineRow(db, tenantId, memoryId, decidedBy) {
|
|
56
|
+
db.prepare(`UPDATE memory_quarantine SET status = 'approved', decided_at = ?, decided_by = ? WHERE tenant_id = ? AND memory_id = ?`)
|
|
57
|
+
.run(new Date().toISOString(), decidedBy, tenantId, memoryId);
|
|
58
|
+
}
|
|
59
|
+
export function rejectQuarantineRow(db, tenantId, memoryId, decidedBy) {
|
|
60
|
+
db.prepare(`UPDATE memory_quarantine SET status = 'rejected', decided_at = ?, decided_by = ? WHERE tenant_id = ? AND memory_id = ?`)
|
|
61
|
+
.run(new Date().toISOString(), decidedBy, tenantId, memoryId);
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=quarantine.js.map
|
package/dist/recall-scope.d.ts
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
package/dist/recall-scope.js
CHANGED
|
@@ -50,18 +50,7 @@ export function passesScopeFilterForRecall(scope, requested) {
|
|
|
50
50
|
if (requested !== undefined && requested !== '') {
|
|
51
51
|
return scope === requested;
|
|
52
52
|
}
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
*/
|
|
122
|
-
export function assertScopeRequestAllowed(
|
|
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 (
|
|
115
|
+
if (canReadScope(actor, requested))
|
|
126
116
|
return;
|
|
127
|
-
|
|
128
|
-
|
|
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
|
package/dist/secret-detect.js
CHANGED
|
@@ -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
|
-
/\
|
|
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
|
/**
|