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