@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/code.js +29 -5
  4. package/src/cli/commands/decision.js +18 -4
  5. package/src/cli/commands/doctor.js +7 -3
  6. package/src/cli/commands/install.js +29 -4
  7. package/src/cli/commands/memory.js +25 -5
  8. package/src/cli/commands/telemetry.js +18 -1
  9. package/src/cli/commands/vm.js +7 -1
  10. package/src/context/detectProjectContext.js +7 -2
  11. package/src/core/agentRuntime/contract.js +5 -1
  12. package/src/core/agentRuntime/eventStore.js +54 -7
  13. package/src/core/agentRuntime/recovery.js +22 -15
  14. package/src/core/agentRuntime/supervisor.js +71 -13
  15. package/src/core/applyPlan.js +11 -1
  16. package/src/core/codeintel/compiler.js +51 -8
  17. package/src/core/codeintel/diagnostics.js +124 -33
  18. package/src/core/codeintel/freshness.js +25 -12
  19. package/src/core/codeintel/invalidation.js +11 -3
  20. package/src/core/codeintel/retriever.js +53 -20
  21. package/src/core/codeintel/router.js +19 -9
  22. package/src/core/codeintel/summaries.js +4 -3
  23. package/src/core/codeintel/vectorProvider.js +30 -4
  24. package/src/core/compact/index.js +24 -7
  25. package/src/core/compact/threshold.js +49 -14
  26. package/src/core/diffPlan.js +51 -23
  27. package/src/core/ensureGitignore.js +19 -2
  28. package/src/core/fileOps.js +61 -0
  29. package/src/core/memory/hygiene.js +51 -1
  30. package/src/core/memory/migrate.js +41 -21
  31. package/src/core/memory/store.js +96 -61
  32. package/src/core/metadata.js +37 -2
  33. package/src/core/observability/adapters/ingest.js +30 -2
  34. package/src/core/observability/emit/config.js +19 -4
  35. package/src/core/observability/emit/crash.js +3 -1
  36. package/src/core/observability/emit/recorder.js +15 -7
  37. package/src/core/observability/privacy/sanitizeObserved.js +3 -1
  38. package/src/core/observability/segments/internal.js +36 -8
  39. package/src/core/observability/segments/retention.js +11 -0
  40. package/src/core/observability/support/import.js +27 -1
  41. package/src/core/output/index.js +16 -1
  42. package/src/core/permissionDoctor.js +72 -9
  43. package/src/core/repairBrokenHooks.js +15 -2
  44. package/src/core/reviewPanelAggregate.js +26 -10
  45. package/src/core/runInstallPipeline.js +71 -22
  46. package/src/core/runtimeConfig.js +2 -0
  47. package/src/core/status.js +2 -0
  48. package/src/core/taskBudgetValidator.js +7 -1
  49. package/src/core/taskProgressGuard.js +11 -1
  50. package/src/core/unattendedDoctor.js +36 -5
  51. package/src/core/uninstall.js +52 -12
  52. package/src/core/update.js +5 -1
  53. package/src/decision/client.js +158 -27
  54. package/src/decision/reviewVerdict.js +23 -7
  55. package/src/diagnostics/failurePatterns.js +1 -1
  56. package/src/diagnostics/feedbackEvents.js +1 -1
  57. package/src/diagnostics/routeOutcomes.js +42 -4
  58. package/src/diagnostics/skillAccuracy.js +35 -4
  59. package/src/index/buildIndex.js +123 -26
  60. package/src/index/fixLoopEscalation.js +3 -0
  61. package/src/index/gitHooks.js +99 -29
  62. package/src/index/importResolution.js +7 -1
  63. package/src/index/playbookRegistry.js +15 -11
  64. package/src/index/queryIndex.js +28 -10
  65. package/src/index/routeResolver.js +8 -3
  66. package/src/index/taskRouting.js +37 -2
  67. package/src/learning/codeProposals.js +24 -5
  68. package/src/learning/selfImprove.js +29 -5
  69. package/src/learning/tunedOverlay.js +18 -7
  70. package/src/learning/tuning.js +10 -4
  71. package/src/skill/auditSkill.js +46 -7
  72. package/template_project/.claude/commands/ukit/handoff-review.md +4 -1
  73. package/template_project/.claude/hooks/auto-allow-bash.sh +10 -1
  74. package/template_project/.claude/hooks/block-dangerous.mjs +10 -2
  75. package/template_project/.claude/hooks/handoff-model-guard.sh +46 -16
  76. package/template_project/.claude/hooks/reset-compact-pressure.sh +128 -72
  77. package/template_project/.claude/hooks/sensitive-data-guard.mjs +394 -11
  78. package/template_project/.claude/hooks/session-episode.sh +60 -28
  79. package/template_project/.claude/hooks/verification-guard.sh +26 -15
  80. package/template_project/.claude/skills/pptx/scripts/thumbnail.py +6 -1
  81. package/template_project/.claude/ukit/index/lib/index-core.mjs +156 -39
  82. package/template_project/.claude/ukit/index/playbook-registry.mjs +15 -11
  83. package/template_project/.claude/ukit/index/post-edit-verify.mjs +25 -4
  84. package/template_project/.claude/ukit/index/pre-edit-backup.mjs +4 -0
  85. package/template_project/.claude/ukit/index/provision-worktree.mjs +15 -10
  86. package/template_project/.claude/ukit/index/query-index.mjs +13 -6
  87. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +127 -25
  88. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +36 -14
  89. package/template_project/.claude/ukit/index/review-verdict.mjs +93 -19
  90. package/template_project/.claude/ukit/index/route-resolver.mjs +8 -3
  91. package/template_project/.claude/ukit/index/route-task.mjs +15 -0
  92. package/template_project/.claude/ukit/index/safe-patch.mjs +4 -1
  93. package/template_project/.claude/ukit/index/sidecar-decision.mjs +43 -10
  94. package/template_project/.claude/ukit/index/stale-spec-check.mjs +13 -3
  95. package/template_project/.claude/ukit/index/task-budget-validator.mjs +7 -1
  96. package/template_project/.claude/ukit/index/unic-decision.mjs +179 -28
  97. package/template_project/.claude/ukit/index/unic-gateway.mjs +33 -8
  98. package/template_project/.claude/ukit/index/verify-context.mjs +9 -2
  99. package/template_project/.claude/ukit/index/worktree-sweep.mjs +89 -31
  100. package/template_project/.claude/ukit/runtime/compact-threshold.mjs +47 -15
  101. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +63 -30
  102. package/template_project/.claude/ukit/runtime/hook-field-salvage.mjs +49 -13
  103. package/template_project/.claude/ukit/runtime/hook-input.sh +48 -13
  104. package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +92 -7
  105. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +17 -0
  106. package/template_project/.claude/ukit/runtime/observability-emit.mjs +38 -10
  107. package/template_project/.claude/ukit/runtime/output-compression.mjs +11 -0
  108. package/template_project/.claude/ukit/runtime/reinject-context.mjs +24 -3
  109. package/template_project/.claude/ukit/runtime/resumable-run.mjs +62 -32
  110. 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
- const scanBashShapes = (command) => {
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
- const tokens = segment.split(/\s+/);
230
- const head = tokens[0].replace(/^["']+|["']+$/g, '');
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
- const unquote = (t) => String(t || '').replace(/^["']+|["']+$/g, '');
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 = unquote(tokens[headIdx] ?? '');
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.replace(/^["']+|["',:]+$/g, '');
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
- return found;
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
- const fs = require("fs");
28
- try {
29
- const config = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
30
- process.stdout.write(config?.learning?.episodes?.autoWrite === true ? "1" : "0");
31
- } catch {
32
- process.stdout.write("0");
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 { spawnSync } = require("child_process");
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
- function readJson(filePath) {
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(fs.readFileSync(filePath, "utf8"));
108
+ return JSON.parse(await fsp.readFile(filePath, "utf8"));
101
109
  } catch {
102
110
  return null;
103
111
  }
104
112
  }
105
113
 
106
- let payload = {};
107
- try {
108
- const raw = fs.readFileSync(inputFile, "utf8");
109
- payload = JSON.parse(raw || "{}") || {};
110
- } catch {}
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
- ? payload.session_id.trim()
117
- : null;
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 = spawnSync(cli.cmd, [...cli.pre, "--version"], {
134
- shell: cli.shell, stdio: "ignore", timeout: 2000,
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
- spawnSync(cli.cmd, [...cli.pre, "memory", "episode"], {
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 { execFileSync } = require('child_process');
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
- function readGitChangedPaths(projectRoot) {
436
- try {
437
- const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], {
438
- cwd: projectRoot,
439
- encoding: 'utf8',
440
- timeout: 1500,
441
- stdio: ['ignore', 'pipe', 'ignore'],
442
- });
443
- return out.split('\n').map((line) => line.trim()).filter(Boolean);
444
- } catch {
445
- return null;
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);