@ijfw/memory-server 1.5.5 → 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.
Files changed (72) hide show
  1. package/bin/ijfw-dashboard +20 -1
  2. package/package.json +4 -3
  3. package/src/audit-roster.js +89 -12
  4. package/src/brain/tiered-llm.js +57 -7
  5. package/src/cross-orchestrator-cli.js +344 -4
  6. package/src/cross-project-search.js +39 -1
  7. package/src/dashboard-server.js +7 -1
  8. package/src/dream/runner.mjs +560 -8
  9. package/src/handlers/brain-handler.js +101 -1
  10. package/src/importers/discover.js +1 -1
  11. package/src/memory/bench-metrics.js +289 -0
  12. package/src/memory/benchmark.js +1 -1
  13. package/src/memory/search.js +53 -1
  14. package/src/orchestrator/plan-checker.js +1 -1
  15. package/src/profile/audit.js +671 -0
  16. package/src/profile/capture.js +871 -0
  17. package/src/profile/derive-dialectic.js +242 -0
  18. package/src/profile/derive-heuristic.js +733 -0
  19. package/src/profile/derive.js +156 -0
  20. package/src/profile/egress.js +306 -0
  21. package/src/profile/eval/build-real-probes.mjs +197 -0
  22. package/src/profile/eval/corpus-from-reddit.mjs +166 -0
  23. package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
  24. package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
  25. package/src/profile/eval/gate-b-behavior.mjs +420 -0
  26. package/src/profile/eval/gate-b-decision-run.mjs +171 -0
  27. package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
  28. package/src/profile/eval/gate-b-run.mjs +417 -0
  29. package/src/profile/eval/gate-b-run.test.mjs +204 -0
  30. package/src/profile/eval/gate-c-capture.mjs +323 -0
  31. package/src/profile/eval/harness.mjs +551 -0
  32. package/src/profile/eval/instrument-validation.mjs +248 -0
  33. package/src/profile/eval/instrument-validation.test.mjs +125 -0
  34. package/src/profile/eval/multi-subject-harness.mjs +106 -0
  35. package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
  36. package/src/profile/eval/personas.test.mjs +83 -0
  37. package/src/profile/eval/plumbing.test.mjs +69 -0
  38. package/src/profile/eval/prereg.mjs +130 -0
  39. package/src/profile/eval/prereg.test.mjs +78 -0
  40. package/src/profile/eval/real-corpus.test.mjs +103 -0
  41. package/src/profile/eval/real-personas.mjs +109 -0
  42. package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
  43. package/src/profile/eval/run-real-corpus.mjs +358 -0
  44. package/src/profile/eval/slug-quality.mjs +464 -0
  45. package/src/profile/eval/stylometry-features.js +85 -0
  46. package/src/profile/eval/stylometry-reference.js +16 -0
  47. package/src/profile/eval/stylometry.js +224 -0
  48. package/src/profile/eval/stylometry.test.mjs +103 -0
  49. package/src/profile/eval/synthetic-personas.js +91 -0
  50. package/src/profile/eval/verifier-features.mjs +170 -0
  51. package/src/profile/eval/verifier-logreg.mjs +74 -0
  52. package/src/profile/eval/verifier-pair.mjs +122 -0
  53. package/src/profile/eval/verifier-reference.mjs +68 -0
  54. package/src/profile/eval/verifier-scorer.mjs +30 -0
  55. package/src/profile/eval/wrong-target-control.mjs +168 -0
  56. package/src/profile/eval/wrong-target-control.test.mjs +124 -0
  57. package/src/profile/exemplar-capture.js +232 -0
  58. package/src/profile/exemplar-retrieve.js +138 -0
  59. package/src/profile/exemplar-store.js +314 -0
  60. package/src/profile/lock.js +64 -0
  61. package/src/profile/merge.js +624 -0
  62. package/src/profile/path-policy.js +213 -0
  63. package/src/profile/precision-stamp.mjs +151 -0
  64. package/src/profile/render-brief.js +717 -0
  65. package/src/profile/schema.js +244 -0
  66. package/src/profile/sensitivity.js +249 -0
  67. package/src/profile/serve.js +345 -0
  68. package/src/profile/store.js +261 -0
  69. package/src/profile/telemetry.js +289 -0
  70. package/src/recovery/checkpoint.js +7 -1
  71. package/src/server.js +185 -14
  72. 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
+ }