@ngockhoale/ukit 2.4.3 → 2.5.1

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.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * projectImportant.js (TASK-037)
3
+ *
4
+ * Source-side inspector + deterministic envelope renderer for
5
+ * PROJECT_IMPORTANT.md per docs/PROJECT_IMPORTANT_SPEC.md §3, §4, §5.2, §6,
6
+ * §12.1.
7
+ *
8
+ * Guarantees:
9
+ * - Open no-follow → fstat → isFile → fd-read (no symlink resolution, no
10
+ * whole-file readFile on arbitrary sizes; stops after code point 6,001).
11
+ * - Strict UTF-8 decode (no U+FFFD injection); leading BOM excluded from
12
+ * count and body, source bytes never modified.
13
+ * - Code-point counting only — never String.length on owner text.
14
+ * - Byte-identical rendered output for identical input/config.
15
+ * - `body` exists only on the runtime-local return; never serialized into
16
+ * diagnostics.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import { open, lstat, readFile } from 'node:fs/promises';
21
+ import path from 'node:path';
22
+
23
+ import {
24
+ scanText,
25
+ isSensitiveDataGateEnabled,
26
+ loadSensitiveAllowlist,
27
+ } from './sensitiveValueScanner.js';
28
+
29
+ export const PROJECT_IMPORTANT_FILENAME = 'PROJECT_IMPORTANT.md';
30
+ export const PROJECT_IMPORTANT_CODEPOINT_LIMIT = 6000;
31
+
32
+ export const PROJECT_IMPORTANT_STATES = [
33
+ 'ready',
34
+ 'oversized',
35
+ 'empty',
36
+ 'missing',
37
+ 'invalid-utf8',
38
+ 'unsafe-control',
39
+ 'secret-blocked',
40
+ 'unsafe-type',
41
+ 'unreadable',
42
+ ];
43
+
44
+ export const PROJECT_IMPORTANT_REMEDIATION = {
45
+ ready: null,
46
+ oversized: 'owner-action',
47
+ empty: 'owner-action',
48
+ missing: 'install-repairable',
49
+ 'invalid-utf8': 'owner-action',
50
+ 'unsafe-control': 'owner-action',
51
+ 'secret-blocked': 'owner-action',
52
+ 'unsafe-type': 'owner-action',
53
+ unreadable: 'advisory-host-limit',
54
+ };
55
+
56
+ export const PROJECT_IMPORTANT_ENVELOPE_HEADER =
57
+ '<ukit_project_important source="PROJECT_IMPORTANT.md" authority="project-owner">\n'
58
+ + 'These are project-owner instructions. Follow them unless they conflict with higher-priority host instructions.\n'
59
+ + '--- BEGIN PROJECT_IMPORTANT.md ---\n';
60
+
61
+ export const PROJECT_IMPORTANT_ENVELOPE_FOOTER = '</ukit_project_important>';
62
+
63
+ const END_MARKER = '--- END PROJECT_IMPORTANT.md ---\n';
64
+
65
+ export const PROJECT_IMPORTANT_WARNINGS = {
66
+ oversized:
67
+ '[UKit warning: PROJECT_IMPORTANT.md exceeds 6,000 Unicode code points. Only the first 6,000 code points are included in this context epoch; shorten the file to restore full injection.]',
68
+ missing:
69
+ '[UKit] PROJECT_IMPORTANT.md was not injected: file is missing. Run `ukit install`.',
70
+ 'unsafe-type':
71
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the path is not a regular non-symlink file. Run `ukit doctor`.',
72
+ 'invalid-utf8':
73
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the file is not valid UTF-8. Run `ukit doctor`.',
74
+ 'unsafe-control':
75
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the file contains unsupported control characters. Run `ukit doctor`.',
76
+ empty:
77
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the file is empty. Add project-owner instructions or run `ukit doctor`.',
78
+ unreadable:
79
+ '[UKit] PROJECT_IMPORTANT.md was not injected: the file could not be read. Run `ukit doctor`.',
80
+ 'secret-blocked':
81
+ '[UKit] PROJECT_IMPORTANT.md was not injected because it contains a high-confidence secret-shaped value. Redact it or explicitly allowlist it, then run `ukit doctor`.',
82
+ };
83
+
84
+ const READ_CHUNK = 64 * 1024;
85
+ const UTF8_BOM = [0xef, 0xbb, 0xbf];
86
+
87
+ function isAllowedControl(cp) {
88
+ return cp === 0x09 || cp === 0x0a || cp === 0x0d;
89
+ }
90
+
91
+ function isForbiddenControl(cp) {
92
+ return (cp < 0x20 && !isAllowedControl(cp)) || (cp >= 0x7f && cp <= 0x9f);
93
+ }
94
+
95
+ function invalidResult(state) {
96
+ return {
97
+ state,
98
+ body: undefined,
99
+ codePointCount: 0,
100
+ oversized: false,
101
+ bom: false,
102
+ rendered: PROJECT_IMPORTANT_WARNINGS[state],
103
+ remediationClass: PROJECT_IMPORTANT_REMEDIATION[state],
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Bounded, strict inspector.
109
+ *
110
+ * @param {{ projectRoot: string, config?: object }} options
111
+ * @returns {Promise<{ state, body?, codePointCount, oversized, bom, rendered, remediationClass }>}
112
+ */
113
+ export async function inspectProjectImportant(options = {}) {
114
+ const { projectRoot, config } = options;
115
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
116
+ return invalidResult('unreadable');
117
+ }
118
+ const target = path.join(projectRoot, PROJECT_IMPORTANT_FILENAME);
119
+
120
+ let handle;
121
+ try {
122
+ // O_NOFOLLOW where supported: symlinks fail open instead of resolving.
123
+ const flags = fs.constants.O_RDONLY
124
+ | (fs.constants.O_NOFOLLOW || 0)
125
+ | (fs.constants.O_NONBLOCK || 0);
126
+ handle = await open(target, flags);
127
+ } catch (err) {
128
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
129
+ return invalidResult('missing');
130
+ }
131
+ if (err && (err.code === 'ELOOP' || err.code === 'EMLINK')) {
132
+ return invalidResult('unsafe-type');
133
+ }
134
+ // Broken symlink surfaces as ENOENT on some platforms only via lstat —
135
+ // but O_NOFOLLOW gives ELOOP. ENOENT from a symlinked *path* means the
136
+ // link target is missing; check whether the entry itself is a symlink.
137
+ if (err && (err.code === 'EACCES' || err.code === 'EPERM')) {
138
+ return invalidResult('unreadable');
139
+ }
140
+ // Platforms without O_NOFOLLOW support, or odd errors: distinguish
141
+ // symlink/type via lstat before declaring unreadable.
142
+ try {
143
+ const lst = await lstat(target);
144
+ if (!lst.isFile()) return invalidResult('unsafe-type');
145
+ } catch {
146
+ return invalidResult('missing');
147
+ }
148
+ return invalidResult('unreadable');
149
+ }
150
+
151
+ try {
152
+ const fst = await handle.stat();
153
+ if (!fst.isFile()) return invalidResult('unsafe-type');
154
+
155
+ // Streamed, bounded strict decode: stop at code point 6,001.
156
+ const decoder = createStrictDecoder();
157
+ const buf = Buffer.allocUnsafe(READ_CHUNK);
158
+ const cps = [];
159
+ let bom = false;
160
+ let firstChunk = true;
161
+ let done = false;
162
+
163
+ while (!done) {
164
+ const { bytesRead } = await handle.read(buf, 0, READ_CHUNK, null);
165
+ let chunk = buf.subarray(0, bytesRead);
166
+ if (bytesRead === 0) {
167
+ decoder.flush(); // throws on truncated trailing sequence
168
+ done = true;
169
+ break;
170
+ }
171
+ if (firstChunk) {
172
+ firstChunk = false;
173
+ if (
174
+ bytesRead >= 3
175
+ && chunk[0] === UTF8_BOM[0]
176
+ && chunk[1] === UTF8_BOM[1]
177
+ && chunk[2] === UTF8_BOM[2]
178
+ ) {
179
+ bom = true;
180
+ chunk = chunk.subarray(3);
181
+ }
182
+ }
183
+ for (const cp of decoder.decode(chunk)) {
184
+ cps.push(cp);
185
+ if (cps.length >= PROJECT_IMPORTANT_CODEPOINT_LIMIT + 1) {
186
+ done = true;
187
+ break;
188
+ }
189
+ }
190
+ }
191
+
192
+ if (cps.length === 0) {
193
+ return { ...invalidResult('empty'), bom };
194
+ }
195
+
196
+ const oversized = cps.length > PROJECT_IMPORTANT_CODEPOINT_LIMIT;
197
+ const injected = oversized ? cps.slice(0, PROJECT_IMPORTANT_CODEPOINT_LIMIT) : cps;
198
+ for (const cp of injected) {
199
+ if (isForbiddenControl(cp)) {
200
+ return { ...invalidResult('unsafe-control'), bom, codePointCount: cps.length };
201
+ }
202
+ }
203
+
204
+ const body = String.fromCodePoint(...injected);
205
+ const codePointCount = cps.length;
206
+
207
+ // Secret gate on the body that would be injected.
208
+ const gateEnabled = isSensitiveDataGateEnabled(config);
209
+ const allowlistHashes = loadSensitiveAllowlist(config);
210
+ const scan = scanText(body, { allowlistHashes, gateEnabled });
211
+ if (scan.hasSecret) {
212
+ return {
213
+ ...invalidResult('secret-blocked'),
214
+ bom,
215
+ codePointCount,
216
+ oversized,
217
+ };
218
+ }
219
+
220
+ const inspection = {
221
+ state: oversized ? 'oversized' : 'ready',
222
+ body,
223
+ codePointCount,
224
+ oversized,
225
+ bom,
226
+ rendered: undefined,
227
+ remediationClass: PROJECT_IMPORTANT_REMEDIATION[oversized ? 'oversized' : 'ready'],
228
+ };
229
+ inspection.rendered = renderProjectImportant(inspection);
230
+ return inspection;
231
+ } catch (err) {
232
+ if (err && err.message === 'invalid-utf8') {
233
+ return invalidResult('invalid-utf8');
234
+ }
235
+ if (err && (err.code === 'EACCES' || err.code === 'EPERM')) {
236
+ return invalidResult('unreadable');
237
+ }
238
+ return invalidResult('unreadable');
239
+ } finally {
240
+ await handle.close().catch(() => {});
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Strict incremental UTF-8 decoder that rejects overlong, surrogate,
246
+ * >U+10FFFF and truncated sequences; throws Error('invalid-utf8').
247
+ */
248
+ function createStrictDecoder() {
249
+ let needed = 0;
250
+ let value = 0;
251
+ let min = 0;
252
+
253
+ function push(out, cp, seqMin) {
254
+ if (cp < seqMin || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) {
255
+ throw new Error('invalid-utf8');
256
+ }
257
+ out.push(cp);
258
+ }
259
+
260
+ return {
261
+ /** @param {Buffer|Uint8Array} chunk @returns {number[]} */
262
+ decode(chunk) {
263
+ const out = [];
264
+ for (let i = 0; i < chunk.length; i += 1) {
265
+ const b = chunk[i];
266
+ if (needed > 0) {
267
+ if (b < 0x80 || b > 0xbf) throw new Error('invalid-utf8');
268
+ value = (value << 6) | (b & 0x3f);
269
+ needed -= 1;
270
+ if (needed === 0) {
271
+ push(out, value, min);
272
+ }
273
+ continue;
274
+ }
275
+ if (b < 0x80) {
276
+ out.push(b);
277
+ } else if (b >= 0xc2 && b <= 0xdf) {
278
+ needed = 1; min = 0x80; value = b & 0x1f;
279
+ } else if (b >= 0xe0 && b <= 0xef) {
280
+ needed = 2; min = 0x800; value = b & 0x0f;
281
+ } else if (b >= 0xf0 && b <= 0xf4) {
282
+ needed = 3; min = 0x10000; value = b & 0x07;
283
+ } else {
284
+ throw new Error('invalid-utf8');
285
+ }
286
+ }
287
+ return out;
288
+ },
289
+ flush() {
290
+ if (needed > 0) throw new Error('invalid-utf8');
291
+ },
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Deterministic envelope renderer. Warning states render as the frozen
297
+ * warning literal only; ready/oversized render the full envelope.
298
+ *
299
+ * @param {{ state: string, body?: string }} inspection
300
+ * @returns {string}
301
+ */
302
+ export function renderProjectImportant(inspection) {
303
+ const { state, body } = inspection || {};
304
+ if (state !== 'ready' && state !== 'oversized') {
305
+ return PROJECT_IMPORTANT_WARNINGS[state] || PROJECT_IMPORTANT_WARNINGS.unreadable;
306
+ }
307
+ const warning = state === 'oversized'
308
+ ? PROJECT_IMPORTANT_WARNINGS.oversized + '\n'
309
+ : '';
310
+ // Framing newline outside the body: body bytes are preserved verbatim,
311
+ // the END marker always starts on its own line.
312
+ const framing = body.endsWith('\n') ? '' : '\n';
313
+ return (
314
+ PROJECT_IMPORTANT_ENVELOPE_HEADER
315
+ + body
316
+ + framing
317
+ + END_MARKER
318
+ + warning
319
+ + PROJECT_IMPORTANT_ENVELOPE_FOOTER
320
+ );
321
+ }
322
+
323
+ /**
324
+ * Convenience: inspect + ensure `rendered` is populated.
325
+ *
326
+ * @param {string} projectRoot
327
+ * @param {{ config?: object }} [options]
328
+ */
329
+ export async function renderProjectImportantForProject(projectRoot, options = {}) {
330
+ const inspection = await inspectProjectImportant({ projectRoot, ...options });
331
+ if (inspection.rendered === undefined) {
332
+ inspection.rendered = renderProjectImportant(inspection);
333
+ }
334
+ return inspection;
335
+ }
336
+
337
+ /**
338
+ * TASK-044 — adapter wiring facts for status/doctor (spec §13/§14).
339
+ *
340
+ * Returns install + wiring facts only — never file bodies. Claude wiring is
341
+ * always expected; omp/Codex/OpenCode are checked only when installed, and an
342
+ * absent adapter is `installed:false`, never a failure.
343
+ *
344
+ * @param {string} projectRoot
345
+ * @param {{ trackedPaths?: string[] }} [options] install.json file list, if known
346
+ * @returns {Promise<{claude: object, omp: object, codex: object, opencode: object, agentsFallback: boolean}>}
347
+ */
348
+ export async function inspectProjectImportantWiring(projectRoot, options = {}) {
349
+ const trackedPaths = Array.isArray(options.trackedPaths) ? options.trackedPaths : [];
350
+ const tracked = (prefix) => trackedPaths.some((entry) => entry.startsWith(prefix));
351
+
352
+ const runtimeModulePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'project-important.mjs');
353
+ const hookPath = path.join(projectRoot, '.claude', 'hooks', 'project-important.sh');
354
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
355
+ const ompBridgePath = path.join(projectRoot, '.omp', 'hooks', 'pre', 'ukit-bridge.js');
356
+ const agentsPath = path.join(projectRoot, 'AGENTS.md');
357
+
358
+ const exists = async (p) => {
359
+ try {
360
+ await lstat(p);
361
+ return true;
362
+ } catch {
363
+ return false;
364
+ }
365
+ };
366
+
367
+ // --- Claude wiring (always expected) ---
368
+ const runtimeModule = await exists(runtimeModulePath);
369
+ let hookInstalled = false;
370
+ let hookExecutable = false;
371
+ try {
372
+ const st = await lstat(hookPath);
373
+ hookInstalled = st.isFile();
374
+ hookExecutable = hookInstalled && (st.mode & 0o111) !== 0;
375
+ } catch {
376
+ // absent
377
+ }
378
+ let settingsWired = false;
379
+ try {
380
+ const raw = await readFile(settingsPath, 'utf8');
381
+ const settings = JSON.parse(raw);
382
+ const sessionStart = Array.isArray(settings?.hooks?.SessionStart) ? settings.hooks.SessionStart : [];
383
+ const commands = sessionStart.flatMap((entry) => (
384
+ Array.isArray(entry?.hooks) ? entry.hooks.map((h) => String(h?.command ?? '')) : []
385
+ ));
386
+ const idx = commands.findIndex((cmd) => cmd.includes('project-important.sh'));
387
+ settingsWired = idx === 0;
388
+ } catch {
389
+ settingsWired = false;
390
+ }
391
+ const claude = {
392
+ installed: true,
393
+ runtimeModule,
394
+ hookInstalled,
395
+ hookExecutable,
396
+ settingsWired,
397
+ wired: runtimeModule && hookInstalled && hookExecutable && settingsWired,
398
+ };
399
+
400
+ // --- AGENTS.md owner-instructions pointer (shared Codex/OpenCode fallback) ---
401
+ let agentsFallback = false;
402
+ try {
403
+ const agents = await readFile(agentsPath, 'utf8');
404
+ agentsFallback = agents.includes('Project Owner Instructions')
405
+ && agents.includes(PROJECT_IMPORTANT_FILENAME);
406
+ } catch {
407
+ agentsFallback = false;
408
+ }
409
+
410
+ // --- omp bridge (only when omp installed/tracked) ---
411
+ const ompBridgeExists = await exists(ompBridgePath);
412
+ const ompInstalled = tracked('.omp/') || ompBridgeExists || await exists(path.join(projectRoot, '.omp', 'config.yml'));
413
+ let ompWired = false;
414
+ if (ompInstalled && ompBridgeExists) {
415
+ try {
416
+ const bridge = await readFile(ompBridgePath, 'utf8');
417
+ ompWired = /session_start\s*:\s*\[\s*['"]project-important\.sh/.test(bridge);
418
+ } catch {
419
+ ompWired = false;
420
+ }
421
+ }
422
+ const omp = { installed: ompInstalled, wired: ompInstalled && ompWired };
423
+
424
+ // --- Codex fallback (only when adapter installed) ---
425
+ const codexInstalled = tracked('.codex/') || await exists(path.join(projectRoot, '.codex', 'settings.json'));
426
+ const codex = { installed: codexInstalled, wired: codexInstalled && agentsFallback };
427
+
428
+ // --- OpenCode fallback (only when opencode.json tracked/installed) ---
429
+ const opencodeInstalled = tracked('opencode.json') || await exists(path.join(projectRoot, 'opencode.json'));
430
+ const opencode = { installed: opencodeInstalled, wired: opencodeInstalled && agentsFallback };
431
+
432
+ return { claude, omp, codex, opencode, agentsFallback };
433
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * sensitiveValueScanner.js (TASK-036)
3
+ *
4
+ * Shared source-side extraction of the high-confidence secret *value* scanner
5
+ * that lives in templates/.claude/hooks/sensitive-data-guard.sh. One semantic
6
+ * implementation is consumed by the runtime hook mirror (TASK-038/039) and the
7
+ * PROJECT_IMPORTANT.md renderer (TASK-037).
8
+ *
9
+ * Scope is the value scanner only: vendor/API-key patterns, JWT, private-key
10
+ * block, SHA-256 exact-value allowlist matching, and the
11
+ * security.sensitiveDataGate config read. File/path classification and Bash
12
+ * command-shape logic stay in the hook.
13
+ *
14
+ * Leak-safety contract: return values carry labels only — never a secret
15
+ * value, prefix, suffix, excerpt, or hash of a matched secret.
16
+ */
17
+
18
+ import { createHash } from 'node:crypto';
19
+
20
+ function sha256(value) {
21
+ return createHash('sha256').update(value).digest('hex');
22
+ }
23
+
24
+ // --- high-confidence secret value patterns ---
25
+ // Semantically identical to TOKEN_PATTERNS in sensitive-data-guard.sh.
26
+ // AKIA/ASIA/AIza prefixes are pure base64-compatible text, so an encoded blob
27
+ // can contain a coincidental key-shaped substring — those rules must stand
28
+ // alone with no base64/base64url character on either side.
29
+ const TOKEN_PATTERNS = [
30
+ { label: 'OpenAI/Anthropic-style API key', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
31
+ { label: 'AWS access key id', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
32
+ { label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
33
+ { label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
34
+ { label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
35
+ { label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
36
+ { label: 'Google API key', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
37
+ { label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
38
+ { label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
39
+ { label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
40
+ ];
41
+
42
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/i;
43
+
44
+ /**
45
+ * Scan text for high-confidence secret values.
46
+ *
47
+ * @param {string} text
48
+ * @param {{ allowlistHashes?: string[], gateEnabled?: boolean }} [options]
49
+ * allowlistHashes: SHA-256 hex digests of explicitly approved exact values.
50
+ * gateEnabled: pass isSensitiveDataGateEnabled(config); when false the scan
51
+ * reports the gate's exit-0 semantics (no detection) like today's hook.
52
+ * @returns {{ hasSecret: boolean, allowed: boolean, labels: string[] }}
53
+ * labels only — never values, excerpts, or hashes.
54
+ */
55
+ export function scanText(text, options = {}) {
56
+ const { allowlistHashes = [], gateEnabled = true } = options || {};
57
+ const clean = { hasSecret: false, allowed: false, labels: [] };
58
+ if (gateEnabled === false) return clean;
59
+ if (typeof text !== 'string' || text.length === 0) return clean;
60
+
61
+ const allowed = new Set(
62
+ Array.isArray(allowlistHashes) ? allowlistHashes.filter((h) => typeof h === 'string') : [],
63
+ );
64
+
65
+ const labels = new Set();
66
+ let sawAllowlisted = false;
67
+ for (const { label, re } of TOKEN_PATTERNS) {
68
+ re.lastIndex = 0;
69
+ let match;
70
+ while ((match = re.exec(text)) !== null) {
71
+ if (allowed.has(sha256(match[0]))) {
72
+ sawAllowlisted = true;
73
+ continue;
74
+ }
75
+ labels.add(label);
76
+ }
77
+ }
78
+
79
+ if (labels.size === 0) {
80
+ return { hasSecret: false, allowed: sawAllowlisted, labels: [] };
81
+ }
82
+ return { hasSecret: true, allowed: false, labels: [...labels] };
83
+ }
84
+
85
+ /**
86
+ * Gate toggle: enabled unless security.sensitiveDataGate === false
87
+ * (mirrors the hook's explicit-false check on .ukit/storage/config.json).
88
+ *
89
+ * @param {object|null|undefined} config injected config object
90
+ * @returns {boolean}
91
+ */
92
+ export function isSensitiveDataGateEnabled(config) {
93
+ if (config && config.security && config.security.sensitiveDataGate === false) {
94
+ return false;
95
+ }
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Load the SHA-256 exact-value allowlist from an injected config object.
101
+ * Accepts security.allowlist.values (mirror of allowlist.json) or
102
+ * security.sensitiveDataAllowlist; keeps only well-formed 64-hex digests.
103
+ *
104
+ * @param {object|null|undefined} config
105
+ * @returns {string[]} SHA-256 hex digests
106
+ */
107
+ export function loadSensitiveAllowlist(config) {
108
+ if (!config || typeof config !== 'object') return [];
109
+ const security = config.security && typeof config.security === 'object' ? config.security : {};
110
+ const candidates = [];
111
+ if (security.allowlist && Array.isArray(security.allowlist.values)) {
112
+ candidates.push(...security.allowlist.values);
113
+ }
114
+ if (Array.isArray(security.sensitiveDataAllowlist)) {
115
+ candidates.push(...security.sensitiveDataAllowlist);
116
+ }
117
+ return candidates.filter((v) => typeof v === 'string' && SHA256_HEX_RE.test(v));
118
+ }
@@ -7,6 +7,7 @@ import { buildPromptCacheStats } from './token/index.js';
7
7
  import { countMemoryItems } from './memory/store.js';
8
8
  import { buildCompactPressureState } from './compact/threshold.js';
9
9
  import { detectProjectContext } from '../context/detectProjectContext.js';
10
+ import { inspectProjectImportant, inspectProjectImportantWiring } from './projectImportant.js';
10
11
 
11
12
  function formatPrimaryAgent(agentKey) {
12
13
  if (agentKey === 'claude-code') return 'Claude Code';
@@ -151,6 +152,29 @@ export async function buildStatusReport(projectRoot) {
151
152
  const memoryCounts = await countMemoryItems(projectRoot);
152
153
  const adapters = await detectAdapterLabels(projectRoot, config.agent);
153
154
 
155
+ // TASK-044 — typed projectImportant state + conditional adapter wiring (spec §13).
156
+ const installMeta = await readStatusJson(
157
+ path.join(projectRoot, '.claude', 'ukit', '.ukit', 'install.json'),
158
+ );
159
+ const trackedPaths = Array.isArray(installMeta?.files)
160
+ ? installMeta.files
161
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.p))
162
+ .filter((entry) => typeof entry === 'string')
163
+ : [];
164
+ const piInspection = await inspectProjectImportant({ projectRoot, config });
165
+ const piWiring = await inspectProjectImportantWiring(projectRoot, { trackedPaths });
166
+ const projectImportant = {
167
+ state: piInspection.state,
168
+ codePointCount: piInspection.codePointCount,
169
+ oversized: piInspection.oversized,
170
+ wiring: {
171
+ claude: piWiring.claude.wired,
172
+ omp: piWiring.omp.installed ? piWiring.omp.wired : undefined,
173
+ codex: piWiring.codex.installed ? piWiring.codex.wired : undefined,
174
+ opencode: piWiring.opencode.installed ? piWiring.opencode.wired : undefined,
175
+ },
176
+ };
177
+
154
178
  return {
155
179
  version: config.version,
156
180
  projectName: projectContext.project.name,
@@ -176,14 +200,43 @@ export async function buildStatusReport(projectRoot) {
176
200
  ),
177
201
  defaultModel: config.router.defaultModel,
178
202
  advisorEnabled: Boolean(config.router.advisorEnabled),
203
+ projectImportant,
179
204
  };
180
205
  }
181
206
 
207
+ const PROJECT_IMPORTANT_STATE_LABELS = {
208
+ ready: (pi) => `ready / ${pi.codePointCount.toLocaleString('en-US')} of 6,000 code points`,
209
+ oversized: () => 'oversized / >6,000 code points / injects first 6,000',
210
+ empty: () => 'empty / not injected',
211
+ missing: () => 'missing / not injected',
212
+ 'invalid-utf8': () => 'invalid UTF-8 / not injected',
213
+ 'unsafe-control': () => 'unsupported control characters / not injected',
214
+ 'secret-blocked': () => 'secret-blocked / not injected',
215
+ 'unsafe-type': () => 'unsafe symlink / not injected',
216
+ unreadable: () => 'unreadable / not injected',
217
+ };
218
+
219
+ function formatProjectImportantRules(pi) {
220
+ const formatter = PROJECT_IMPORTANT_STATE_LABELS[pi?.state] ?? (() => 'unreadable / not injected');
221
+ return formatter(pi);
222
+ }
223
+
224
+ function formatProjectImportantWiring(pi) {
225
+ const mark = (wired) => (wired ? '✓' : '✗');
226
+ const parts = [`Claude runtime ${mark(pi.wiring.claude)}`];
227
+ if (pi.wiring.omp !== undefined) parts.push(`omp runtime ${mark(pi.wiring.omp)}`);
228
+ if (pi.wiring.codex !== undefined) parts.push(`Codex fallback ${mark(pi.wiring.codex)}`);
229
+ if (pi.wiring.opencode !== undefined) parts.push(`OpenCode fallback ${mark(pi.wiring.opencode)}`);
230
+ return parts.join(' / ');
231
+ }
232
+
182
233
  export function formatStatusReport(report) {
183
234
  return [
184
235
  `UKit v${report.version} — Status`,
185
236
  '─────────────────────',
186
237
  `${padLabel('Project')} ${report.projectName}`,
238
+ `${padLabel('Project rules')} ${formatProjectImportantRules(report.projectImportant)}`,
239
+ `${padLabel('Rule wiring')} ${formatProjectImportantWiring(report.projectImportant)}`,
187
240
  `${padLabel('Adapters')} ${report.adapters.join(', ')}`,
188
241
  `${padLabel('Memory items')} ${report.memoryCounts.projectCount} project / ${report.memoryCounts.sessionCount} session / ${report.memoryCounts.userCount} user`,
189
242
  `${padLabel('Last compact')} ${report.lastCompact}`,
@@ -191,7 +244,8 @@ export function formatStatusReport(report) {
191
244
  `${padLabel('Compact lanes')} ${report.compactLanes}`,
192
245
  `${padLabel('Ctx pressure')} ${report.contextPressure}`,
193
246
  `${padLabel('Threshold cmp')} ${report.thresholdCompact}`,
194
- `${padLabel('Prompt cache')} ${report.promptCache}`,
247
+ `${padLabel('Local p-cache')} ${report.promptCache}`,
248
+ `${padLabel('Provider cache')} unknown (host telemetry unavailable)`,
195
249
  `${padLabel('Cache lanes')} ${report.cacheLanes}`,
196
250
  `${padLabel('Output comp.')} ${report.outputCompression}`,
197
251
  `${padLabel('Output lanes')} ${report.outputLanes}`,