@ijfw/memory-server 1.5.6 → 1.6.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/bin/ijfw-dashboard +20 -1
- package/package.json +4 -3
- package/src/audit-roster.js +89 -12
- package/src/brain/tiered-llm.js +57 -7
- package/src/cross-orchestrator-cli.js +344 -4
- package/src/cross-project-search.js +39 -1
- package/src/dashboard-server.js +7 -1
- package/src/dream/runner.mjs +560 -8
- package/src/handlers/brain-handler.js +101 -1
- package/src/importers/discover.js +1 -1
- package/src/memory/bench-metrics.js +289 -0
- package/src/memory/benchmark.js +1 -1
- package/src/memory/search.js +53 -1
- package/src/orchestrator/plan-checker.js +1 -1
- package/src/profile/audit.js +671 -0
- package/src/profile/capture.js +871 -0
- package/src/profile/derive-dialectic.js +242 -0
- package/src/profile/derive-heuristic.js +733 -0
- package/src/profile/derive.js +156 -0
- package/src/profile/egress.js +306 -0
- package/src/profile/eval/build-real-probes.mjs +197 -0
- package/src/profile/eval/corpus-from-reddit.mjs +166 -0
- package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
- package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
- package/src/profile/eval/gate-b-behavior.mjs +420 -0
- package/src/profile/eval/gate-b-decision-run.mjs +171 -0
- package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
- package/src/profile/eval/gate-b-run.mjs +417 -0
- package/src/profile/eval/gate-b-run.test.mjs +204 -0
- package/src/profile/eval/gate-c-capture.mjs +323 -0
- package/src/profile/eval/harness.mjs +551 -0
- package/src/profile/eval/instrument-validation.mjs +248 -0
- package/src/profile/eval/instrument-validation.test.mjs +125 -0
- package/src/profile/eval/multi-subject-harness.mjs +106 -0
- package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
- package/src/profile/eval/personas.test.mjs +83 -0
- package/src/profile/eval/plumbing.test.mjs +69 -0
- package/src/profile/eval/prereg.mjs +130 -0
- package/src/profile/eval/prereg.test.mjs +78 -0
- package/src/profile/eval/real-corpus.test.mjs +103 -0
- package/src/profile/eval/real-personas.mjs +109 -0
- package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
- package/src/profile/eval/run-real-corpus.mjs +358 -0
- package/src/profile/eval/slug-quality.mjs +464 -0
- package/src/profile/eval/stylometry-features.js +85 -0
- package/src/profile/eval/stylometry-reference.js +16 -0
- package/src/profile/eval/stylometry.js +224 -0
- package/src/profile/eval/stylometry.test.mjs +103 -0
- package/src/profile/eval/synthetic-personas.js +91 -0
- package/src/profile/eval/verifier-features.mjs +170 -0
- package/src/profile/eval/verifier-logreg.mjs +74 -0
- package/src/profile/eval/verifier-pair.mjs +122 -0
- package/src/profile/eval/verifier-reference.mjs +68 -0
- package/src/profile/eval/verifier-scorer.mjs +30 -0
- package/src/profile/eval/wrong-target-control.mjs +168 -0
- package/src/profile/eval/wrong-target-control.test.mjs +124 -0
- package/src/profile/exemplar-capture.js +232 -0
- package/src/profile/exemplar-retrieve.js +138 -0
- package/src/profile/exemplar-store.js +314 -0
- package/src/profile/lock.js +64 -0
- package/src/profile/merge.js +624 -0
- package/src/profile/path-policy.js +213 -0
- package/src/profile/precision-stamp.mjs +151 -0
- package/src/profile/render-brief.js +717 -0
- package/src/profile/schema.js +244 -0
- package/src/profile/sensitivity.js +249 -0
- package/src/profile/serve.js +345 -0
- package/src/profile/store.js +261 -0
- package/src/profile/telemetry.js +289 -0
- package/src/recovery/checkpoint.js +7 -1
- package/src/server.js +185 -14
- package/src/.registry-meta-key.pem +0 -3
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile/serve.js — Cross-system profile bus, PHASE P4 (P4.4 + P4.6 serving).
|
|
3
|
+
*
|
|
4
|
+
* The MCP-facing READ path for the user-global profile. Two operations, both
|
|
5
|
+
* ZERO-LLM by construction (the moat): this module imports ONLY store.js
|
|
6
|
+
* (read), render-brief.js (compose), egress.js (audit), sensitivity.js (gate) —
|
|
7
|
+
* none of which reach the LLM tier. The P4.5 import-graph guard proves it.
|
|
8
|
+
*
|
|
9
|
+
* profileGet(opts) — the structured, sensitivity-filtered listing of what
|
|
10
|
+
* the profile knows (style axes + expertise + the
|
|
11
|
+
* inferences cleared for the caller's sensitivity tier).
|
|
12
|
+
* Logs an egress entry (a `get` is an exfiltration).
|
|
13
|
+
* profileBrief(opts) — the short descriptive brief for passive injection
|
|
14
|
+
* (renderBrief). Logs an egress entry for what left.
|
|
15
|
+
*
|
|
16
|
+
* COLD START — a missing/empty profile yields an empty result, NEVER an error
|
|
17
|
+
* (design-v2 §5 + the P4 spec): the absence of a profile must be a graceful
|
|
18
|
+
* "nothing yet", not a failure the host has to handle.
|
|
19
|
+
*
|
|
20
|
+
* The host + session identifiers (for the egress ledger) come from the caller's
|
|
21
|
+
* context so the audit trail can answer "which host saw what, when".
|
|
22
|
+
*
|
|
23
|
+
* Zero deps, Node built-ins only. NO LLM calls.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readProfile } from './store.js';
|
|
27
|
+
import { renderBrief, renderSnapshot, BRIEF_MIN_CONFIDENCE, BRIEF_MIN_EVIDENCE } from './render-brief.js';
|
|
28
|
+
import { appendEgress, appendExemplarEgress } from './egress.js';
|
|
29
|
+
// V3 voice few-shot seam. BOTH imports are zero-LLM by construction:
|
|
30
|
+
// exemplar-retrieve ranks the user's OWN writing with pure BM25 + register/
|
|
31
|
+
// recency (no LLM/network/embedder), and exemplar-capture's classifyRegister is
|
|
32
|
+
// a pure string heuristic. Pulling them here keeps the P4.5 moat intact — the
|
|
33
|
+
// serve read path still never reaches the LLM tier (the moat-guard import-graph
|
|
34
|
+
// test re-proves this on every run).
|
|
35
|
+
import { retrieveExemplars } from './exemplar-retrieve.js';
|
|
36
|
+
import { classifyRegister } from './exemplar-capture.js';
|
|
37
|
+
// S5 — serve.js is the ONLY profile read-path module that loads the approval
|
|
38
|
+
// registry from disk; render-brief stays pure and is handed the registry. audit
|
|
39
|
+
// imports only lock/store/egress/schema (zero-LLM), so the moat is unchanged.
|
|
40
|
+
import { readApprovals } from './audit.js';
|
|
41
|
+
import {
|
|
42
|
+
fieldSensitivity,
|
|
43
|
+
sensitivityAllowed,
|
|
44
|
+
loadRedaction,
|
|
45
|
+
killSwitchEngaged,
|
|
46
|
+
isRedacted,
|
|
47
|
+
} from './sensitivity.js';
|
|
48
|
+
import { styleAxisConfirmed, expertiseBand } from './derive-heuristic.js';
|
|
49
|
+
import { STYLE_AXES } from './schema.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* isCloudHost(host) -> boolean. Conservative classifier for the exemplar-egress
|
|
53
|
+
* cloud flag (V4). A voice exemplar is the user's OWN raw writing, so when it is
|
|
54
|
+
* disclosed to a CLOUD host the egress record must say so distinctly. We default
|
|
55
|
+
* to FALSE (local) and flag TRUE only when the resolved host string clearly names
|
|
56
|
+
* a hosted/web surface. First-party LOCAL CLI hosts (claude-code, cursor,
|
|
57
|
+
* windsurf, codex, gemini-cli, …) are local => false. Unknown => false (we never
|
|
58
|
+
* over-claim cloud; the flag is an honesty marker, not a gate — the inject
|
|
59
|
+
* decision is the user's opt-in, made upstream).
|
|
60
|
+
*/
|
|
61
|
+
export function isCloudHost(host) {
|
|
62
|
+
const h = String(host || '').toLowerCase().trim();
|
|
63
|
+
if (!h) return false;
|
|
64
|
+
// Explicit cloud/web markers anywhere in the host string.
|
|
65
|
+
if (/(^|[^a-z])(cloud|web|hosted|saas|server|remote)([^a-z]|$)/.test(h)) return true;
|
|
66
|
+
// Known hosted assistant surfaces (web apps / hosted APIs), distinct from the
|
|
67
|
+
// local CLIs of the same vendors (claude-code, gemini-cli stay local).
|
|
68
|
+
const CLOUD_HOSTS = new Set([
|
|
69
|
+
'claude-web', 'claude.ai', 'chatgpt', 'openai', 'gemini-web', 'gemini.google.com',
|
|
70
|
+
'copilot-web', 'github-copilot-web', 'perplexity', 'poe',
|
|
71
|
+
]);
|
|
72
|
+
if (CLOUD_HOSTS.has(h)) return true;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pull host/session for the egress ledger from a caller context. */
|
|
77
|
+
function egressMeta(opts = {}) {
|
|
78
|
+
const ctx = opts.context && typeof opts.context === 'object' ? opts.context : {};
|
|
79
|
+
return {
|
|
80
|
+
host: typeof ctx.host === 'string' ? ctx.host : (typeof opts.host === 'string' ? opts.host : null),
|
|
81
|
+
session: typeof ctx.session === 'string' ? ctx.session : (typeof opts.session === 'string' ? opts.session : null),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* profileBrief(opts) -> { ok, brief, fields, egress }. Renders the descriptive
|
|
87
|
+
* brief and records exactly what left in the egress ledger. Cold start -> empty
|
|
88
|
+
* brief (ok:true). Never throws.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} [opts] forwarded to renderBrief (tokenBudget, context,
|
|
91
|
+
* shareSensitive, env, redactFile) plus context.host/context.session for the
|
|
92
|
+
* egress ledger.
|
|
93
|
+
*/
|
|
94
|
+
export function profileBrief(opts = {}) {
|
|
95
|
+
let profile = null;
|
|
96
|
+
try {
|
|
97
|
+
const r = readProfile();
|
|
98
|
+
// A corrupt/symlinked profile is NOT a serve error — serve nothing rather
|
|
99
|
+
// than leak a half-read profile or throw at the host. Cold start semantics.
|
|
100
|
+
profile = r && r.ok ? r.profile : null;
|
|
101
|
+
} catch {
|
|
102
|
+
profile = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Thread the RESOLVED host (from caller context) into renderBrief so the
|
|
106
|
+
// sensitivity gate can cross-check it against the share-hosts allowlist
|
|
107
|
+
// (audit MED-2). forceLowOnly (audit MED-3) is honored when the caller marks
|
|
108
|
+
// this a passive read (the MCP resource path sets it). Self-declared host in
|
|
109
|
+
// the field payload is NEVER used — only the resolved context host.
|
|
110
|
+
const meta = egressMeta(opts);
|
|
111
|
+
const briefOpts = {
|
|
112
|
+
...opts,
|
|
113
|
+
host: meta.host,
|
|
114
|
+
forceLowOnly: opts.forceLowOnly === true,
|
|
115
|
+
enforceHostAllowlist: true, // MED-2: every serve-path read binds to the host allowlist.
|
|
116
|
+
};
|
|
117
|
+
const { text, fields } = renderBrief(profile, briefOpts);
|
|
118
|
+
|
|
119
|
+
// Record the egress ONLY when something actually left (empty brief => no
|
|
120
|
+
// exfiltration => no ledger noise).
|
|
121
|
+
let egress = null;
|
|
122
|
+
if (fields.length > 0) {
|
|
123
|
+
egress = appendEgress({ host: meta.host, session: meta.session, fields });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ok: true, brief: text, fields, egress };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* profileSnapshot(opts) -> { ok, snapshot, fields, egress }. Renders the flat
|
|
131
|
+
* RULES-FILE snapshot (renderSnapshot) for cursor/windsurf/copilot-style standing
|
|
132
|
+
* context, and — when `includePreferences` is set — appends ONLY corroborated +
|
|
133
|
+
* approved + precision-eligible preference slugs (S5 gate). serve.js loads the S3
|
|
134
|
+
* approval registry from disk ONCE and threads it into the pure renderer; an
|
|
135
|
+
* absent/corrupt registry reads as "nothing approved" (fail-closed), so no
|
|
136
|
+
* un-cleared preference can leak. Records what left in the egress ledger. Cold
|
|
137
|
+
* start -> empty snapshot (ok:true). Never throws.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} [opts] forwarded to renderSnapshot (tokenBudget, context,
|
|
140
|
+
* shareSensitive, env, redactFile, includePreferences) plus context.host/
|
|
141
|
+
* context.session for the egress ledger. `forceLowOnly` is honored for passive
|
|
142
|
+
* reads; the host allowlist is enforced on every serve-path read (MED-2).
|
|
143
|
+
*/
|
|
144
|
+
export function profileSnapshot(opts = {}) {
|
|
145
|
+
let profile = null;
|
|
146
|
+
try {
|
|
147
|
+
const r = readProfile();
|
|
148
|
+
profile = r && r.ok ? r.profile : null;
|
|
149
|
+
} catch {
|
|
150
|
+
profile = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Load the approval registry ONCE here (the pure renderer never touches disk).
|
|
154
|
+
// A symlinked/oversized/corrupt store degrades to an empty registry inside
|
|
155
|
+
// readApprovals (fail-closed = nothing approved), so a tampered approvals file
|
|
156
|
+
// can never GRANT injection — it can only withhold it.
|
|
157
|
+
let registry = null;
|
|
158
|
+
if (opts.includePreferences === true) {
|
|
159
|
+
try {
|
|
160
|
+
const ar = readApprovals();
|
|
161
|
+
registry = ar && ar.registry && typeof ar.registry === 'object' ? ar.registry : null;
|
|
162
|
+
} catch {
|
|
163
|
+
registry = null; // fail-closed
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const meta = egressMeta(opts);
|
|
168
|
+
|
|
169
|
+
// --- V3 VOICE FEW-SHOT SEAM (sibling of the S5 includePreferences gate) -----
|
|
170
|
+
// Behind `opts.includeVoiceExemplars === true`, AND honoring the SAME kill-
|
|
171
|
+
// switch the preference path respects, AND requiring a non-empty `opts.taskText`,
|
|
172
|
+
// we retrieve the user's OWN writing samples (ZERO-LLM) and hand them to the
|
|
173
|
+
// PURE renderer to few-shot a "match their voice" block. FAIL-CLOSED on every
|
|
174
|
+
// axis: flag off, kill-switch engaged, empty taskText, retrieval throws, or no
|
|
175
|
+
// exemplars => no voice block, no exemplar-egress disclosure. The gate is the
|
|
176
|
+
// ONLY way `voiceExemplars` reaches renderSnapshot, so with the flag off the
|
|
177
|
+
// output is byte-identical to today's (regression-guarded by the unit test).
|
|
178
|
+
let voiceExemplars = null;
|
|
179
|
+
const wantVoice = opts.includeVoiceExemplars === true;
|
|
180
|
+
const taskText = typeof opts.taskText === 'string' ? opts.taskText.trim() : '';
|
|
181
|
+
if (wantVoice && taskText) {
|
|
182
|
+
// Kill-switch check, same source the preference/brief paths use. Engaged =>
|
|
183
|
+
// never retrieve, never inject, never disclose.
|
|
184
|
+
let killed = false;
|
|
185
|
+
try {
|
|
186
|
+
killed = killSwitchEngaged(loadRedaction({ env: opts.env || process.env, redactFile: opts.redactFile }));
|
|
187
|
+
} catch {
|
|
188
|
+
killed = false;
|
|
189
|
+
}
|
|
190
|
+
if (!killed) {
|
|
191
|
+
try {
|
|
192
|
+
const register = classifyRegister(taskText, 'prompt');
|
|
193
|
+
const k = Number.isFinite(opts.voiceK) && opts.voiceK > 0 ? Math.floor(opts.voiceK) : 3;
|
|
194
|
+
// DI: tests/callers may inject a candidate set; default loads via store.
|
|
195
|
+
const got = retrieveExemplars({
|
|
196
|
+
register,
|
|
197
|
+
taskText,
|
|
198
|
+
k,
|
|
199
|
+
exemplars: Array.isArray(opts.exemplars) ? opts.exemplars : null,
|
|
200
|
+
env: opts.env || process.env,
|
|
201
|
+
});
|
|
202
|
+
voiceExemplars = Array.isArray(got) && got.length > 0 ? got : null;
|
|
203
|
+
} catch {
|
|
204
|
+
voiceExemplars = null; // fail-closed: any retrieval failure => no voice.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const snapOpts = {
|
|
210
|
+
...opts,
|
|
211
|
+
host: meta.host,
|
|
212
|
+
forceLowOnly: opts.forceLowOnly === true,
|
|
213
|
+
enforceHostAllowlist: true, // MED-2: every serve-path read binds to the host allowlist.
|
|
214
|
+
registry,
|
|
215
|
+
voiceExemplars, // null when the gate is off / fail-closed => renderer emits nothing.
|
|
216
|
+
};
|
|
217
|
+
const { text, fields, voice } = renderSnapshot(profile, snapOpts);
|
|
218
|
+
|
|
219
|
+
let egress = null;
|
|
220
|
+
if (fields.length > 0) {
|
|
221
|
+
egress = appendEgress({ host: meta.host, session: meta.session, fields });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Voice disclosure (V4): record ONLY the exemplars that ACTUALLY landed in the
|
|
225
|
+
// block (renderSnapshot returns them in `voice` after its own budget cap), and
|
|
226
|
+
// ONLY through the dedicated exemplar-egress channel. Cloud-host flagged when
|
|
227
|
+
// the resolved host is a cloud surface. Empty `voice` => no disclosure line.
|
|
228
|
+
let voiceEgress = null;
|
|
229
|
+
const landed = Array.isArray(voice) ? voice : [];
|
|
230
|
+
if (landed.length > 0) {
|
|
231
|
+
voiceEgress = appendExemplarEgress({
|
|
232
|
+
ids: landed.map((e) => e && e.id).filter(Boolean),
|
|
233
|
+
host: meta.host,
|
|
234
|
+
session: meta.session,
|
|
235
|
+
cloudHost: isCloudHost(meta.host),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { ok: true, snapshot: text, fields, egress, voice: landed, voiceEgress };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* profileGet(opts) -> { ok, profile:{ style, expertise, inferences }, fields,
|
|
244
|
+
* egress }. The structured, sensitivity-filtered listing. Mirrors the brief's
|
|
245
|
+
* gates (sensitivity tier + redaction + kill-switch) so `get` can never leak
|
|
246
|
+
* more than `brief` would. Cold start -> empty listing (ok:true). Never throws.
|
|
247
|
+
*/
|
|
248
|
+
export function profileGet(opts = {}) {
|
|
249
|
+
const env = opts.env || process.env;
|
|
250
|
+
const redaction = loadRedaction({ env, redactFile: opts.redactFile });
|
|
251
|
+
// Resolve the host from caller context (NOT self-declared) for the MED-2
|
|
252
|
+
// share-hosts allowlist cross-check; honor forceLowOnly for passive reads.
|
|
253
|
+
const meta = egressMeta(opts);
|
|
254
|
+
const shareOpts = {
|
|
255
|
+
env,
|
|
256
|
+
shareSensitive: opts.shareSensitive,
|
|
257
|
+
host: meta.host,
|
|
258
|
+
forceLowOnly: opts.forceLowOnly === true,
|
|
259
|
+
enforceHostAllowlist: true, // MED-2: bind med/high to the share-hosts allowlist.
|
|
260
|
+
shareHostsFile: opts.shareHostsFile,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const empty = { ok: true, profile: { style: {}, expertise: {}, inferences: [] }, fields: [], egress: null };
|
|
264
|
+
|
|
265
|
+
// Kill-switch: hard stop, return nothing.
|
|
266
|
+
if (killSwitchEngaged(redaction)) return empty;
|
|
267
|
+
|
|
268
|
+
let profile = null;
|
|
269
|
+
try {
|
|
270
|
+
const r = readProfile();
|
|
271
|
+
profile = r && r.ok ? r.profile : null;
|
|
272
|
+
} catch {
|
|
273
|
+
profile = null;
|
|
274
|
+
}
|
|
275
|
+
if (!profile || typeof profile !== 'object' || !profile.global) return empty;
|
|
276
|
+
|
|
277
|
+
const context = opts.context && typeof opts.context === 'object' ? opts.context : {};
|
|
278
|
+
const overlayKey = typeof context.overlay === 'string' ? context.overlay : null;
|
|
279
|
+
const overlay = overlayKey && profile.overlays ? profile.overlays[overlayKey] : null;
|
|
280
|
+
|
|
281
|
+
const out = { style: {}, expertise: {}, inferences: [] };
|
|
282
|
+
const fields = [];
|
|
283
|
+
|
|
284
|
+
// Style — confirmed axes only, low-sensitivity, redaction-gated. Overlay
|
|
285
|
+
// overrides global per axis.
|
|
286
|
+
for (const axis of STYLE_AXES) {
|
|
287
|
+
const a = (overlay && overlay.style && overlay.style[axis])
|
|
288
|
+
|| (profile.global.style && profile.global.style[axis]);
|
|
289
|
+
if (!a || !styleAxisConfirmed(a)) continue;
|
|
290
|
+
const fieldId = `style:${axis}`;
|
|
291
|
+
if (!sensitivityAllowed(fieldSensitivity({ kind: 'style' }), shareOpts)) continue;
|
|
292
|
+
if (isRedacted(fieldId, redaction)) continue;
|
|
293
|
+
out.style[axis] = { ema: a.ema, evidence_count: a.evidence_count };
|
|
294
|
+
fields.push(fieldId);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Expertise — banded domains only, low-sensitivity, redaction-gated.
|
|
298
|
+
if (profile.expertise && typeof profile.expertise === 'object') {
|
|
299
|
+
for (const [domain, rec] of Object.entries(profile.expertise)) {
|
|
300
|
+
const band = expertiseBand(rec);
|
|
301
|
+
if (band === 'unknown') continue;
|
|
302
|
+
const fieldId = `expertise:${domain}`;
|
|
303
|
+
if (!sensitivityAllowed(fieldSensitivity({ kind: 'expertise' }), shareOpts)) continue;
|
|
304
|
+
if (isRedacted(fieldId, redaction)) continue;
|
|
305
|
+
out.expertise[domain] = { band, wilsonLB: rec.wilsonLB, n: rec.n };
|
|
306
|
+
fields.push(fieldId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Inferences — confidence/evidence floor + sensitivity tier + redaction.
|
|
311
|
+
const byId = new Map();
|
|
312
|
+
for (const inf of (Array.isArray(profile.global.dialectic) ? profile.global.dialectic : [])) {
|
|
313
|
+
if (inf && inf.id) byId.set(inf.id, inf);
|
|
314
|
+
}
|
|
315
|
+
if (overlay && Array.isArray(overlay.dialectic)) {
|
|
316
|
+
for (const inf of overlay.dialectic) if (inf && inf.id) byId.set(inf.id, inf);
|
|
317
|
+
}
|
|
318
|
+
for (const inf of byId.values()) {
|
|
319
|
+
// Inclusion floor shared with the brief (audit L2): confidence > 0.45 AND
|
|
320
|
+
// evidence_count >= 3 so a corroborated dialectic trait (conf 0.5) can
|
|
321
|
+
// surface while the evidence gate remains the real corroboration barrier.
|
|
322
|
+
if (!(Number(inf.confidence) > BRIEF_MIN_CONFIDENCE
|
|
323
|
+
&& (Number(inf.evidence_count) || 0) >= BRIEF_MIN_EVIDENCE)) continue;
|
|
324
|
+
const sens = fieldSensitivity({ kind: 'inference', sensitivity: inf.sensitivity });
|
|
325
|
+
if (!sensitivityAllowed(sens, shareOpts)) continue;
|
|
326
|
+
if (isRedacted(inf.id, redaction)) continue;
|
|
327
|
+
out.inferences.push({
|
|
328
|
+
id: inf.id,
|
|
329
|
+
kind: inf.kind,
|
|
330
|
+
subject: inf.subject,
|
|
331
|
+
value: inf.value,
|
|
332
|
+
confidence: inf.confidence,
|
|
333
|
+
evidence_count: inf.evidence_count,
|
|
334
|
+
sensitivity: sens,
|
|
335
|
+
});
|
|
336
|
+
fields.push(inf.id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let egress = null;
|
|
340
|
+
if (fields.length > 0) {
|
|
341
|
+
egress = appendEgress({ host: meta.host, session: meta.session, fields });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { ok: true, profile: out, fields, egress };
|
|
345
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile/store.js — Cross-system profile bus, P0.2 + P0.3.
|
|
3
|
+
*
|
|
4
|
+
* The user-global profile store: path resolution + atomic read/write with a
|
|
5
|
+
* `.bak` backup and a symlink guard.
|
|
6
|
+
*
|
|
7
|
+
* KEY INVARIANT (design-v2 §2, audit MED-Storage): this tier is **user-global,
|
|
8
|
+
* homedir-rooted, and EXEMPT from the per-project PROJECT_HASH namespacing**.
|
|
9
|
+
* Project memory is REPO_ROOT-scoped to prevent cross-project worming; the
|
|
10
|
+
* profile is the deliberate, documented exception (it's about the user, not the
|
|
11
|
+
* project). Therefore every path resolves from `os.homedir()` — NEVER from CWD
|
|
12
|
+
* or REPO_ROOT — so two host processes in two projects converge on ONE file.
|
|
13
|
+
*
|
|
14
|
+
* Layout-v2 note: a future rename moves `.ijfw/` → `ijfw/`. We key off homedir
|
|
15
|
+
* + a single base-dir constant so that rename is a one-line change here, not a
|
|
16
|
+
* literal sprinkled across the codebase.
|
|
17
|
+
*
|
|
18
|
+
* Atomic write: temp file in the SAME directory → fsync → rename. The target is
|
|
19
|
+
* opened with O_NOFOLLOW (via openSync flags) so a symlinked target is refused
|
|
20
|
+
* rather than followed (anti-symlink-swap). A `.bak` copy of the prior good
|
|
21
|
+
* content is kept; a corrupt/unparseable primary is recovered from `.bak`.
|
|
22
|
+
*
|
|
23
|
+
* Zero deps, Node built-ins only. NO LLM calls.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
openSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
fsyncSync,
|
|
30
|
+
closeSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
unlinkSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
copyFileSync,
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
lstatSync,
|
|
38
|
+
constants as fsConstants,
|
|
39
|
+
} from 'node:fs';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
import { randomBytes } from 'node:crypto';
|
|
42
|
+
|
|
43
|
+
import { makeProfile, serializeProfile, parseProfile } from './schema.js';
|
|
44
|
+
import { resolveOverrideDir, homedirProfileDefault } from './path-policy.js';
|
|
45
|
+
|
|
46
|
+
const PROFILE_FILE = 'user-profile.md';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Max bytes we will read from an on-disk profile (audit LOW: read-size cap).
|
|
50
|
+
* The profile is bounded by P0.6 eviction/decay, so a file larger than this is
|
|
51
|
+
* a hand-edited or corrupt artifact; refusing to slurp it whole avoids an OOM
|
|
52
|
+
* on a pathological input. 4 MiB is ~orders of magnitude above any legitimate
|
|
53
|
+
* profile. A too-large primary falls through to .bak recovery (readProfile).
|
|
54
|
+
*/
|
|
55
|
+
const MAX_PROFILE_BYTES = 4 * 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The user-global profile directory. Override via IJFW_PROFILE_DIR (tests +
|
|
59
|
+
* power users). Default: `~/.ijfw/profile`. Resolved from homedir, never CWD.
|
|
60
|
+
*
|
|
61
|
+
* SECURITY (audit HIGH-4): a verbatim env override is a relocation/identity-
|
|
62
|
+
* partition-defeat surface, so the override is validated by `resolveOverrideDir`
|
|
63
|
+
* — honored only under a test runner OR when it resolves under os.homedir() and
|
|
64
|
+
* is uid-owned. An unsafe override falls back to the default homedir path.
|
|
65
|
+
*
|
|
66
|
+
* TEST-ISOLATION: when there is no safe override, the default is resolved by
|
|
67
|
+
* `homedirProfileDefault`, which under a test context returns a process-unique
|
|
68
|
+
* `os.tmpdir()` scratch dir (never the real homedir) — so a test that forgot to
|
|
69
|
+
* set IJFW_PROFILE_DIR is auto-isolated and can never write into the user's real
|
|
70
|
+
* `~/.ijfw/profile`. Production behavior is unchanged (real homedir).
|
|
71
|
+
*/
|
|
72
|
+
export function profileDir() {
|
|
73
|
+
const safe = resolveOverrideDir(process.env.IJFW_PROFILE_DIR);
|
|
74
|
+
if (safe) return safe;
|
|
75
|
+
return homedirProfileDefault(['.ijfw', 'profile']);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function profilePath() {
|
|
79
|
+
return join(profileDir(), PROFILE_FILE);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function backupPath() {
|
|
83
|
+
return `${profilePath()}.bak`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The user-global archive dir (P0.6 decay-to-archive lands files here). */
|
|
87
|
+
export function archiveDir() {
|
|
88
|
+
return join(profileDir(), 'archive');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ensureDir(dir) {
|
|
92
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* True iff `p` exists AND is a symlink. We refuse to read/write through a
|
|
97
|
+
* symlinked target — an attacker who can pre-create the profile path as a
|
|
98
|
+
* symlink could otherwise redirect our atomic rename / our read at an arbitrary
|
|
99
|
+
* file. lstat() (not stat()) so the link itself is inspected, not its target.
|
|
100
|
+
*/
|
|
101
|
+
function isSymlink(p) {
|
|
102
|
+
try {
|
|
103
|
+
return lstatSync(p).isSymbolicLink();
|
|
104
|
+
} catch {
|
|
105
|
+
return false; // absent → not a symlink
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* writeProfile(profile) — atomic, backup-keeping, symlink-guarded write.
|
|
111
|
+
* Returns { ok:true } or { ok:false, code, message }. Never throws.
|
|
112
|
+
*/
|
|
113
|
+
export function writeProfile(profile) {
|
|
114
|
+
const target = profilePath();
|
|
115
|
+
// Symlink guard: refuse a symlinked target outright.
|
|
116
|
+
if (isSymlink(target)) {
|
|
117
|
+
return { ok: false, code: 'EPROFILE_SYMLINK', message: `refusing symlinked target: ${target}` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let content;
|
|
121
|
+
try {
|
|
122
|
+
content = serializeProfile(profile);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return { ok: false, code: 'ESERIALIZE', message: err.message };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
ensureDir(profileDir());
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return { ok: false, code: err.code || 'EMKDIR', message: err.message };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Back up the prior good content (best-effort) BEFORE we overwrite, so a
|
|
134
|
+
// crash mid-rename leaves a recoverable .bak. Guard BOTH sides against a
|
|
135
|
+
// symlink swap: the source (`target`) so we don't read through a link, AND
|
|
136
|
+
// the destination (`backupPath()`) — a pre-planted `user-profile.md.bak`
|
|
137
|
+
// symlink would otherwise let copyFileSync follow it and overwrite an
|
|
138
|
+
// arbitrary file. If the .bak path is a symlink, drop it (unlink) and copy to
|
|
139
|
+
// a real file instead; never write through the link.
|
|
140
|
+
if (existsSync(target) && !isSymlink(target)) {
|
|
141
|
+
try {
|
|
142
|
+
const bak = backupPath();
|
|
143
|
+
if (isSymlink(bak)) {
|
|
144
|
+
try { unlinkSync(bak); } catch {}
|
|
145
|
+
}
|
|
146
|
+
copyFileSync(target, bak);
|
|
147
|
+
} catch {
|
|
148
|
+
// non-fatal — proceed; worst case is no fresh backup this round.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const tmp = `${target}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
153
|
+
let fd;
|
|
154
|
+
try {
|
|
155
|
+
// O_NOFOLLOW on the tmp create is belt-and-suspenders; the tmp name is
|
|
156
|
+
// random so it cannot pre-exist as an attacker symlink, but we keep the
|
|
157
|
+
// flag for defense in depth. O_EXCL ensures we never open an existing file.
|
|
158
|
+
// NOTE: O_NOFOLLOW is a no-op on Windows (no symlink semantics there) — the
|
|
159
|
+
// real cross-platform guard is the isSymlink() (lstat) check above/below.
|
|
160
|
+
fd = openSync(
|
|
161
|
+
tmp,
|
|
162
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
|
|
163
|
+
0o600,
|
|
164
|
+
);
|
|
165
|
+
writeFileSync(fd, content, 'utf8');
|
|
166
|
+
fsyncSync(fd);
|
|
167
|
+
closeSync(fd);
|
|
168
|
+
fd = null;
|
|
169
|
+
// Re-check the target right before rename — TOCTOU narrowing.
|
|
170
|
+
if (isSymlink(target)) {
|
|
171
|
+
try { unlinkSync(tmp); } catch {}
|
|
172
|
+
return { ok: false, code: 'EPROFILE_SYMLINK', message: `target became a symlink: ${target}` };
|
|
173
|
+
}
|
|
174
|
+
renameSync(tmp, target);
|
|
175
|
+
// Durability: rename() is atomic, but on Linux ext4/XFS the parent
|
|
176
|
+
// directory entry is not guaranteed to survive a power-fail until the
|
|
177
|
+
// directory itself is fsynced — even though the file data was fsynced
|
|
178
|
+
// above. Best-effort fsync the parent dir to durably commit the new entry.
|
|
179
|
+
// Wrapped in try/catch: rename atomicity already holds, so a failed dir
|
|
180
|
+
// fsync degrades durability, not correctness, and must not fail the write.
|
|
181
|
+
try {
|
|
182
|
+
const dirFd = openSync(profileDir(), fsConstants.O_RDONLY);
|
|
183
|
+
try {
|
|
184
|
+
fsyncSync(dirFd);
|
|
185
|
+
} finally {
|
|
186
|
+
closeSync(dirFd);
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// best-effort — some platforms (e.g. Windows) cannot fsync a directory.
|
|
190
|
+
}
|
|
191
|
+
return { ok: true };
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (fd != null) { try { closeSync(fd); } catch {} }
|
|
194
|
+
try { unlinkSync(tmp); } catch {}
|
|
195
|
+
return { ok: false, code: err.code || 'EWRITE', message: err.message };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function tryParseFile(p) {
|
|
200
|
+
// Refuse symlinked source.
|
|
201
|
+
if (isSymlink(p)) return { ok: false, code: 'EPROFILE_SYMLINK' };
|
|
202
|
+
// Read-size cap (audit LOW): refuse to slurp a pathologically large profile
|
|
203
|
+
// into memory. The profile is bounded by P0.6, so an oversized file is a
|
|
204
|
+
// corrupt/hand-edited artifact — surface ETOOBIG so readProfile falls through
|
|
205
|
+
// to .bak recovery rather than OOMing on the read.
|
|
206
|
+
try {
|
|
207
|
+
const st = lstatSync(p);
|
|
208
|
+
if (st.isFile() && st.size > MAX_PROFILE_BYTES) {
|
|
209
|
+
return { ok: false, code: 'ETOOBIG', message: `profile exceeds ${MAX_PROFILE_BYTES} bytes` };
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// stat failure falls through to the read, which will surface its own error.
|
|
213
|
+
}
|
|
214
|
+
let raw;
|
|
215
|
+
try {
|
|
216
|
+
raw = readFileSync(p, 'utf8');
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { ok: false, code: err.code || 'EREAD', message: err.message };
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
return { ok: true, profile: parseProfile(raw) };
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return { ok: false, code: 'EPARSE', message: err.message };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* readProfile() — read the user-global profile.
|
|
229
|
+
* - missing file → fresh default profile, { ok:true, created:true }
|
|
230
|
+
* - good file → { ok:true, profile }
|
|
231
|
+
* - corrupt file → recover from .bak → { ok:true, profile, recovered:true }
|
|
232
|
+
* - symlinked → { ok:false } (refused)
|
|
233
|
+
*/
|
|
234
|
+
export function readProfile() {
|
|
235
|
+
const target = profilePath();
|
|
236
|
+
|
|
237
|
+
if (isSymlink(target)) {
|
|
238
|
+
return { ok: false, code: 'EPROFILE_SYMLINK', message: `refusing symlinked target: ${target}` };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!existsSync(target)) {
|
|
242
|
+
return { ok: true, profile: makeProfile(), created: true };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const primary = tryParseFile(target);
|
|
246
|
+
if (primary.ok) return { ok: true, profile: primary.profile };
|
|
247
|
+
|
|
248
|
+
// Primary unparseable — attempt backup recovery.
|
|
249
|
+
const bak = backupPath();
|
|
250
|
+
if (existsSync(bak) && !isSymlink(bak)) {
|
|
251
|
+
const recovered = tryParseFile(bak);
|
|
252
|
+
if (recovered.ok) {
|
|
253
|
+
return { ok: true, profile: recovered.profile, recovered: true };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Neither primary nor backup is usable. Surface the error rather than
|
|
258
|
+
// silently clobbering with a default (caller decides; merge layer will hold
|
|
259
|
+
// the lock and can choose to reinitialize).
|
|
260
|
+
return { ok: false, code: 'ECORRUPT', message: 'primary unparseable and no usable backup' };
|
|
261
|
+
}
|