@ngockhoale/ukit 3.4.0 → 3.4.2
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 +23 -0
- package/package.json +1 -1
- package/src/cli/commands/code.js +29 -5
- package/src/cli/commands/decision.js +18 -4
- package/src/cli/commands/doctor.js +7 -3
- package/src/cli/commands/install.js +29 -4
- package/src/cli/commands/memory.js +25 -5
- package/src/cli/commands/telemetry.js +18 -1
- package/src/cli/commands/vm.js +7 -1
- package/src/context/detectProjectContext.js +7 -2
- package/src/core/agentRuntime/contract.js +5 -1
- package/src/core/agentRuntime/eventStore.js +54 -7
- package/src/core/agentRuntime/recovery.js +22 -15
- package/src/core/agentRuntime/supervisor.js +71 -13
- package/src/core/applyPlan.js +11 -1
- package/src/core/codeintel/compiler.js +51 -8
- package/src/core/codeintel/diagnostics.js +124 -33
- package/src/core/codeintel/freshness.js +25 -12
- package/src/core/codeintel/invalidation.js +11 -3
- package/src/core/codeintel/retriever.js +53 -20
- package/src/core/codeintel/router.js +19 -9
- package/src/core/codeintel/summaries.js +4 -3
- package/src/core/codeintel/vectorProvider.js +30 -4
- package/src/core/compact/index.js +24 -7
- package/src/core/compact/threshold.js +49 -14
- package/src/core/diffPlan.js +51 -23
- package/src/core/ensureGitignore.js +19 -2
- package/src/core/fileOps.js +61 -0
- package/src/core/memory/hygiene.js +51 -1
- package/src/core/memory/migrate.js +41 -21
- package/src/core/memory/store.js +96 -61
- package/src/core/metadata.js +37 -2
- package/src/core/observability/adapters/ingest.js +30 -2
- package/src/core/observability/emit/config.js +19 -4
- package/src/core/observability/emit/crash.js +3 -1
- package/src/core/observability/emit/recorder.js +15 -7
- package/src/core/observability/privacy/sanitizeObserved.js +3 -1
- package/src/core/observability/segments/internal.js +36 -8
- package/src/core/observability/segments/retention.js +11 -0
- package/src/core/observability/support/import.js +27 -1
- package/src/core/output/index.js +16 -1
- package/src/core/permissionDoctor.js +72 -9
- package/src/core/repairBrokenHooks.js +15 -2
- package/src/core/reviewPanelAggregate.js +26 -10
- package/src/core/runInstallPipeline.js +71 -22
- package/src/core/runtimeConfig.js +2 -0
- package/src/core/status.js +2 -0
- package/src/core/taskBudgetValidator.js +7 -1
- package/src/core/taskProgressGuard.js +11 -1
- package/src/core/unattendedDoctor.js +36 -5
- package/src/core/uninstall.js +52 -12
- package/src/core/update.js +5 -1
- package/src/decision/client.js +158 -27
- package/src/decision/reviewVerdict.js +23 -7
- package/src/diagnostics/failurePatterns.js +1 -1
- package/src/diagnostics/feedbackEvents.js +1 -1
- package/src/diagnostics/routeOutcomes.js +42 -4
- package/src/diagnostics/skillAccuracy.js +35 -4
- package/src/index/buildIndex.js +123 -26
- package/src/index/fixLoopEscalation.js +3 -0
- package/src/index/gitHooks.js +99 -29
- package/src/index/importResolution.js +7 -1
- package/src/index/playbookRegistry.js +15 -11
- package/src/index/queryIndex.js +28 -10
- package/src/index/routeResolver.js +8 -3
- package/src/index/taskRouting.js +37 -2
- package/src/learning/codeProposals.js +24 -5
- package/src/learning/selfImprove.js +29 -5
- package/src/learning/tunedOverlay.js +18 -7
- package/src/learning/tuning.js +10 -4
- package/src/skill/auditSkill.js +46 -7
- package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
- package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
- package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
- package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
- package/template_project/.claude/hooks/session-episode.sh +60 -28
- package/template_project/.claude/hooks/verification-guard.sh +26 -15
- package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
- package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
- package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
- package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
- package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
- package/template_project/.claude/ukit/index/query-index.mjs +13 -6
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
- package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
- package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
- package/template_project/.claude/ukit/index/route-task.mjs +15 -0
- package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
- package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
- package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
- package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
- package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
- package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
- package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
- package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
- package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
- package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
- package/template_project/.claude/ukit/runtime/token-utils.mjs +57 -14
|
@@ -220,20 +220,319 @@ async function evaluate({ rawInput, projectRoot, env }) {
|
|
|
220
220
|
'base64', 'od', 'hexdump', 'bat', 'grep', 'egrep', 'fgrep', 'rg', 'awk', 'sed',
|
|
221
221
|
]);
|
|
222
222
|
|
|
223
|
-
|
|
223
|
+
// W2-D03: token normalisation must mirror what the shell does to a word
|
|
224
|
+
// before the dump verb sees it. Quote modes are tracked so a QUOTED
|
|
225
|
+
// backslash stays literal — bash keeps `\` inside quotes, so `'C:\x\.env'`
|
|
226
|
+
// (Windows/git-bash path) must still classify to `.env` — while an unquoted
|
|
227
|
+
// `.e\nv` still folds to `.env`. Backticks survive dequoting: they delimit
|
|
228
|
+
// substitutions, which removeSubstSpans below cuts out of the class view.
|
|
229
|
+
const DQ_ESCAPABLE = '"$`\\\n';
|
|
230
|
+
const stripQuotes = (token) => {
|
|
231
|
+
const src = String(token ?? '');
|
|
232
|
+
let out = '';
|
|
233
|
+
let i = 0;
|
|
234
|
+
while (i < src.length) {
|
|
235
|
+
const ch = src[i];
|
|
236
|
+
if (ch === "'") {
|
|
237
|
+
// Single quotes: fully literal to the next '. Unterminated consumes
|
|
238
|
+
// the rest (bash would reject; classify the pessimistic view).
|
|
239
|
+
const end = src.indexOf("'", i + 1);
|
|
240
|
+
out += src.slice(i + 1, end === -1 ? src.length : end);
|
|
241
|
+
i = end === -1 ? src.length : end + 1;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (ch === '"') {
|
|
245
|
+
// Double quotes: `\` escapes only before " $ ` \ newline; it is
|
|
246
|
+
// literal otherwise — `"C:\x"` must keep its backslash.
|
|
247
|
+
i += 1;
|
|
248
|
+
while (i < src.length && src[i] !== '"') {
|
|
249
|
+
if (src[i] === '\\' && i + 1 < src.length && DQ_ESCAPABLE.includes(src[i + 1])) {
|
|
250
|
+
out += src[i + 1];
|
|
251
|
+
i += 2;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
out += src[i];
|
|
255
|
+
i += 1;
|
|
256
|
+
}
|
|
257
|
+
i += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (ch === '\\') {
|
|
261
|
+
// Unquoted escape: drops the backslash, keeps the next char.
|
|
262
|
+
i += 1;
|
|
263
|
+
if (i < src.length) {
|
|
264
|
+
out += src[i];
|
|
265
|
+
i += 1;
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
out += ch;
|
|
270
|
+
i += 1;
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
};
|
|
274
|
+
// Substitution spans contribute their EXPANSION, which the scanner cannot
|
|
275
|
+
// know — classify the word with the span removed (fail-closed: `.env$(:)`,
|
|
276
|
+
// `.env$()` and `.env`:` splice onto a protected name exactly like the
|
|
277
|
+
// shell's empty expansion). `$(`/`${` spans are balanced so an inner `)`/`}`
|
|
278
|
+
// cannot truncate early; an unterminated span consumes the rest. The span's
|
|
279
|
+
// own body was already scanned by extractSubstBodies, so nothing hides in
|
|
280
|
+
// the removed text. Unbraced `$name`/`$1`/`$$` refs drop the same way —
|
|
281
|
+
// `$f.env` is `${f}.env` to the shell.
|
|
282
|
+
const removeSubstSpans = (token) => {
|
|
283
|
+
const src = String(token ?? '');
|
|
284
|
+
let out = '';
|
|
285
|
+
let i = 0;
|
|
286
|
+
while (i < src.length) {
|
|
287
|
+
const ch = src[i];
|
|
288
|
+
if (ch === '$' && (src[i + 1] === '(' || src[i + 1] === '{')) {
|
|
289
|
+
const open = src[i + 1];
|
|
290
|
+
const close = open === '(' ? ')' : '}';
|
|
291
|
+
let depth = 1;
|
|
292
|
+
let j = i + 2;
|
|
293
|
+
while (j < src.length && depth > 0) {
|
|
294
|
+
const b = src[j];
|
|
295
|
+
if (b === '\\') { j += 2; continue; }
|
|
296
|
+
if (b === open) depth += 1;
|
|
297
|
+
if (b === close) depth -= 1;
|
|
298
|
+
j += 1;
|
|
299
|
+
}
|
|
300
|
+
i = j; // unterminated: j === src.length consumes the rest
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (ch === '`') {
|
|
304
|
+
const end = src.indexOf('`', i + 1);
|
|
305
|
+
i = end === -1 ? src.length : end + 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (ch === '$') {
|
|
309
|
+
const name = /^\$[A-Za-z_][A-Za-z0-9_]*|^\$[?@$*#!0-9-]/.exec(src.slice(i));
|
|
310
|
+
if (name) {
|
|
311
|
+
i += name[0].length;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
out += ch;
|
|
316
|
+
i += 1;
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
};
|
|
320
|
+
// Classification view of a token: real shell dequote, substitution spans
|
|
321
|
+
// gone, plus the edge punctuation a protected name can hide behind —
|
|
322
|
+
// redirects are already split out, list separators trimmed at the edges.
|
|
323
|
+
const cleanPathToken = (token) =>
|
|
324
|
+
removeSubstSpans(stripQuotes(token)).replace(/^[(${]+/, '').replace(/[)},:;]+$/, '');
|
|
325
|
+
// Unquoted ${IFS}/$IFS expansions word-split exactly like whitespace —
|
|
326
|
+
// `cat${IFS}.env` IS `cat .env` to the shell. Quoted ${IFS} stays inside the
|
|
327
|
+
// word (a quoted expansion does not split).
|
|
328
|
+
const IFS_PREFIX = /^\$\{IFS[^}]*\}|^\$IFS(?![A-Za-z0-9_])/;
|
|
329
|
+
// Quote/IFS/substitution-aware word split. Tokens come back RAW (quotes and
|
|
330
|
+
// backslashes kept): `X="a b" cat .env` must not hand `"b"` the head slot,
|
|
331
|
+
// and keeping the quote chars lets stripQuotes re-parse modes correctly.
|
|
332
|
+
// Substitution spans are appended verbatim so interior spaces cannot split
|
|
333
|
+
// a word mid-span — extractSubstBodies already scanned their content.
|
|
334
|
+
const splitShellWords = (segment) => {
|
|
335
|
+
const src = String(segment ?? '');
|
|
336
|
+
const tokens = [];
|
|
337
|
+
let cur = '';
|
|
338
|
+
let has = false;
|
|
339
|
+
const flush = () => {
|
|
340
|
+
if (has) tokens.push(cur);
|
|
341
|
+
cur = '';
|
|
342
|
+
has = false;
|
|
343
|
+
};
|
|
344
|
+
let i = 0;
|
|
345
|
+
while (i < src.length) {
|
|
346
|
+
const ch = src[i];
|
|
347
|
+
if (ch === "'") {
|
|
348
|
+
const end = src.indexOf("'", i + 1);
|
|
349
|
+
cur += src.slice(i, end === -1 ? src.length : end + 1);
|
|
350
|
+
i = end === -1 ? src.length : end + 1;
|
|
351
|
+
has = true;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (ch === '"') {
|
|
355
|
+
const start = i;
|
|
356
|
+
i += 1;
|
|
357
|
+
while (i < src.length && src[i] !== '"') {
|
|
358
|
+
if (src[i] === '\\' && i + 1 < src.length && DQ_ESCAPABLE.includes(src[i + 1])) {
|
|
359
|
+
i += 2;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
i += 1;
|
|
363
|
+
}
|
|
364
|
+
i += 1;
|
|
365
|
+
cur += src.slice(start, i);
|
|
366
|
+
has = true;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (ch === '\\') {
|
|
370
|
+
cur += src.slice(i, Math.min(i + 2, src.length));
|
|
371
|
+
i = Math.min(i + 2, src.length);
|
|
372
|
+
has = true;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (ch === '<' || ch === '>' || /\s/.test(ch)) {
|
|
376
|
+
flush();
|
|
377
|
+
i += 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (ch === '$') {
|
|
381
|
+
const ifs = IFS_PREFIX.exec(src.slice(i));
|
|
382
|
+
if (ifs) {
|
|
383
|
+
// ${IFS}/$IFS is a separator itself — flush, skip the expansion,
|
|
384
|
+
// keep scanning (it never contributes literal text to a word).
|
|
385
|
+
flush();
|
|
386
|
+
i += ifs[0].length;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (src[i + 1] === '(' || src[i + 1] === '{') {
|
|
390
|
+
const open = src[i + 1];
|
|
391
|
+
const close = open === '(' ? ')' : '}';
|
|
392
|
+
const start = i;
|
|
393
|
+
let depth = 1;
|
|
394
|
+
let j = i + 2;
|
|
395
|
+
while (j < src.length && depth > 0) {
|
|
396
|
+
const b = src[j];
|
|
397
|
+
if (b === '\\') { j += 2; continue; }
|
|
398
|
+
if (b === open) depth += 1;
|
|
399
|
+
if (b === close) depth -= 1;
|
|
400
|
+
j += 1;
|
|
401
|
+
}
|
|
402
|
+
cur += src.slice(start, j);
|
|
403
|
+
i = j;
|
|
404
|
+
has = true;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (ch === '`') {
|
|
409
|
+
const end = src.indexOf('`', i + 1);
|
|
410
|
+
cur += src.slice(i, end === -1 ? src.length : end + 1);
|
|
411
|
+
i = end === -1 ? src.length : end + 1;
|
|
412
|
+
has = true;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
cur += ch;
|
|
416
|
+
i += 1;
|
|
417
|
+
has = true;
|
|
418
|
+
}
|
|
419
|
+
flush();
|
|
420
|
+
return tokens;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// W2-D03: `$(…)` and backtick bodies are scanned recursively (bounded by
|
|
424
|
+
// MAX_SUBST_DEPTH so a hostile nest cannot eat the hook deadline). The
|
|
425
|
+
// extractor is quote-mode aware: '$(…)' is literal text in bash and must
|
|
426
|
+
// NOT expand here, while "$(…)" still expands like the real shell.
|
|
427
|
+
const MAX_SUBST_DEPTH = 8;
|
|
428
|
+
const extractSubstBodies = (text) => {
|
|
429
|
+
const bodies = [];
|
|
430
|
+
// Scan src[pos..] under a quote mode: null = unquoted, '"' = inside
|
|
431
|
+
// double quotes (substitutions still expand). Single quotes freeze
|
|
432
|
+
// everything to the next '. Returns the index after the terminator
|
|
433
|
+
// (or src.length). An unterminated quote consumes the rest — the shell
|
|
434
|
+
// would reject the command anyway.
|
|
435
|
+
const scan = (src, pos, mode) => {
|
|
436
|
+
let i = pos;
|
|
437
|
+
while (i < src.length) {
|
|
438
|
+
const ch = src[i];
|
|
439
|
+
if (ch === '\\') { i += 2; continue; }
|
|
440
|
+
if (mode === '"') {
|
|
441
|
+
if (ch === '"') return i + 1;
|
|
442
|
+
} else if (ch === "'") {
|
|
443
|
+
const end = src.indexOf("'", i + 1);
|
|
444
|
+
i = end === -1 ? src.length : end + 1;
|
|
445
|
+
continue;
|
|
446
|
+
} else if (ch === '"') {
|
|
447
|
+
i = scan(src, i + 1, '"');
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (ch === '`') {
|
|
451
|
+
const end = src.indexOf('`', i + 1);
|
|
452
|
+
if (end === -1) return src.length;
|
|
453
|
+
bodies.push(src.slice(i + 1, end));
|
|
454
|
+
i = end + 1;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (ch === '$' && src[i + 1] === '(') {
|
|
458
|
+
let d = 1;
|
|
459
|
+
let j = i + 2;
|
|
460
|
+
while (j < src.length && d > 0) {
|
|
461
|
+
const b = src[j];
|
|
462
|
+
if (b === '\\') { j += 2; continue; }
|
|
463
|
+
if (b === "'") {
|
|
464
|
+
const e = src.indexOf("'", j + 1);
|
|
465
|
+
j = e === -1 ? src.length : e + 1;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (b === '"') { j = scan(src, j + 1, '"'); continue; }
|
|
469
|
+
if (b === '`') {
|
|
470
|
+
const e = src.indexOf('`', j + 1);
|
|
471
|
+
if (e === -1) { j = src.length; break; }
|
|
472
|
+
bodies.push(src.slice(j + 1, e));
|
|
473
|
+
j = e + 1;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (b === '$' && src[j + 1] === '(') { d += 1; j += 2; continue; }
|
|
477
|
+
if (b === '(') { d += 1; j += 1; continue; }
|
|
478
|
+
if (b === ')') { d -= 1; j += 1; continue; }
|
|
479
|
+
j += 1;
|
|
480
|
+
}
|
|
481
|
+
bodies.push(src.slice(i + 2, d === 0 ? j - 1 : src.length));
|
|
482
|
+
i = j;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
i += 1;
|
|
486
|
+
}
|
|
487
|
+
return i;
|
|
488
|
+
};
|
|
489
|
+
scan(text, 0, null);
|
|
490
|
+
return bodies;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const scanBashShapes = (command, depth = 0) => {
|
|
224
494
|
const found = [];
|
|
495
|
+
// Substitution bodies are extracted BEFORE segment splitting — a `;`
|
|
496
|
+
// inside `$(…)` belongs to the substitution, not the top level. Each body
|
|
497
|
+
// is scanned recursively AND its words are classified directly: `cat
|
|
498
|
+
// $(echo .env)` produces the file via substitution, a route no segment
|
|
499
|
+
// split can see.
|
|
500
|
+
if (depth < MAX_SUBST_DEPTH) {
|
|
501
|
+
for (const body of extractSubstBodies(command)) {
|
|
502
|
+
for (const inner of scanBashShapes(body, depth + 1)) found.push(inner);
|
|
503
|
+
for (const rawTok of splitShellWords(body)) {
|
|
504
|
+
const cleaned = cleanPathToken(rawTok);
|
|
505
|
+
const kind = classifySecretFile(cleaned);
|
|
506
|
+
if (kind && !pathAllowed(cleaned)) {
|
|
507
|
+
found.push({ label: `${kind} referenced in command substitution`, value: cleaned, isPath: true });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
225
512
|
const segments = command.split(/\|\||&&|;|\||\n/);
|
|
226
513
|
for (const rawSegment of segments) {
|
|
227
514
|
const segment = rawSegment.trim();
|
|
228
515
|
if (!segment) continue;
|
|
229
|
-
|
|
230
|
-
|
|
516
|
+
// Shell word semantics: whitespace AND `<`/`>` split words, quoted runs
|
|
517
|
+
// (`"a b"`), escapes and `${IFS}` separators behave like the shell —
|
|
518
|
+
// `cat<.env` splits on the redirect and `X="a b" cat .env` keeps the
|
|
519
|
+
// quoted space inside the assignment word. Tokens stay RAW here —
|
|
520
|
+
// cleanPathToken/unquote dequote once downstream; dequoting twice would
|
|
521
|
+
// eat a legit quoted backslash (`'C:\x\.env'` -> `C:x.env` -> miss).
|
|
522
|
+
// Words that dequote to nothing (`''`) drop so they never land in a
|
|
523
|
+
// head/arg slot, matching the pre-split filter(Boolean) behavior.
|
|
524
|
+
const tokens = splitShellWords(segment).filter((t) => stripQuotes(t) !== '');
|
|
525
|
+
if (!tokens.length) continue;
|
|
526
|
+
const head = cleanPathToken(tokens[0]);
|
|
527
|
+
// Tokens are RAW — quote chars are still on them so downstream helpers
|
|
528
|
+
// dequote once. Every literal comparison goes through unquote.
|
|
529
|
+
const unquote = stripQuotes;
|
|
231
530
|
|
|
232
531
|
if ((head === 'env' || head === 'printenv' || head === 'history') && tokens.length === 1) {
|
|
233
532
|
found.push({ label: `bare \`${head}\` dumps environment/history (may contain tokens)`, value: segment });
|
|
234
533
|
continue;
|
|
235
534
|
}
|
|
236
|
-
if (head === 'export' && (tokens[1] === '-p' || tokens[1] === '--print')) {
|
|
535
|
+
if (head === 'export' && (unquote(tokens[1]) === '-p' || unquote(tokens[1]) === '--print')) {
|
|
237
536
|
found.push({ label: 'bare `export -p` dumps all environment variables', value: segment });
|
|
238
537
|
continue;
|
|
239
538
|
}
|
|
@@ -258,18 +557,58 @@ async function evaluate({ rawInput, projectRoot, env }) {
|
|
|
258
557
|
['flock', 1],
|
|
259
558
|
['docker', 2], ['podman', 2], ['nerdctl', 2], ['ctr', 2],
|
|
260
559
|
['kubectl', 2], ['machinectl', 2],
|
|
560
|
+
// W2-D03: shell keywords are transparent to the verb check — `do cat
|
|
561
|
+
// .env` or `then cat .env` runs cat exactly like a wrapper does. The
|
|
562
|
+
// loop heads (for/select/read/done) are NOT here: they own their word
|
|
563
|
+
// list and get the dedicated LOOP_HEADS check below.
|
|
564
|
+
['do', 0], ['then', 0], ['else', 0], ['elif', 0],
|
|
565
|
+
['if', 0], ['while', 0], ['until', 0], ['!', 0],
|
|
566
|
+
]);
|
|
567
|
+
// Shells whose `-c` flag (also inside a cluster like `-lc`) introduces
|
|
568
|
+
// a command STRING: `bash -c 'cat .env'` hands the whole inner command
|
|
569
|
+
// to a child shell, so the operand's dequoted text is scanned as a
|
|
570
|
+
// nested command rather than classified as a path. `eval` (and remote
|
|
571
|
+
// wrappers such as `ssh`) carry the command string in the operand
|
|
572
|
+
// slot instead — that lane is handled at head resolution below.
|
|
573
|
+
const COMMAND_STRING_SHELLS = new Set([
|
|
574
|
+
'bash', 'sh', 'zsh', 'dash', 'ksh', 'su', 'sg', 'runuser',
|
|
261
575
|
]);
|
|
262
|
-
|
|
576
|
+
// Predicates dequote once: tokens are raw, and bash resolves `-n`/`X=v`
|
|
577
|
+
// the same whether or not the word carried quotes.
|
|
263
578
|
const isVerbToken = (t) => DUMP_VERBS.has(unquote(t).replace(/^.*\//, ''));
|
|
264
579
|
const wrapperPositionals = (t) => WRAPPER_POSITIONALS.get(unquote(t).replace(/^.*\//, ''));
|
|
265
|
-
const isAssignToken = (t) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
|
|
266
|
-
const isFlagToken = (t) => /^-[^-]?|--/.test(t);
|
|
580
|
+
const isAssignToken = (t) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(unquote(t));
|
|
581
|
+
const isFlagToken = (t) => /^-[^-]?|--/.test(unquote(t));
|
|
267
582
|
let headIdx = 0;
|
|
268
583
|
let wrapperPositionalPending = 0;
|
|
584
|
+
// The wrapper most recently entered in this chain — `bash -c` / `su -c`
|
|
585
|
+
// tell their flag operand apart from e.g. `grep -c` (a count, not a
|
|
586
|
+
// command string).
|
|
587
|
+
let lastWrapperBase = null;
|
|
588
|
+
// A shell `-c` flag operand and an `eval` operand ARE inner command
|
|
589
|
+
// text no matter how compact: `bash -c 'cat<.env'` and `eval
|
|
590
|
+
// 'cat${IFS}.env'` need no literal whitespace or quote marks, so the
|
|
591
|
+
// operand's dequoted text is scanned as a nested command whenever it is
|
|
592
|
+
// non-empty. Escaped whitespace is normalised to a plain separator
|
|
593
|
+
// first — `cat\ .env` dequotes to the word `cat .env`, which can never
|
|
594
|
+
// run as a command name anyway, so splitting it only denies a
|
|
595
|
+
// construct bash itself rejects while catching the real bypass where
|
|
596
|
+
// the OUTER shell consumed the escape (`bash -c cat\ .env`).
|
|
597
|
+
// A head-position word outside a command-string slot keeps the old
|
|
598
|
+
// gate: it only hides a nested command when its text carries
|
|
599
|
+
// whitespace or the word was quoted — otherwise it is a plain
|
|
600
|
+
// head/path token for the checks below.
|
|
601
|
+
const scanCommandString = (rawToken, viaCommandStringSlot) => {
|
|
602
|
+
if (rawToken === undefined || depth >= MAX_SUBST_DEPTH) return;
|
|
603
|
+
const inner = unquote(rawToken).replace(/\\(?=\s)/g, '');
|
|
604
|
+
if (!inner) return;
|
|
605
|
+
if (!viaCommandStringSlot && !/\s/.test(inner) && !/["'`]/.test(rawToken)) return;
|
|
606
|
+
for (const innerFinding of scanBashShapes(inner, depth + 1)) found.push(innerFinding);
|
|
607
|
+
};
|
|
269
608
|
for (;;) {
|
|
270
609
|
const t = tokens[headIdx];
|
|
271
610
|
if (t === undefined) break;
|
|
272
|
-
if (t === '--') { headIdx += 1; break; }
|
|
611
|
+
if (unquote(t) === '--') { headIdx += 1; break; }
|
|
273
612
|
if (isAssignToken(t)) { headIdx += 1; continue; }
|
|
274
613
|
if (wrapperPositionalPending > 0 && !isVerbToken(t) && !isFlagToken(t)) {
|
|
275
614
|
// The wrapper's own operand (user/host/duration/image/service), then
|
|
@@ -284,6 +623,7 @@ async function evaluate({ rawInput, projectRoot, env }) {
|
|
|
284
623
|
const positionalCount = wrapperPositionals(t);
|
|
285
624
|
if (positionalCount !== undefined) {
|
|
286
625
|
wrapperPositionalPending = positionalCount;
|
|
626
|
+
lastWrapperBase = unquote(t).replace(/^.*\//, '');
|
|
287
627
|
// `docker compose <sub> <svc> <cmd>` carries one extra operand (the
|
|
288
628
|
// service) beyond the flat two — `compose` is a sub-CLI, not a
|
|
289
629
|
// subcommand like `exec`.
|
|
@@ -303,18 +643,32 @@ async function evaluate({ rawInput, projectRoot, env }) {
|
|
|
303
643
|
&& !isAssignToken(next)
|
|
304
644
|
&& !isVerbToken(next)
|
|
305
645
|
&& wrapperPositionals(next) === undefined) {
|
|
646
|
+
// A shell `-c` operand IS a command string — classify its inner
|
|
647
|
+
// text as a nested command so `bash -c 'cat .env'` cannot hide
|
|
648
|
+
// the dump behind quoting. viaCommandStringSlot=true: compact
|
|
649
|
+
// strings (`cat<.env`, `cat${IFS}.env`) bypassed the
|
|
650
|
+
// whitespace/quote gate.
|
|
651
|
+
if (COMMAND_STRING_SHELLS.has(lastWrapperBase) && /^-[^-]*c/.test(unquote(t))) {
|
|
652
|
+
scanCommandString(next, true);
|
|
653
|
+
}
|
|
306
654
|
headIdx += 1;
|
|
307
655
|
}
|
|
308
656
|
continue;
|
|
309
657
|
}
|
|
658
|
+
// Head reached. When it is a quoted command string (`eval 'cat
|
|
659
|
+
// .env'`, `ssh host 'cat .env'`) the real verb lives INSIDE the
|
|
660
|
+
// token — classify the inner text instead of the literal words.
|
|
661
|
+
// `eval` gets the unconditional lane: its operand is re-parsed as a
|
|
662
|
+
// command string regardless of quoting or whitespace.
|
|
663
|
+
scanCommandString(t, lastWrapperBase === 'eval');
|
|
310
664
|
break;
|
|
311
665
|
}
|
|
312
|
-
const effectiveHead =
|
|
666
|
+
const effectiveHead = cleanPathToken(tokens[headIdx] ?? '');
|
|
313
667
|
const headBase = effectiveHead.replace(/^.*\//, '');
|
|
314
668
|
|
|
315
669
|
if (DUMP_VERBS.has(headBase)) {
|
|
316
670
|
for (const token of tokens.slice(headIdx + 1)) {
|
|
317
|
-
const cleaned = token
|
|
671
|
+
const cleaned = cleanPathToken(token);
|
|
318
672
|
const kind = classifySecretFile(cleaned);
|
|
319
673
|
if (kind && !pathAllowed(cleaned)) {
|
|
320
674
|
found.push({ label: `${effectiveHead} reads ${kind}`, value: cleaned, isPath: true });
|
|
@@ -322,6 +676,26 @@ async function evaluate({ rawInput, projectRoot, env }) {
|
|
|
322
676
|
}
|
|
323
677
|
}
|
|
324
678
|
|
|
679
|
+
// W2-D03: loop/read indirection — `for f in .env; do cat "$f"; done`
|
|
680
|
+
// reads the file even though no protected token sits in a dump-verb
|
|
681
|
+
// arg slot. Flag a protected literal in the `in` list (or the words
|
|
682
|
+
// after the loop head) instead of tracking variables; `read`/`done`
|
|
683
|
+
// carry the redirect target `while read x < .env` / `done < .env`.
|
|
684
|
+
const LOOP_HEADS = new Set(['for', 'select', 'read', 'done']);
|
|
685
|
+
if (LOOP_HEADS.has(headBase)) {
|
|
686
|
+
const inIdx = headBase === 'for' || headBase === 'select'
|
|
687
|
+
? tokens.findIndex((t, idx) => idx > headIdx && unquote(t) === 'in')
|
|
688
|
+
: -1;
|
|
689
|
+
const listStart = inIdx === -1 ? headIdx + 1 : inIdx + 1;
|
|
690
|
+
for (const token of tokens.slice(listStart)) {
|
|
691
|
+
const cleaned = cleanPathToken(token);
|
|
692
|
+
const kind = classifySecretFile(cleaned);
|
|
693
|
+
if (kind && !pathAllowed(cleaned)) {
|
|
694
|
+
found.push({ label: `${headBase} list references ${kind}`, value: cleaned, isPath: true });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
325
699
|
const CRED_USER_TOOLS = new Set(['curl', 'wget', 'ftp', 'lftp', 'aria2c', 'http', 'https']);
|
|
326
700
|
// `-uuser:pass`, `-u user:pass`, `--user=user:pass`, `--user user:pass` —
|
|
327
701
|
// the old `\s+` separator missed the joined and `=` forms entirely.
|
|
@@ -336,7 +710,16 @@ async function evaluate({ rawInput, projectRoot, env }) {
|
|
|
336
710
|
found.push({ label: 'URL with embedded credentials (user:pass@host)', value: segment });
|
|
337
711
|
}
|
|
338
712
|
}
|
|
339
|
-
|
|
713
|
+
// A protected file reachable through two routes (substitution body token
|
|
714
|
+
// AND a dump-verb slot in the same command) reports once — same verdict,
|
|
715
|
+
// less noise.
|
|
716
|
+
const seenPathValues = new Set();
|
|
717
|
+
return found.filter((finding) => {
|
|
718
|
+
if (!finding.isPath) return true;
|
|
719
|
+
if (seenPathValues.has(finding.value)) return false;
|
|
720
|
+
seenPathValues.add(finding.value);
|
|
721
|
+
return true;
|
|
722
|
+
});
|
|
340
723
|
};
|
|
341
724
|
|
|
342
725
|
// --- channel wiring ---
|
|
@@ -24,13 +24,17 @@ CONFIG_FILE="$PROJECT_ROOT/.ukit/storage/config.json"
|
|
|
24
24
|
__ukit_ep_gate="$(node -e '
|
|
25
25
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 2000;
|
|
26
26
|
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
27
|
+
// W2-D06: async read so a wedged filesystem cannot park the deadline above.
|
|
28
|
+
require("fs").promises.readFile(process.argv[1], "utf8")
|
|
29
|
+
.then((raw) => {
|
|
30
|
+
try {
|
|
31
|
+
const config = JSON.parse(raw);
|
|
32
|
+
process.stdout.write(config?.learning?.episodes?.autoWrite === true ? "1" : "0");
|
|
33
|
+
} catch {
|
|
34
|
+
process.stdout.write("0");
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
.catch(() => process.stdout.write("0"));
|
|
34
38
|
' "$CONFIG_FILE" 2>/dev/null)" || __ukit_ep_gate="0"
|
|
35
39
|
if [ "$__ukit_ep_gate" != "1" ]; then
|
|
36
40
|
exit 0
|
|
@@ -89,34 +93,66 @@ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || ""
|
|
|
89
93
|
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
90
94
|
|
|
91
95
|
const fs = require("fs");
|
|
92
|
-
const
|
|
96
|
+
const fsp = fs.promises;
|
|
97
|
+
const { spawn } = require("child_process");
|
|
93
98
|
|
|
94
99
|
const inputFile = process.argv[1];
|
|
95
100
|
const configPath = process.argv[2];
|
|
96
101
|
const projectRoot = process.argv[3];
|
|
97
102
|
|
|
98
|
-
|
|
103
|
+
// W2-D06: every filesystem and child-process wait inside the armed block is
|
|
104
|
+
// async — a wedged sync read/spawnSync used to park the event loop so the
|
|
105
|
+
// deadline above could never fire.
|
|
106
|
+
async function readJson(filePath) {
|
|
99
107
|
try {
|
|
100
|
-
return JSON.parse(
|
|
108
|
+
return JSON.parse(await fsp.readFile(filePath, "utf8"));
|
|
101
109
|
} catch {
|
|
102
110
|
return null;
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
// Bounded child: resolves on close; the timeout hard-kills so a hung `ukit`
|
|
115
|
+
// can never hold the hook — and even that timeout is only a backstop for the
|
|
116
|
+
// armed deadline, which is now free to preempt any residual wait.
|
|
117
|
+
function runBounded(cmd, args, { timeout, cwd, env, shell } = {}) {
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
let child;
|
|
120
|
+
try {
|
|
121
|
+
child = spawn(cmd, args, {
|
|
122
|
+
stdio: "ignore",
|
|
123
|
+
timeout,
|
|
124
|
+
killSignal: "SIGKILL",
|
|
125
|
+
...(shell ? { shell: true } : {}),
|
|
126
|
+
...(cwd ? { cwd } : {}),
|
|
127
|
+
...(env ? { env } : {}),
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
resolve({ ok: false });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let timedOut = false;
|
|
134
|
+
child.on("error", () => resolve({ ok: false }));
|
|
135
|
+
child.on("close", (code, signal) => {
|
|
136
|
+
resolve({ ok: !timedOut && code === 0 && !signal });
|
|
137
|
+
});
|
|
138
|
+
child.on("timeout", () => { timedOut = true; });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
(async () => {
|
|
143
|
+
let payload = {};
|
|
144
|
+
try {
|
|
145
|
+
const raw = await fsp.readFile(inputFile, "utf8");
|
|
146
|
+
payload = JSON.parse(raw || "{}") || {};
|
|
147
|
+
} catch {}
|
|
111
148
|
|
|
112
|
-
const config = readJson(configPath) || {};
|
|
113
|
-
if (config?.learning?.episodes?.autoWrite !== true) process.exit(0);
|
|
149
|
+
const config = (await readJson(configPath)) || {};
|
|
150
|
+
if (config?.learning?.episodes?.autoWrite !== true) process.exit(0);
|
|
114
151
|
|
|
115
|
-
const sessionId = typeof payload.session_id === "string" && payload.session_id.trim()
|
|
116
|
-
|
|
117
|
-
|
|
152
|
+
const sessionId = typeof payload.session_id === "string" && payload.session_id.trim()
|
|
153
|
+
? payload.session_id.trim()
|
|
154
|
+
: null;
|
|
118
155
|
|
|
119
|
-
(async () => {
|
|
120
156
|
// 3.3.0: prefer the absolute node + CLI recorded by `ukit install`
|
|
121
157
|
// (.ukit/storage/cli.json, validated by resolveCliLocator) — GUI-launched
|
|
122
158
|
// hosts often lack nvm/volta on PATH, which made the bare `ukit` spawn fail
|
|
@@ -130,14 +166,10 @@ const sessionId = typeof payload.session_id === "string" && payload.session_id.t
|
|
|
130
166
|
? { cmd: located.node, pre: [located.bin], shell: false }
|
|
131
167
|
: { cmd: "ukit", pre: [], shell: true };
|
|
132
168
|
|
|
133
|
-
const probe =
|
|
134
|
-
|
|
135
|
-
});
|
|
136
|
-
if (probe.error || probe.status === null || probe.status === undefined) return;
|
|
169
|
+
const probe = await runBounded(cli.cmd, [...cli.pre, "--version"], { timeout: 2000 });
|
|
170
|
+
if (!probe.ok) return;
|
|
137
171
|
|
|
138
|
-
|
|
139
|
-
shell: cli.shell,
|
|
140
|
-
stdio: "ignore",
|
|
172
|
+
await runBounded(cli.cmd, [...cli.pre, "memory", "episode"], {
|
|
141
173
|
timeout: 4000,
|
|
142
174
|
cwd: projectRoot,
|
|
143
175
|
env: sessionId
|
|
@@ -88,7 +88,7 @@ setTimeout(() => {
|
|
|
88
88
|
const fs = require('fs').promises;
|
|
89
89
|
const path = require('path');
|
|
90
90
|
const { pathToFileURL } = require('url');
|
|
91
|
-
const {
|
|
91
|
+
const { execFile } = require('child_process');
|
|
92
92
|
|
|
93
93
|
async function readJson(filePath, fallback = null) {
|
|
94
94
|
try {
|
|
@@ -432,18 +432,29 @@ const NEW_SOURCE_FILE_RE = /\.(jsx?|tsx?|mjs|cjs|vue|svelte|css|scss|sass|less|h
|
|
|
432
432
|
|
|
433
433
|
// `git status --porcelain` positions: untracked entries start '??', index-staged
|
|
434
434
|
// adds start 'A'. Both are "component-create" candidates for the wiring check.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
435
|
+
// W2-D06: async execFile — a wedged git (slow index lock, network fs) must not
|
|
436
|
+
// park the event loop inside the armed deadline block the way execFileSync did.
|
|
437
|
+
// SIGKILL keeps the child bound hard even for a TERM-ignoring process.
|
|
438
|
+
async function readGitChangedPaths(projectRoot) {
|
|
439
|
+
return new Promise((resolve) => {
|
|
440
|
+
try {
|
|
441
|
+
execFile('git', ['status', '--porcelain', '--untracked-files=all'], {
|
|
442
|
+
cwd: projectRoot,
|
|
443
|
+
encoding: 'utf8',
|
|
444
|
+
timeout: 1500,
|
|
445
|
+
killSignal: 'SIGKILL',
|
|
446
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
447
|
+
}, (error, stdout) => {
|
|
448
|
+
if (error) {
|
|
449
|
+
resolve(null);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
resolve(String(stdout).split('\n').map((line) => line.trim()).filter(Boolean));
|
|
453
|
+
});
|
|
454
|
+
} catch {
|
|
455
|
+
resolve(null);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
447
458
|
}
|
|
448
459
|
|
|
449
460
|
async function readSessionLedger(projectRoot, payload, ledgerModule) {
|
|
@@ -470,7 +481,7 @@ async function observeSmallFeatureDoneCriteria({ projectRoot, payload, state })
|
|
|
470
481
|
}
|
|
471
482
|
const ledgerModule = await loadLedgerModule();
|
|
472
483
|
const ledger = await readSessionLedger(projectRoot, payload, ledgerModule);
|
|
473
|
-
const gitLines = readGitChangedPaths(projectRoot);
|
|
484
|
+
const gitLines = await readGitChangedPaths(projectRoot);
|
|
474
485
|
const newSourceFiles = gitLines === null ? [] : gitLines
|
|
475
486
|
.filter((line) => (line.startsWith('??') || line.startsWith('A')) && NEW_SOURCE_FILE_RE.test(line))
|
|
476
487
|
.map((line) => line.replace(/^(..)\s+/, ''))
|
|
@@ -578,7 +589,7 @@ async function observeVerificationRecipe({ projectRoot, payload, state }) {
|
|
|
578
589
|
const mapModule = await loadVerificationMapModule();
|
|
579
590
|
if (!mapModule) return;
|
|
580
591
|
|
|
581
|
-
const gitLines = readGitChangedPaths(projectRoot);
|
|
592
|
+
const gitLines = await readGitChangedPaths(projectRoot);
|
|
582
593
|
if (gitLines === null) return; // no git answer → no classification, no recipe
|
|
583
594
|
const changedPaths = gitLines.map((line) => line.replace(/^(..)\s+/, ''));
|
|
584
595
|
const artifactClass = mapModule.classifyArtifactClass(changedPaths);
|