@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,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-important.mjs (TASK-038)
|
|
3
|
+
*
|
|
4
|
+
* Installed-runtime mirror of src/core/projectImportant.js — inspector +
|
|
5
|
+
* deterministic envelope renderer for PROJECT_IMPORTANT.md per
|
|
6
|
+
* docs/PROJECT_IMPORTANT_SPEC.md §3, §4, §5.2, §6, §12.1.
|
|
7
|
+
*
|
|
8
|
+
* Deliberate mirror: the installed runtime cannot import package source, so
|
|
9
|
+
* this file keeps the same constants, state names, scanner semantics, and
|
|
10
|
+
* 6,000-code-point output as the source module; drift is locked out by
|
|
11
|
+
* tests/consistency/projectImportantRuntimeParity.test.js (spec §12.2).
|
|
12
|
+
*
|
|
13
|
+
* Guarantees:
|
|
14
|
+
* - Open no-follow → fstat → isFile → fd-read (no symlink resolution, no
|
|
15
|
+
* whole-file readFile on arbitrary sizes; stops after code point 6,001).
|
|
16
|
+
* - Strict UTF-8 decode (no U+FFFD injection); leading BOM excluded from
|
|
17
|
+
* count and body, source bytes never modified.
|
|
18
|
+
* - Code-point counting only — never String.length on owner text.
|
|
19
|
+
* - Byte-identical rendered output for identical input/config.
|
|
20
|
+
* - `body` exists only on the runtime-local return; never serialized into
|
|
21
|
+
* diagnostics.
|
|
22
|
+
* - Fail-open: no documented entry point throws; unexpected errors surface
|
|
23
|
+
* as the deterministic `unreadable` warning so the hook contract stays
|
|
24
|
+
* exit-0.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import { open, lstat } from 'node:fs/promises';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
scanText,
|
|
34
|
+
isSensitiveDataGateEnabled,
|
|
35
|
+
loadSensitiveAllowlist,
|
|
36
|
+
} from './sensitive-value-scanner.mjs';
|
|
37
|
+
|
|
38
|
+
// Hook-context self-deadline (2.4.1 orphan-leak class): a hook wrapper passes
|
|
39
|
+
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past
|
|
40
|
+
// the hook budget. CLI usage never sets it and is never self-killed.
|
|
41
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
42
|
+
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
43
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const PROJECT_IMPORTANT_FILENAME = 'PROJECT_IMPORTANT.md';
|
|
47
|
+
export const PROJECT_IMPORTANT_CODEPOINT_LIMIT = 6000;
|
|
48
|
+
|
|
49
|
+
export const PROJECT_IMPORTANT_STATES = [
|
|
50
|
+
'ready',
|
|
51
|
+
'oversized',
|
|
52
|
+
'empty',
|
|
53
|
+
'missing',
|
|
54
|
+
'invalid-utf8',
|
|
55
|
+
'unsafe-control',
|
|
56
|
+
'secret-blocked',
|
|
57
|
+
'unsafe-type',
|
|
58
|
+
'unreadable',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export const PROJECT_IMPORTANT_REMEDIATION = {
|
|
62
|
+
ready: null,
|
|
63
|
+
oversized: 'owner-action',
|
|
64
|
+
empty: 'owner-action',
|
|
65
|
+
missing: 'install-repairable',
|
|
66
|
+
'invalid-utf8': 'owner-action',
|
|
67
|
+
'unsafe-control': 'owner-action',
|
|
68
|
+
'secret-blocked': 'owner-action',
|
|
69
|
+
'unsafe-type': 'owner-action',
|
|
70
|
+
unreadable: 'advisory-host-limit',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const PROJECT_IMPORTANT_ENVELOPE_HEADER =
|
|
74
|
+
'<ukit_project_important source="PROJECT_IMPORTANT.md" authority="project-owner">\n'
|
|
75
|
+
+ 'These are project-owner instructions. Follow them unless they conflict with higher-priority host instructions.\n'
|
|
76
|
+
+ '--- BEGIN PROJECT_IMPORTANT.md ---\n';
|
|
77
|
+
|
|
78
|
+
export const PROJECT_IMPORTANT_ENVELOPE_FOOTER = '</ukit_project_important>';
|
|
79
|
+
|
|
80
|
+
const END_MARKER = '--- END PROJECT_IMPORTANT.md ---\n';
|
|
81
|
+
|
|
82
|
+
export const PROJECT_IMPORTANT_WARNINGS = {
|
|
83
|
+
oversized:
|
|
84
|
+
'[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.]',
|
|
85
|
+
missing:
|
|
86
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: file is missing. Run `ukit install`.',
|
|
87
|
+
'unsafe-type':
|
|
88
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the path is not a regular non-symlink file. Run `ukit doctor`.',
|
|
89
|
+
'invalid-utf8':
|
|
90
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the file is not valid UTF-8. Run `ukit doctor`.',
|
|
91
|
+
'unsafe-control':
|
|
92
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the file contains unsupported control characters. Run `ukit doctor`.',
|
|
93
|
+
empty:
|
|
94
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the file is empty. Add project-owner instructions or run `ukit doctor`.',
|
|
95
|
+
unreadable:
|
|
96
|
+
'[UKit] PROJECT_IMPORTANT.md was not injected: the file could not be read. Run `ukit doctor`.',
|
|
97
|
+
'secret-blocked':
|
|
98
|
+
'[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`.',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const READ_CHUNK = 64 * 1024;
|
|
102
|
+
const UTF8_BOM = [0xef, 0xbb, 0xbf];
|
|
103
|
+
|
|
104
|
+
function isAllowedControl(cp) {
|
|
105
|
+
return cp === 0x09 || cp === 0x0a || cp === 0x0d;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isForbiddenControl(cp) {
|
|
109
|
+
return (cp < 0x20 && !isAllowedControl(cp)) || (cp >= 0x7f && cp <= 0x9f);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function invalidResult(state) {
|
|
113
|
+
return {
|
|
114
|
+
state,
|
|
115
|
+
body: undefined,
|
|
116
|
+
codePointCount: 0,
|
|
117
|
+
oversized: false,
|
|
118
|
+
bom: false,
|
|
119
|
+
rendered: PROJECT_IMPORTANT_WARNINGS[state],
|
|
120
|
+
remediationClass: PROJECT_IMPORTANT_REMEDIATION[state],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Bounded, strict inspector.
|
|
126
|
+
*
|
|
127
|
+
* @param {{ projectRoot: string, config?: object }} options
|
|
128
|
+
* @returns {Promise<{ state, body?, codePointCount, oversized, bom, rendered, remediationClass }>}
|
|
129
|
+
*/
|
|
130
|
+
export async function inspectProjectImportant(options = {}) {
|
|
131
|
+
const { projectRoot, config } = options;
|
|
132
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
133
|
+
return invalidResult('unreadable');
|
|
134
|
+
}
|
|
135
|
+
const target = path.join(projectRoot, PROJECT_IMPORTANT_FILENAME);
|
|
136
|
+
|
|
137
|
+
let handle;
|
|
138
|
+
try {
|
|
139
|
+
// O_NOFOLLOW where supported: symlinks fail open instead of resolving.
|
|
140
|
+
const flags = fs.constants.O_RDONLY
|
|
141
|
+
| (fs.constants.O_NOFOLLOW || 0)
|
|
142
|
+
| (fs.constants.O_NONBLOCK || 0);
|
|
143
|
+
handle = await open(target, flags);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
|
|
146
|
+
return invalidResult('missing');
|
|
147
|
+
}
|
|
148
|
+
if (err && (err.code === 'ELOOP' || err.code === 'EMLINK')) {
|
|
149
|
+
return invalidResult('unsafe-type');
|
|
150
|
+
}
|
|
151
|
+
// Broken symlink surfaces as ENOENT on some platforms only via lstat —
|
|
152
|
+
// but O_NOFOLLOW gives ELOOP. ENOENT from a symlinked *path* means the
|
|
153
|
+
// link target is missing; check whether the entry itself is a symlink.
|
|
154
|
+
if (err && (err.code === 'EACCES' || err.code === 'EPERM')) {
|
|
155
|
+
return invalidResult('unreadable');
|
|
156
|
+
}
|
|
157
|
+
// Platforms without O_NOFOLLOW support, or odd errors: distinguish
|
|
158
|
+
// symlink/type via lstat before declaring unreadable.
|
|
159
|
+
try {
|
|
160
|
+
const lst = await lstat(target);
|
|
161
|
+
if (!lst.isFile()) return invalidResult('unsafe-type');
|
|
162
|
+
} catch {
|
|
163
|
+
return invalidResult('missing');
|
|
164
|
+
}
|
|
165
|
+
return invalidResult('unreadable');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const fst = await handle.stat();
|
|
170
|
+
if (!fst.isFile()) return invalidResult('unsafe-type');
|
|
171
|
+
|
|
172
|
+
// Streamed, bounded strict decode: stop at code point 6,001.
|
|
173
|
+
const decoder = createStrictDecoder();
|
|
174
|
+
const buf = Buffer.allocUnsafe(READ_CHUNK);
|
|
175
|
+
const cps = [];
|
|
176
|
+
let bom = false;
|
|
177
|
+
let firstChunk = true;
|
|
178
|
+
let done = false;
|
|
179
|
+
|
|
180
|
+
while (!done) {
|
|
181
|
+
const { bytesRead } = await handle.read(buf, 0, READ_CHUNK, null);
|
|
182
|
+
let chunk = buf.subarray(0, bytesRead);
|
|
183
|
+
if (bytesRead === 0) {
|
|
184
|
+
decoder.flush(); // throws on truncated trailing sequence
|
|
185
|
+
done = true;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
if (firstChunk) {
|
|
189
|
+
firstChunk = false;
|
|
190
|
+
if (
|
|
191
|
+
bytesRead >= 3
|
|
192
|
+
&& chunk[0] === UTF8_BOM[0]
|
|
193
|
+
&& chunk[1] === UTF8_BOM[1]
|
|
194
|
+
&& chunk[2] === UTF8_BOM[2]
|
|
195
|
+
) {
|
|
196
|
+
bom = true;
|
|
197
|
+
chunk = chunk.subarray(3);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const cp of decoder.decode(chunk)) {
|
|
201
|
+
cps.push(cp);
|
|
202
|
+
if (cps.length >= PROJECT_IMPORTANT_CODEPOINT_LIMIT + 1) {
|
|
203
|
+
done = true;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (cps.length === 0) {
|
|
210
|
+
return { ...invalidResult('empty'), bom };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const oversized = cps.length > PROJECT_IMPORTANT_CODEPOINT_LIMIT;
|
|
214
|
+
const injected = oversized ? cps.slice(0, PROJECT_IMPORTANT_CODEPOINT_LIMIT) : cps;
|
|
215
|
+
for (const cp of injected) {
|
|
216
|
+
if (isForbiddenControl(cp)) {
|
|
217
|
+
return { ...invalidResult('unsafe-control'), bom, codePointCount: cps.length };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const body = String.fromCodePoint(...injected);
|
|
222
|
+
const codePointCount = cps.length;
|
|
223
|
+
|
|
224
|
+
// Secret gate on the body that would be injected.
|
|
225
|
+
const gateEnabled = isSensitiveDataGateEnabled(config);
|
|
226
|
+
const allowlistHashes = loadSensitiveAllowlist(config);
|
|
227
|
+
const scan = scanText(body, { allowlistHashes, gateEnabled });
|
|
228
|
+
if (scan.hasSecret) {
|
|
229
|
+
return {
|
|
230
|
+
...invalidResult('secret-blocked'),
|
|
231
|
+
bom,
|
|
232
|
+
codePointCount,
|
|
233
|
+
oversized,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const inspection = {
|
|
238
|
+
state: oversized ? 'oversized' : 'ready',
|
|
239
|
+
body,
|
|
240
|
+
codePointCount,
|
|
241
|
+
oversized,
|
|
242
|
+
bom,
|
|
243
|
+
rendered: undefined,
|
|
244
|
+
remediationClass: PROJECT_IMPORTANT_REMEDIATION[oversized ? 'oversized' : 'ready'],
|
|
245
|
+
};
|
|
246
|
+
inspection.rendered = renderProjectImportant(inspection);
|
|
247
|
+
return inspection;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (err && err.message === 'invalid-utf8') {
|
|
250
|
+
return invalidResult('invalid-utf8');
|
|
251
|
+
}
|
|
252
|
+
if (err && (err.code === 'EACCES' || err.code === 'EPERM')) {
|
|
253
|
+
return invalidResult('unreadable');
|
|
254
|
+
}
|
|
255
|
+
return invalidResult('unreadable');
|
|
256
|
+
} finally {
|
|
257
|
+
await handle.close().catch(() => {});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Strict incremental UTF-8 decoder that rejects overlong, surrogate,
|
|
263
|
+
* >U+10FFFF and truncated sequences; throws Error('invalid-utf8').
|
|
264
|
+
*/
|
|
265
|
+
function createStrictDecoder() {
|
|
266
|
+
let needed = 0;
|
|
267
|
+
let value = 0;
|
|
268
|
+
let min = 0;
|
|
269
|
+
|
|
270
|
+
function push(out, cp, seqMin) {
|
|
271
|
+
if (cp < seqMin || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) {
|
|
272
|
+
throw new Error('invalid-utf8');
|
|
273
|
+
}
|
|
274
|
+
out.push(cp);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
/** @param {Buffer|Uint8Array} chunk @returns {number[]} */
|
|
279
|
+
decode(chunk) {
|
|
280
|
+
const out = [];
|
|
281
|
+
for (let i = 0; i < chunk.length; i += 1) {
|
|
282
|
+
const b = chunk[i];
|
|
283
|
+
if (needed > 0) {
|
|
284
|
+
if (b < 0x80 || b > 0xbf) throw new Error('invalid-utf8');
|
|
285
|
+
value = (value << 6) | (b & 0x3f);
|
|
286
|
+
needed -= 1;
|
|
287
|
+
if (needed === 0) {
|
|
288
|
+
push(out, value, min);
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (b < 0x80) {
|
|
293
|
+
out.push(b);
|
|
294
|
+
} else if (b >= 0xc2 && b <= 0xdf) {
|
|
295
|
+
needed = 1; min = 0x80; value = b & 0x1f;
|
|
296
|
+
} else if (b >= 0xe0 && b <= 0xef) {
|
|
297
|
+
needed = 2; min = 0x800; value = b & 0x0f;
|
|
298
|
+
} else if (b >= 0xf0 && b <= 0xf4) {
|
|
299
|
+
needed = 3; min = 0x10000; value = b & 0x07;
|
|
300
|
+
} else {
|
|
301
|
+
throw new Error('invalid-utf8');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
},
|
|
306
|
+
flush() {
|
|
307
|
+
if (needed > 0) throw new Error('invalid-utf8');
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Deterministic envelope renderer. Warning states render as the frozen
|
|
314
|
+
* warning literal only; ready/oversized render the full envelope.
|
|
315
|
+
* Fail-open: any internal render error degrades to the deterministic
|
|
316
|
+
* `unreadable` warning — no throw escapes this entry point.
|
|
317
|
+
*
|
|
318
|
+
* @param {{ state: string, body?: string }} inspection
|
|
319
|
+
* @returns {string}
|
|
320
|
+
*/
|
|
321
|
+
export function renderProjectImportant(inspection) {
|
|
322
|
+
try {
|
|
323
|
+
const { state, body } = inspection || {};
|
|
324
|
+
if (state !== 'ready' && state !== 'oversized') {
|
|
325
|
+
return PROJECT_IMPORTANT_WARNINGS[state] || PROJECT_IMPORTANT_WARNINGS.unreadable;
|
|
326
|
+
}
|
|
327
|
+
const warning = state === 'oversized'
|
|
328
|
+
? PROJECT_IMPORTANT_WARNINGS.oversized + '\n'
|
|
329
|
+
: '';
|
|
330
|
+
// Framing newline outside the body: body bytes are preserved verbatim,
|
|
331
|
+
// the END marker always starts on its own line.
|
|
332
|
+
const framing = body.endsWith('\n') ? '' : '\n';
|
|
333
|
+
return (
|
|
334
|
+
PROJECT_IMPORTANT_ENVELOPE_HEADER
|
|
335
|
+
+ body
|
|
336
|
+
+ framing
|
|
337
|
+
+ END_MARKER
|
|
338
|
+
+ warning
|
|
339
|
+
+ PROJECT_IMPORTANT_ENVELOPE_FOOTER
|
|
340
|
+
);
|
|
341
|
+
} catch {
|
|
342
|
+
return PROJECT_IMPORTANT_WARNINGS.unreadable;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Convenience: inspect + ensure `rendered` is populated.
|
|
348
|
+
* Fail-open: an unexpected inspector/render error degrades to the
|
|
349
|
+
* deterministic `unreadable` result instead of throwing.
|
|
350
|
+
*
|
|
351
|
+
* @param {string} projectRoot
|
|
352
|
+
* @param {{ config?: object }} [options]
|
|
353
|
+
*/
|
|
354
|
+
export async function renderProjectImportantForProject(projectRoot, options = {}) {
|
|
355
|
+
try {
|
|
356
|
+
const inspection = await inspectProjectImportant({ projectRoot, ...options });
|
|
357
|
+
if (inspection.rendered === undefined) {
|
|
358
|
+
inspection.rendered = renderProjectImportant(inspection);
|
|
359
|
+
}
|
|
360
|
+
return inspection;
|
|
361
|
+
} catch {
|
|
362
|
+
return invalidResult('unreadable');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// CLI entry (TASK-041, spec §9): `node project-important.mjs` prints the
|
|
367
|
+
// deterministic rendered envelope — or the frozen invalid-state warning — for
|
|
368
|
+
// the resolved project root. Advisory fail-open: any unexpected error degrades
|
|
369
|
+
// to the deterministic `unreadable` warning and the process still exits 0, so a
|
|
370
|
+
// SessionStart hook can never block the session on a renderer fault.
|
|
371
|
+
if (
|
|
372
|
+
process.argv[1]
|
|
373
|
+
&& fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
374
|
+
) {
|
|
375
|
+
try {
|
|
376
|
+
const projectRoot = process.env.UKIT_PROJECT_ROOT
|
|
377
|
+
|| process.env.CLAUDE_PROJECT_DIR
|
|
378
|
+
|| process.cwd();
|
|
379
|
+
const inspection = await renderProjectImportantForProject(projectRoot);
|
|
380
|
+
process.stdout.write(`${inspection.rendered ?? PROJECT_IMPORTANT_WARNINGS.unreadable}\n`);
|
|
381
|
+
} catch {
|
|
382
|
+
process.stdout.write(`${PROJECT_IMPORTANT_WARNINGS.unreadable}\n`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sensitive-value-scanner.mjs (TASK-038)
|
|
3
|
+
*
|
|
4
|
+
* Installed-runtime mirror of src/core/sensitiveValueScanner.js — the
|
|
5
|
+
* high-confidence secret *value* scanner shared with
|
|
6
|
+
* templates/.claude/hooks/sensitive-data-guard.sh. Deliberate mirror: the
|
|
7
|
+
* installed runtime cannot import package source, so this file is kept
|
|
8
|
+
* byte-semantic with the source module and locked by
|
|
9
|
+
* tests/consistency/projectImportantRuntimeParity.test.js.
|
|
10
|
+
*
|
|
11
|
+
* Scope is the value scanner only: vendor/API-key patterns, JWT, private-key
|
|
12
|
+
* block, SHA-256 exact-value allowlist matching, and the
|
|
13
|
+
* security.sensitiveDataGate config read. File/path classification and Bash
|
|
14
|
+
* command-shape logic stay in the hook.
|
|
15
|
+
*
|
|
16
|
+
* Leak-safety contract: return values carry labels only — never a secret
|
|
17
|
+
* value, prefix, suffix, excerpt, or hash of a matched secret.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
// Hook-context self-deadline (2.4.1 orphan-leak class): a hook wrapper passes
|
|
23
|
+
// UKIT_HOOK_DEADLINE_MS so a wedged scan can never orphan this process past
|
|
24
|
+
// the hook budget. CLI usage never sets it and is never self-killed.
|
|
25
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
|
|
26
|
+
if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
|
|
27
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sha256(value) {
|
|
31
|
+
return createHash('sha256').update(value).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- high-confidence secret value patterns ---
|
|
35
|
+
// Semantically identical to TOKEN_PATTERNS in sensitive-data-guard.sh.
|
|
36
|
+
// AKIA/ASIA/AIza prefixes are pure base64-compatible text, so an encoded blob
|
|
37
|
+
// can contain a coincidental key-shaped substring — those rules must stand
|
|
38
|
+
// alone with no base64/base64url character on either side.
|
|
39
|
+
const TOKEN_PATTERNS = [
|
|
40
|
+
{ label: 'OpenAI/Anthropic-style API key', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
|
|
41
|
+
{ label: 'AWS access key id', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
|
|
42
|
+
{ label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
|
|
43
|
+
{ label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
|
|
44
|
+
{ label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
|
|
45
|
+
{ label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
46
|
+
{ label: 'Google API key', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
|
|
47
|
+
{ label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
|
|
48
|
+
{ label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
49
|
+
{ label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/i;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Scan text for high-confidence secret values.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @param {{ allowlistHashes?: string[], gateEnabled?: boolean }} [options]
|
|
59
|
+
* allowlistHashes: SHA-256 hex digests of explicitly approved exact values.
|
|
60
|
+
* gateEnabled: pass isSensitiveDataGateEnabled(config); when false the scan
|
|
61
|
+
* reports the gate's exit-0 semantics (no detection) like today's hook.
|
|
62
|
+
* @returns {{ hasSecret: boolean, allowed: boolean, labels: string[] }}
|
|
63
|
+
* labels only — never values, excerpts, or hashes.
|
|
64
|
+
*/
|
|
65
|
+
export function scanText(text, options = {}) {
|
|
66
|
+
const { allowlistHashes = [], gateEnabled = true } = options || {};
|
|
67
|
+
const clean = { hasSecret: false, allowed: false, labels: [] };
|
|
68
|
+
if (gateEnabled === false) return clean;
|
|
69
|
+
if (typeof text !== 'string' || text.length === 0) return clean;
|
|
70
|
+
|
|
71
|
+
const allowed = new Set(
|
|
72
|
+
Array.isArray(allowlistHashes) ? allowlistHashes.filter((h) => typeof h === 'string') : [],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const labels = new Set();
|
|
76
|
+
let sawAllowlisted = false;
|
|
77
|
+
for (const { label, re } of TOKEN_PATTERNS) {
|
|
78
|
+
re.lastIndex = 0;
|
|
79
|
+
let match;
|
|
80
|
+
while ((match = re.exec(text)) !== null) {
|
|
81
|
+
if (allowed.has(sha256(match[0]))) {
|
|
82
|
+
sawAllowlisted = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
labels.add(label);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (labels.size === 0) {
|
|
90
|
+
return { hasSecret: false, allowed: sawAllowlisted, labels: [] };
|
|
91
|
+
}
|
|
92
|
+
return { hasSecret: true, allowed: false, labels: [...labels] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Gate toggle: enabled unless security.sensitiveDataGate === false
|
|
97
|
+
* (mirrors the hook's explicit-false check on .ukit/storage/config.json).
|
|
98
|
+
*
|
|
99
|
+
* @param {object|null|undefined} config injected config object
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function isSensitiveDataGateEnabled(config) {
|
|
103
|
+
if (config && config.security && config.security.sensitiveDataGate === false) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load the SHA-256 exact-value allowlist from an injected config object.
|
|
111
|
+
* Accepts security.allowlist.values (mirror of allowlist.json) or
|
|
112
|
+
* security.sensitiveDataAllowlist; keeps only well-formed 64-hex digests.
|
|
113
|
+
*
|
|
114
|
+
* @param {object|null|undefined} config
|
|
115
|
+
* @returns {string[]} SHA-256 hex digests
|
|
116
|
+
*/
|
|
117
|
+
export function loadSensitiveAllowlist(config) {
|
|
118
|
+
if (!config || typeof config !== 'object') return [];
|
|
119
|
+
const security = config.security && typeof config.security === 'object' ? config.security : {};
|
|
120
|
+
const candidates = [];
|
|
121
|
+
if (security.allowlist && Array.isArray(security.allowlist.values)) {
|
|
122
|
+
candidates.push(...security.allowlist.values);
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(security.sensitiveDataAllowlist)) {
|
|
125
|
+
candidates.push(...security.sensitiveDataAllowlist);
|
|
126
|
+
}
|
|
127
|
+
return candidates.filter((v) => typeof v === 'string' && SHA256_HEX_RE.test(v));
|
|
128
|
+
}
|
|
@@ -56,7 +56,7 @@ export const HOOK_EVENT_MAP = {
|
|
|
56
56
|
},
|
|
57
57
|
before_agent_start: ['sensitive-data-guard.sh', 'skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
|
|
58
58
|
'session.compacting': ['reinject-context.sh'],
|
|
59
|
-
session_start: ['auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
|
|
59
|
+
session_start: ['project-important.sh', 'auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
|
|
60
60
|
};
|
|
61
61
|
|
|
62
62
|
const TOOL_NAME_MAP = {
|
|
@@ -111,6 +111,7 @@ export const ADVISORY_SCRIPTS = new Set([
|
|
|
111
111
|
'task-watchdog.sh',
|
|
112
112
|
'compress-output.sh',
|
|
113
113
|
'reinject-context.sh',
|
|
114
|
+
'project-important.sh',
|
|
114
115
|
'auto-prune-bash.sh',
|
|
115
116
|
'reset-compact-pressure.sh',
|
|
116
117
|
'handoff-resume.sh',
|
|
@@ -703,10 +704,12 @@ export async function runSessionCompact(pi, event, { projectRoot, context: exten
|
|
|
703
704
|
// session_compact fires only after agent.replaceMessages()/rebaseAfterCompaction() already
|
|
704
705
|
// ran (session-maintenance.ts), i.e. the session is idle here, and agent-session.ts's
|
|
705
706
|
// sendCustomMessage() non-streaming branch appends the message to the live context
|
|
706
|
-
// regardless of deliverAs — so 'steer' is accepted right after a compaction.
|
|
707
|
-
//
|
|
707
|
+
// regardless of deliverAs — so 'steer' is accepted right after a compaction.
|
|
708
|
+
// TASK-042: delivery is exact-once. The former defense-in-depth 'nextTurn' copy produced
|
|
709
|
+
// a second model-visible duplicate of the same context; per spec §10/§17 Phase 5 keep
|
|
710
|
+
// 'steer' only (switch to 'nextTurn' ONLY if a live omp smoke proves 'steer' is not
|
|
711
|
+
// retained after compact — never both).
|
|
708
712
|
sendContext(pi, result.context, 'steer');
|
|
709
|
-
sendContext(pi, result.context, 'nextTurn');
|
|
710
713
|
return undefined;
|
|
711
714
|
}
|
|
712
715
|
|
package/templates/AGENTS.md
CHANGED
|
@@ -238,6 +238,14 @@ At the start of every OpenCode session, before working on the first task:
|
|
|
238
238
|
4. If the route result points to a skill, read that SKILL.md before acting — do not skip this step.
|
|
239
239
|
5. If `.ukit/storage/config.json` has `router.enabled: true`, prefer the router output over ad-hoc guessing.
|
|
240
240
|
|
|
241
|
+
## Project Owner Instructions — Codex and OpenCode
|
|
242
|
+
|
|
243
|
+
When running in Codex or OpenCode, read and follow the root
|
|
244
|
+
`PROJECT_IMPORTANT.md` before doing project work. It is the canonical
|
|
245
|
+
project-owner instruction source. Do not copy its contents into this file.
|
|
246
|
+
If it is missing or unreadable, state that limitation and continue with the
|
|
247
|
+
remaining project instructions.
|
|
248
|
+
|
|
241
249
|
## Skills
|
|
242
250
|
|
|
243
251
|
- Canonical skills live in `.claude/skills/`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Project Important Instructions
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Add project-specific, non-negotiable AI instructions here.
|
|
5
|
+
|
|
6
|
+
UKit creates this file only when it is missing. After creation, UKit never rewrites,
|
|
7
|
+
merges, formats, chmods, or deletes it. Keep it at or below 6,000 Unicode code
|
|
8
|
+
points for complete runtime injection. Do not put credentials or secrets here.
|
|
9
|
+
-->
|