@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,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile/schema.js — Cross-system profile bus, P0.1.
|
|
3
|
+
*
|
|
4
|
+
* The portable user-profile atom + container schema, plus a lossless
|
|
5
|
+
* markdown+YAML-frontmatter (de)serialization. ZERO dependencies, Node
|
|
6
|
+
* built-ins only. NO LLM calls (a later CI guard enforces this module never
|
|
7
|
+
* reaches the LLM tier).
|
|
8
|
+
*
|
|
9
|
+
* Storage format (human-readable + hand-editable, per design-v2 §4):
|
|
10
|
+
* ---
|
|
11
|
+
* schema_version: N
|
|
12
|
+
* <scalar summary frontmatter>
|
|
13
|
+
* ---
|
|
14
|
+
*
|
|
15
|
+
* # IJFW User Profile
|
|
16
|
+
* ...prose...
|
|
17
|
+
*
|
|
18
|
+
* ```json
|
|
19
|
+
* { ...the full structured UserProfile... }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The fenced JSON block is the canonical, lossless record (round-trip
|
|
23
|
+
* identity). The frontmatter carries human-glanceable scalars + the
|
|
24
|
+
* schema_version. We hand-roll a *minimal* flat-scalar frontmatter
|
|
25
|
+
* parse/serialize (no YAML dep); nested structure lives in the JSON block so
|
|
26
|
+
* round-trip fidelity never depends on a hand-rolled recursive YAML parser.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const SCHEMA_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/** The four EMA+Beta style axes (design-v2 §4). */
|
|
32
|
+
export const STYLE_AXES = Object.freeze(['formality', 'energy', 'terseness', 'emoji_use']);
|
|
33
|
+
|
|
34
|
+
const SENSITIVITIES = Object.freeze(['low', 'med', 'high']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deterministic, stable id for an inference so two sessions deriving the "same"
|
|
38
|
+
* trait dedupe to one atom on merge. Keyed on kind+subject (NOT value — a
|
|
39
|
+
* changed value for the same subject is an *update*, not a new atom).
|
|
40
|
+
*/
|
|
41
|
+
export function inferenceId(kind, subject) {
|
|
42
|
+
return `${String(kind)}::${String(subject)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* makeInference — the best-in-field atom (Honcho + Copilot + BEP):
|
|
47
|
+
* { id, kind, subject, value, confidence, evidence_count, last_confirmed,
|
|
48
|
+
* source_sessions[], source_hosts[], sensitivity }.
|
|
49
|
+
*/
|
|
50
|
+
export function makeInference(fields = {}) {
|
|
51
|
+
const {
|
|
52
|
+
kind,
|
|
53
|
+
subject,
|
|
54
|
+
value,
|
|
55
|
+
confidence = 0.0,
|
|
56
|
+
evidence_count = 0,
|
|
57
|
+
last_confirmed = new Date(0).toISOString(),
|
|
58
|
+
source_sessions = [],
|
|
59
|
+
source_hosts = [],
|
|
60
|
+
sensitivity = 'low',
|
|
61
|
+
} = fields;
|
|
62
|
+
|
|
63
|
+
if (typeof kind !== 'string' || kind.length === 0) {
|
|
64
|
+
throw new TypeError('makeInference: kind must be a non-empty string');
|
|
65
|
+
}
|
|
66
|
+
if (typeof subject !== 'string' || subject.length === 0) {
|
|
67
|
+
throw new TypeError('makeInference: subject must be a non-empty string');
|
|
68
|
+
}
|
|
69
|
+
if (!SENSITIVITIES.includes(sensitivity)) {
|
|
70
|
+
throw new TypeError(
|
|
71
|
+
`makeInference: sensitivity must be one of ${SENSITIVITIES.join('|')} (got ${JSON.stringify(sensitivity)})`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const c = Number(confidence);
|
|
75
|
+
if (!Number.isFinite(c) || c < 0 || c > 1) {
|
|
76
|
+
throw new TypeError(`makeInference: confidence must be in [0,1] (got ${JSON.stringify(confidence)})`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
id: inferenceId(kind, subject),
|
|
81
|
+
kind,
|
|
82
|
+
subject,
|
|
83
|
+
value: value === undefined ? null : value,
|
|
84
|
+
confidence: c,
|
|
85
|
+
evidence_count: Number(evidence_count) || 0,
|
|
86
|
+
last_confirmed,
|
|
87
|
+
source_sessions: [...source_sessions],
|
|
88
|
+
source_hosts: [...source_hosts],
|
|
89
|
+
sensitivity,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One style axis: EMA point estimate + Beta(α,β) uncertainty (graceful cold start). */
|
|
94
|
+
function makeStyleAxis() {
|
|
95
|
+
// Uniform Beta(1,1) prior = "unconfirmed" until evidence accrues.
|
|
96
|
+
return { ema: 0.5, alpha: 1, beta: 1, evidence_count: 0 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* makeProfile — the partitioned UserProfile container (design-v2 §4):
|
|
101
|
+
* global.style (4 EMA+Beta axes) + global.dialectic[] + overlays + expertise +
|
|
102
|
+
* provenance + egress_log_ref + schema_version.
|
|
103
|
+
*/
|
|
104
|
+
export function makeProfile() {
|
|
105
|
+
const style = {};
|
|
106
|
+
for (const axis of STYLE_AXES) style[axis] = makeStyleAxis();
|
|
107
|
+
return {
|
|
108
|
+
schema_version: SCHEMA_VERSION,
|
|
109
|
+
global: {
|
|
110
|
+
style,
|
|
111
|
+
dialectic: [],
|
|
112
|
+
},
|
|
113
|
+
overlays: {},
|
|
114
|
+
expertise: {},
|
|
115
|
+
provenance: {
|
|
116
|
+
created: new Date(0).toISOString(),
|
|
117
|
+
updated: new Date(0).toISOString(),
|
|
118
|
+
},
|
|
119
|
+
egress_log_ref: null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Minimal flat-scalar YAML frontmatter (no dependency).
|
|
125
|
+
// Only string/number/boolean/null scalars are supported — nested data lives in
|
|
126
|
+
// the canonical JSON block, so we never need a recursive YAML parser.
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function serializeFrontmatterValue(v) {
|
|
130
|
+
if (v === null || v === undefined) return 'null';
|
|
131
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
132
|
+
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null';
|
|
133
|
+
// Quote strings to keep them unambiguous (colons, leading spaces, etc.).
|
|
134
|
+
return JSON.stringify(String(v));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseFrontmatterValue(raw) {
|
|
138
|
+
const s = raw.trim();
|
|
139
|
+
if (s === 'null' || s === '~' || s === '') return null;
|
|
140
|
+
if (s === 'true') return true;
|
|
141
|
+
if (s === 'false') return false;
|
|
142
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- anchored ^...$, \d+ then a disambiguated optional group; linear.
|
|
143
|
+
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
|
|
144
|
+
if (s.startsWith('"')) {
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(s);
|
|
147
|
+
} catch {
|
|
148
|
+
return s;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return s;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseFrontmatter(block) {
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const line of block.split('\n')) {
|
|
157
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
158
|
+
const idx = line.indexOf(':');
|
|
159
|
+
if (idx === -1) continue;
|
|
160
|
+
const key = line.slice(0, idx).trim();
|
|
161
|
+
if (!key) continue;
|
|
162
|
+
out[key] = parseFrontmatterValue(line.slice(idx + 1));
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Human-glanceable summary scalars surfaced in the frontmatter. */
|
|
168
|
+
function summaryScalars(profile) {
|
|
169
|
+
const fm = {
|
|
170
|
+
schema_version:
|
|
171
|
+
typeof profile.schema_version === 'number' ? profile.schema_version : SCHEMA_VERSION,
|
|
172
|
+
};
|
|
173
|
+
if (profile.provenance && typeof profile.provenance === 'object') {
|
|
174
|
+
if (profile.provenance.updated) fm.updated = profile.provenance.updated;
|
|
175
|
+
if (profile.provenance.created) fm.created = profile.provenance.created;
|
|
176
|
+
}
|
|
177
|
+
const dialectic = profile.global && Array.isArray(profile.global.dialectic)
|
|
178
|
+
? profile.global.dialectic.length : 0;
|
|
179
|
+
fm.dialectic_count = dialectic;
|
|
180
|
+
fm.expertise_domains = profile.expertise ? Object.keys(profile.expertise).length : 0;
|
|
181
|
+
return fm;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* serializeProfile(profile) -> markdown text. Lossless via the canonical JSON
|
|
186
|
+
* block; the frontmatter is a convenience surface only.
|
|
187
|
+
*/
|
|
188
|
+
export function serializeProfile(profile) {
|
|
189
|
+
const fm = summaryScalars(profile);
|
|
190
|
+
const fmLines = Object.entries(fm)
|
|
191
|
+
.map(([k, v]) => `${k}: ${serializeFrontmatterValue(v)}`)
|
|
192
|
+
.join('\n');
|
|
193
|
+
const json = JSON.stringify(profile, null, 2);
|
|
194
|
+
return (
|
|
195
|
+
`---\n${fmLines}\n---\n\n`
|
|
196
|
+
+ '# IJFW User Profile\n\n'
|
|
197
|
+
+ 'Portable, user-global interaction profile. Inspect or hand-edit the\n'
|
|
198
|
+
+ 'canonical record in the JSON block below. Re-derived at SessionEnd.\n\n'
|
|
199
|
+
+ '```json\n'
|
|
200
|
+
+ `${json}\n`
|
|
201
|
+
+ '```\n'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* parseProfile(text) -> profile object. Tolerant of unknown fields and of a
|
|
207
|
+
* hand-edited file missing schema_version (stamped on parse). Throws only when
|
|
208
|
+
* the canonical JSON block is absent/unparseable (structurally unrecoverable).
|
|
209
|
+
*/
|
|
210
|
+
export function parseProfile(text) {
|
|
211
|
+
if (typeof text !== 'string') {
|
|
212
|
+
throw new TypeError('parseProfile: text must be a string');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Frontmatter (optional, advisory).
|
|
216
|
+
let frontmatter = {};
|
|
217
|
+
const fmMatch = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
218
|
+
if (fmMatch) frontmatter = parseFrontmatter(fmMatch[1]);
|
|
219
|
+
|
|
220
|
+
// Canonical JSON block (required).
|
|
221
|
+
const jsonMatch = /```json\s*\n([\s\S]*?)\n```/.exec(text);
|
|
222
|
+
if (!jsonMatch) {
|
|
223
|
+
throw new Error('parseProfile: no canonical ```json``` profile block found');
|
|
224
|
+
}
|
|
225
|
+
let obj;
|
|
226
|
+
try {
|
|
227
|
+
obj = JSON.parse(jsonMatch[1]);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
throw new Error(`parseProfile: canonical JSON block is unparseable: ${err.message}`);
|
|
230
|
+
}
|
|
231
|
+
if (!obj || typeof obj !== 'object') {
|
|
232
|
+
throw new Error('parseProfile: canonical JSON block did not yield an object');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// schema_version invariant: never undefined. Prefer the block, fall back to
|
|
236
|
+
// frontmatter, then the current SCHEMA_VERSION.
|
|
237
|
+
if (typeof obj.schema_version !== 'number') {
|
|
238
|
+
obj.schema_version =
|
|
239
|
+
typeof frontmatter.schema_version === 'number'
|
|
240
|
+
? frontmatter.schema_version
|
|
241
|
+
: SCHEMA_VERSION;
|
|
242
|
+
}
|
|
243
|
+
return obj;
|
|
244
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile/sensitivity.js — Cross-system profile bus, PHASE P4 (exfiltration guard).
|
|
3
|
+
*
|
|
4
|
+
* The data-minimization gate that decides WHAT may leave the machine when a
|
|
5
|
+
* brief is rendered (design-v2 §6 "data minimization" + §7 "exfiltration").
|
|
6
|
+
* Three concerns live here, all pure and zero-dep / zero-LLM:
|
|
7
|
+
*
|
|
8
|
+
* 1. SENSITIVITY TIERING — every renderable field is low / med / high.
|
|
9
|
+
* - low : style axes (formality/energy/terseness/emoji_use) + expertise
|
|
10
|
+
* bands. Behavioural fingerprint, not content. Shared by default.
|
|
11
|
+
* - med : preferences / corrections / rules — what the user asked for.
|
|
12
|
+
* - high: anything an inference explicitly tags `high` (e.g. a derived
|
|
13
|
+
* dialectic belief about the user). Never shared unless opted in.
|
|
14
|
+
* `brief` returns ONLY low-sensitivity fields by default; med/high require
|
|
15
|
+
* a per-host opt-in AND the resolved host being on the share-hosts
|
|
16
|
+
* allowlist (audit MED-2). The opt-in alone (env flag / arg) is necessary
|
|
17
|
+
* but NOT sufficient — a self-declared host can never grant itself
|
|
18
|
+
* sensitive fields; an operator must add it to the allowlist.
|
|
19
|
+
*
|
|
20
|
+
* 2. REDACTION LIST — a user-controlled denylist of field ids / substrings
|
|
21
|
+
* that must NEVER leave, honored BEFORE any field is emitted. Sourced from
|
|
22
|
+
* the env (IJFW_PROFILE_REDACT, comma/newline separated) AND a plaintext
|
|
23
|
+
* file at ~/.ijfw/profile/redact.txt (one pattern per line, # comments).
|
|
24
|
+
*
|
|
25
|
+
* 3. KILL-SWITCH — a global "share nothing" override. Engaged by the env var
|
|
26
|
+
* IJFW_PROFILE_KILL (truthy) or a bare `*` / `KILL` line in redact.txt.
|
|
27
|
+
* When engaged, renderBrief emits an EMPTY brief regardless of everything
|
|
28
|
+
* else — the user's hard stop on profile egress.
|
|
29
|
+
*
|
|
30
|
+
* Zero deps, Node built-ins only. NO LLM calls.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
|
|
36
|
+
import { profileDir } from './store.js';
|
|
37
|
+
|
|
38
|
+
/** Sensitivity levels, ordered low → high (index = severity). */
|
|
39
|
+
export const SENSITIVITY_ORDER = Object.freeze(['low', 'med', 'high']);
|
|
40
|
+
|
|
41
|
+
/** The four style axes are always low-sensitivity (behavioural fingerprint). */
|
|
42
|
+
const STYLE_LOW = 'low';
|
|
43
|
+
/** Expertise bands are low-sensitivity (a competence summary, not content). */
|
|
44
|
+
const EXPERTISE_LOW = 'low';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* fieldSensitivity(field) -> 'low' | 'med' | 'high'.
|
|
48
|
+
*
|
|
49
|
+
* `field` is a descriptor `{ kind, sensitivity? }`:
|
|
50
|
+
* - kind 'style' -> low (always).
|
|
51
|
+
* - kind 'expertise' -> low (always).
|
|
52
|
+
* - kind 'inference' -> the inference's OWN sensitivity tag (schema.js sets
|
|
53
|
+
* it; preferences default to 'med'), clamped to a known
|
|
54
|
+
* level. Unknown/absent -> 'med' (fail-safe: an
|
|
55
|
+
* un-tagged inference is treated as at least med, never
|
|
56
|
+
* silently low).
|
|
57
|
+
*/
|
|
58
|
+
export function fieldSensitivity(field) {
|
|
59
|
+
if (!field || typeof field !== 'object') return 'med';
|
|
60
|
+
if (field.kind === 'style') return STYLE_LOW;
|
|
61
|
+
if (field.kind === 'expertise') return EXPERTISE_LOW;
|
|
62
|
+
// inference (preference/trait/dialectic): honor its own tag, fail-safe to med.
|
|
63
|
+
const tag = String(field.sensitivity || '').toLowerCase();
|
|
64
|
+
if (SENSITIVITY_ORDER.includes(tag)) return tag;
|
|
65
|
+
return 'med';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* sensitivityAllowed(level, opts) -> boolean. Low is ALWAYS allowed. med/high
|
|
70
|
+
* require BOTH (audit MED-2 + MED-3):
|
|
71
|
+
* - the per-call/per-env share-sensitive opt-in (shareSensitive), AND
|
|
72
|
+
* - the resolved host being on the share-hosts allowlist (hostAllowedSensitive).
|
|
73
|
+
* - and NOT opts.forceLowOnly (the passive resource path forces low-only —
|
|
74
|
+
* audit MED-3: the user cannot consent per passive read, so a passive
|
|
75
|
+
* injection NEVER honors a sensitive opt-in).
|
|
76
|
+
* Either factor missing => low-only. The opt-in alone can never elevate a
|
|
77
|
+
* non-allowlisted (or self-declared) host.
|
|
78
|
+
*/
|
|
79
|
+
export function sensitivityAllowed(level, opts = {}) {
|
|
80
|
+
const lvl = SENSITIVITY_ORDER.includes(level) ? level : 'med';
|
|
81
|
+
if (lvl === 'low') return true;
|
|
82
|
+
if (opts.forceLowOnly === true) return false; // passive read: low-only, always.
|
|
83
|
+
if (!shareSensitive(opts)) return false; // no opt-in => never sensitive.
|
|
84
|
+
// MED-2 host allowlist: enforced whenever the serve layer engages it
|
|
85
|
+
// (opts.enforceHostAllowlist === true — set for EVERY production read path in
|
|
86
|
+
// serve.js). Direct unit callers of renderBrief that don't engage it fall back
|
|
87
|
+
// to opt-in-only (the legacy P4.2 contract) — but they are NOT a production
|
|
88
|
+
// egress surface, so the moat is intact: every real read goes through serve.js.
|
|
89
|
+
if (opts.enforceHostAllowlist === true) return hostAllowedSensitive(opts);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Truthy env values that engage a boolean flag. */
|
|
94
|
+
function isTruthy(v) {
|
|
95
|
+
if (v === undefined || v === null) return false;
|
|
96
|
+
const s = String(v).trim().toLowerCase();
|
|
97
|
+
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* shareSensitive(opts) -> boolean. The per-host opt-in to include med/high
|
|
102
|
+
* fields in a brief. Read from opts.shareSensitive (explicit override, for
|
|
103
|
+
* tests / programmatic callers) else the IJFW_PROFILE_SHARE_SENSITIVE env var.
|
|
104
|
+
*/
|
|
105
|
+
export function shareSensitive(opts = {}) {
|
|
106
|
+
if (typeof opts.shareSensitive === 'boolean') return opts.shareSensitive;
|
|
107
|
+
const env = opts.env || process.env;
|
|
108
|
+
return isTruthy(env.IJFW_PROFILE_SHARE_SENSITIVE);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The share-hosts allowlist path (sibling of the profile). */
|
|
112
|
+
export function shareHostsFilePath() {
|
|
113
|
+
return join(profileDir(), 'share-hosts.txt');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* loadShareHosts(opts) -> { hosts:Set<string>, all:boolean }.
|
|
118
|
+
*
|
|
119
|
+
* Reads the per-host sensitive-field allowlist from `~/.ijfw/profile/
|
|
120
|
+
* share-hosts.txt` (one host per line, `#` comments) AND the env var
|
|
121
|
+
* IJFW_PROFILE_SHARE_HOSTS (comma/newline separated). Hosts are lowercased for
|
|
122
|
+
* case-insensitive matching. A bare `*` line opts ALL hosts in (operator escape
|
|
123
|
+
* hatch — explicit and auditable). A missing/unreadable file -> empty set
|
|
124
|
+
* (default-deny: no host gets sensitive fields). Never throws.
|
|
125
|
+
*
|
|
126
|
+
* @param {{ env?:object, shareHostsFile?:string }} [opts]
|
|
127
|
+
*/
|
|
128
|
+
export function loadShareHosts(opts = {}) {
|
|
129
|
+
const env = opts.env || process.env;
|
|
130
|
+
const raw = [];
|
|
131
|
+
raw.push(...parsePatterns(env.IJFW_PROFILE_SHARE_HOSTS));
|
|
132
|
+
const file = opts.shareHostsFile || shareHostsFilePath();
|
|
133
|
+
try {
|
|
134
|
+
if (existsSync(file)) {
|
|
135
|
+
raw.push(...parsePatterns(readFileSync(file, 'utf8')));
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// unreadable allowlist -> no hosts (default-deny). Never throw.
|
|
139
|
+
}
|
|
140
|
+
const hosts = new Set();
|
|
141
|
+
let all = false;
|
|
142
|
+
for (const h of raw) {
|
|
143
|
+
if (h === '*') { all = true; continue; }
|
|
144
|
+
hosts.add(h.toLowerCase());
|
|
145
|
+
}
|
|
146
|
+
return { hosts, all };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* hostAllowedSensitive(opts) -> boolean. True iff the resolved host is on the
|
|
151
|
+
* share-hosts allowlist (audit MED-2). The host comes from opts.host (resolved
|
|
152
|
+
* by the serve layer from the caller context, NOT self-declared by the field
|
|
153
|
+
* payload). No host, or a host absent from the allowlist => false (default-deny).
|
|
154
|
+
*
|
|
155
|
+
* @param {{ host?:string, env?:object, shareHostsFile?:string,
|
|
156
|
+
* shareHosts?:{hosts:Set<string>,all:boolean} }} [opts]
|
|
157
|
+
*/
|
|
158
|
+
export function hostAllowedSensitive(opts = {}) {
|
|
159
|
+
const host = typeof opts.host === 'string' ? opts.host.trim().toLowerCase() : '';
|
|
160
|
+
if (!host) return false; // unknown host can never receive sensitive fields.
|
|
161
|
+
const allow = opts.shareHosts || loadShareHosts(opts);
|
|
162
|
+
if (allow.all) return true;
|
|
163
|
+
return allow.hosts.has(host);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The redaction file path (sibling of the profile). */
|
|
167
|
+
export function redactFilePath() {
|
|
168
|
+
return join(profileDir(), 'redact.txt');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse a raw redaction blob (env value or file contents) into pattern lines.
|
|
173
|
+
* Splits on commas AND newlines, trims, drops blanks and `#` comments.
|
|
174
|
+
*/
|
|
175
|
+
function parsePatterns(raw) {
|
|
176
|
+
if (!raw) return [];
|
|
177
|
+
return String(raw)
|
|
178
|
+
.split(/[\n,]/)
|
|
179
|
+
.map((s) => s.trim())
|
|
180
|
+
.filter((s) => s.length > 0 && !s.startsWith('#'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* loadRedaction(opts) -> { patterns: string[], kill: boolean }.
|
|
185
|
+
*
|
|
186
|
+
* Merges the env denylist (IJFW_PROFILE_REDACT) and the redact.txt file. The
|
|
187
|
+
* kill-switch is engaged by IJFW_PROFILE_KILL (truthy) OR a bare `*` / `KILL`
|
|
188
|
+
* (case-insensitive) pattern in either source. File read is best-effort — a
|
|
189
|
+
* missing/unreadable file is simply no patterns (never throws).
|
|
190
|
+
*
|
|
191
|
+
* @param {{ env?:object, redactFile?:string }} [opts]
|
|
192
|
+
*/
|
|
193
|
+
export function loadRedaction(opts = {}) {
|
|
194
|
+
const env = opts.env || process.env;
|
|
195
|
+
const patterns = [];
|
|
196
|
+
|
|
197
|
+
// Env denylist.
|
|
198
|
+
patterns.push(...parsePatterns(env.IJFW_PROFILE_REDACT));
|
|
199
|
+
|
|
200
|
+
// File denylist (best-effort).
|
|
201
|
+
const file = opts.redactFile || redactFilePath();
|
|
202
|
+
try {
|
|
203
|
+
if (existsSync(file)) {
|
|
204
|
+
patterns.push(...parsePatterns(readFileSync(file, 'utf8')));
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// unreadable redact file -> treat as no file patterns (never throw).
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Kill-switch: explicit env flag OR a bare `*`/`KILL` token in the patterns.
|
|
211
|
+
const killTokens = new Set(['*', 'kill']);
|
|
212
|
+
const killFromPatterns = patterns.some((p) => killTokens.has(p.toLowerCase()));
|
|
213
|
+
const kill = isTruthy(env.IJFW_PROFILE_KILL) || killFromPatterns;
|
|
214
|
+
|
|
215
|
+
// Drop the kill tokens from the substring denylist — they're a switch, not a
|
|
216
|
+
// field pattern (a literal `*` would otherwise redact everything via substring
|
|
217
|
+
// match, which is the same outcome, but we keep the two mechanisms distinct).
|
|
218
|
+
const cleaned = patterns.filter((p) => !killTokens.has(p.toLowerCase()));
|
|
219
|
+
|
|
220
|
+
return { patterns: cleaned, kill };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* killSwitchEngaged(redaction) -> boolean. Thin reader over loadRedaction's
|
|
225
|
+
* result so callers can branch without re-parsing.
|
|
226
|
+
*/
|
|
227
|
+
export function killSwitchEngaged(redaction) {
|
|
228
|
+
return !!(redaction && redaction.kill);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* isRedacted(fieldId, redaction) -> boolean. A field is redacted when its id
|
|
233
|
+
* EXACTLY equals OR CONTAINS any denylist pattern (substring match mirrors the
|
|
234
|
+
* memory `forget <pattern>` UX). Case-insensitive on both sides so a user need
|
|
235
|
+
* not match casing exactly. An empty/absent denylist redacts nothing.
|
|
236
|
+
*/
|
|
237
|
+
export function isRedacted(fieldId, redaction) {
|
|
238
|
+
if (!redaction || !Array.isArray(redaction.patterns) || redaction.patterns.length === 0) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
const id = String(fieldId || '').toLowerCase();
|
|
242
|
+
if (!id) return false;
|
|
243
|
+
for (const p of redaction.patterns) {
|
|
244
|
+
const needle = String(p).toLowerCase();
|
|
245
|
+
if (!needle) continue;
|
|
246
|
+
if (id === needle || id.includes(needle)) return true;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|