@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.
- package/CHANGELOG.md +76 -0
- package/README.md +20 -0
- package/manifests/platform.full.yaml +32 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +132 -2
- package/src/cli/commands/uninstall.js +18 -0
- package/src/core/applyPlan.js +17 -2
- package/src/core/diffPlan.js +35 -0
- package/src/core/fileOps.js +26 -0
- package/src/core/projectImportant.js +433 -0
- package/src/core/sensitiveValueScanner.js +118 -0
- package/src/core/status.js +55 -1
- package/src/core/uninstall.js +187 -3
- package/templates/.claude/hooks/project-important.sh +67 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +80 -48
- package/templates/.claude/settings.json +5 -0
- package/templates/.claude/ukit/runtime/project-important.mjs +384 -0
- package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +7 -4
- package/templates/AGENTS.md +8 -0
- package/templates/PROJECT_IMPORTANT.md +9 -0
|
@@ -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
|
+
}
|
package/src/core/status.js
CHANGED
|
@@ -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('
|
|
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}`,
|