@ngockhoale/ukit 2.6.0 → 2.6.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 (36) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/manifests/platform.full.yaml +16 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/install.js +49 -2
  5. package/src/cli/commands/update.js +5 -0
  6. package/src/core/output/index.js +77 -0
  7. package/src/core/update.js +36 -5
  8. package/templates/.claude/commands/ukit/handoff-clear.md +2 -2
  9. package/templates/.claude/commands/ukit/handoff-fullstack.md +10 -7
  10. package/templates/.claude/hooks/auto-allow-bash.sh +17 -5
  11. package/templates/.claude/hooks/block-dangerous.sh +18 -5
  12. package/templates/.claude/hooks/completion-gate.sh +17 -5
  13. package/templates/.claude/hooks/compress-output.sh +15 -6
  14. package/templates/.claude/hooks/context-hardcap-gate.sh +77 -46
  15. package/templates/.claude/hooks/context-window-guard.sh +41 -27
  16. package/templates/.claude/hooks/handoff-model-guard.sh +41 -17
  17. package/templates/.claude/hooks/handoff-resume.sh +20 -7
  18. package/templates/.claude/hooks/post-edit-verify.sh +17 -5
  19. package/templates/.claude/hooks/pre-edit-backup.sh +17 -5
  20. package/templates/.claude/hooks/project-important.sh +18 -1
  21. package/templates/.claude/hooks/protect-files.sh +18 -5
  22. package/templates/.claude/hooks/record-execution.sh +17 -5
  23. package/templates/.claude/hooks/sensitive-data-guard.sh +48 -9
  24. package/templates/.claude/hooks/skill-router.sh +124 -86
  25. package/templates/.claude/hooks/stale-spec-guard.sh +22 -6
  26. package/templates/.claude/hooks/task-watchdog.sh +22 -7
  27. package/templates/.claude/hooks/verification-guard.sh +17 -5
  28. package/templates/.claude/hooks/vision-router.sh +17 -5
  29. package/templates/.claude/settings.json +15 -10
  30. package/templates/.claude/ukit/index/provision-worktree.mjs +30 -2
  31. package/templates/.claude/ukit/runtime/execution-ledger.mjs +99 -1
  32. package/templates/.claude/ukit/runtime/hook-input.sh +25 -0
  33. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +86 -2
  34. package/templates/.claude/ukit/runtime/output-compression.mjs +87 -0
  35. package/templates/.omp/hooks/pre/ukit-bridge.js +110 -3
  36. package/templates/ukit/storage/config.json +1 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.6.2 - 2026-09-18
6
+
7
+ Stability release — 17 confirmed hang / silent-idle / silent-hook defects fixed with
8
+ root causes (full register: `docs/AI_HANDOFF/BUGS-2.6.2.md`), focused on the "system
9
+ goes quiet and does nothing" failure class.
10
+
11
+ - **No more silent degraded input (§8 contract, BUG-C21-01/02/03)**: every hook that
12
+ stages stdin now *announces* when staging fails, stalls, or refuses — fail-closed
13
+ hooks (`block-dangerous`, `protect-files`, `context-hardcap-gate`,
14
+ `sensitive-data-guard`) emit an `ask`/`systemMessage` JSON + stderr `BLOCKED` +
15
+ `exit 2`; advisory hooks emit a `systemMessage` + `exit 0`. The old bounded-cut
16
+ silently truncated and proceeded. `sensitive-data-guard` now also fails closed
17
+ (exit 2) on any non-refusal staging failure and mirrors its deadline block to
18
+ stderr so the reason survives `process.exit` truncation.
19
+ - **Sync-fs defeats inside self-deadlines removed (BUG-C21-04)**: the
20
+ deadline-guarded node blocks in `context-window-guard.sh` and
21
+ `context-hardcap-gate.sh` no longer call synchronous `fs.*Sync` inside the kill
22
+ window — a stalled `.ukit` mount can no longer freeze the gate so the unref'd
23
+ deadline can never fire. (Residual call-frame past the grant boundary recorded
24
+ as BUG-C21-R1/R2 for the next cycle.)
25
+ - **Host timeouts now cover internal budgets (BUG-C21-05)**: every hook registered
26
+ in `settings.json` gets `timeout ≥ stage bound + node deadline + margin`; the
27
+ regression test iterates *all* registered hooks instead of a hand-picked list,
28
+ and four under-budgeted hooks (`auto-allow-bash`, `vision-router`,
29
+ `auto-prune-bash`, `reset-compact-pressure`) were raised to 12s.
30
+ - **Missing `await` in skill-router (review major)**: an un-awaited async context
31
+ snapshot could leave the router writing a stale/partial decision silently.
32
+ - **CLI hangs eliminated (BUG-C21-12/13)**: `ukit update`/`install` `spawnSync`
33
+ calls now carry timeouts with `killSignal: 'SIGKILL'` (SIGTERM alone keeps
34
+ waiting on signal-resistant children); a wedged `npm root -g` now reports
35
+ `global-root-query-timeout` instead of hanging forever. `promptYesNo` resolves
36
+ on stdin EOF / `ERR_USE_AFTER_CLOSE` instead of hanging or crashing.
37
+ - **Unbounded growth bounded (BUG-C21-07/09/10/11)**: `tee/` cache, hook-errors
38
+ jsonl, hook-latency and exec-ledger session files now have count caps; the
39
+ ledger protects receipt files (`.journal`, `.quarantine`, gate crash counter)
40
+ from count-cap eviction, and the overflow basis uses `entries.length`
41
+ (post-filter) everywhere.
42
+ - **Handoff machinery (BUG-C21-15)**: all four `RUN.md` `Phase:` consumers treat
43
+ `done`/`blocked` as terminal — dead runs no longer nag forever.
44
+ - **Wiring/parity (BUG-C21-06/08/14/16/17)**: stale-spec-guard deadline exits
45
+ distinguish "stall" from "match"; `provision-worktree` spawnSync calls carry
46
+ timeouts; `task-budget-validator.mjs` is manifest-covered so the documented
47
+ command works on fresh installs; `verification-guard.sh` is actually registered
48
+ on PreToolUse Bash; shipped `config.json` regained `codex-context-budget` and a
49
+ parity test now guards every array field under `subagents`.
50
+
51
+
5
52
  ## 2.6.0 - 2026-09-18
6
53
 
7
54
  handoff-fullstack v2 — autonomous create→implement→review loop that runs to 100%
@@ -1548,6 +1548,22 @@ items:
1548
1548
  packs:
1549
1549
  - core
1550
1550
 
1551
+ # BUG-C21-14: this file shipped in templates/ but was never manifest-listed, so
1552
+ # autoDiscoverTemplates:false silently skipped it and the agent-documented
1553
+ # `node .claude/ukit/index/task-budget-validator.mjs` command 404'd on installs.
1554
+ # tests/manifest/settingsHookCoverage.test.js now asserts every file under
1555
+ # templates/.claude/ukit/index/ is covered, which immunizes the next added module.
1556
+ - id: ukit-index-task-budget-validator-script
1557
+ type: config
1558
+ sourceTemplate: .claude/ukit/index/task-budget-validator.mjs
1559
+ targetPath: .claude/ukit/index/task-budget-validator.mjs
1560
+ requires: []
1561
+ mergeStrategy: overwrite_with_backup
1562
+ variables: []
1563
+ enabledByDefault: true
1564
+ packs:
1565
+ - core
1566
+
1551
1567
  - id: gitignore-root
1552
1568
  type: config
1553
1569
  sourceTemplate: .gitignore
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -81,9 +81,46 @@ async function loadTrackedManagedPathSet(projectRoot) {
81
81
  );
82
82
  }
83
83
 
84
+ // BUG-C21-13: `readline/promises` `question()` does not settle when the input
85
+ // stream hits EOF without a pending newline — the promise stays pending after
86
+ // the interface `close` event, so an unattended Ctrl+D / piped stdin froze
87
+ // `runInstall` forever. Race every question against the interface's `close`
88
+ // event; on EOF treat it as the default answer (decline → retain) and stop.
89
+ // A post-EOF `question()` also throws ERR_USE_AFTER_CLOSE — same treatment.
90
+ function onceInterfaceClosed(line) {
91
+ return new Promise((resolve) => {
92
+ if (typeof line.once === 'function') {
93
+ line.once('close', resolve);
94
+ }
95
+ });
96
+ }
97
+
98
+ async function askWithEofGuard(line, question) {
99
+ if (line.closed === true) {
100
+ return null;
101
+ }
102
+ try {
103
+ const answer = await Promise.race([line.question(question), onceInterfaceClosed(line)]);
104
+ return typeof answer === 'string' ? answer : null;
105
+ } catch (error) {
106
+ if (error?.code === 'ERR_USE_AFTER_CLOSE') {
107
+ return null;
108
+ }
109
+ throw error;
110
+ }
111
+ }
112
+
84
113
  async function promptYesNo(line, question) {
85
114
  while (true) {
86
- const answer = (await line.question(question)).trim().toLowerCase();
115
+ const raw = await askWithEofGuard(line, question);
116
+
117
+ if (raw === null) {
118
+ // EOF / interface closed: default answer (No) — never re-prompt a dead stream.
119
+ console.log('[UKit] Input ended (EOF) — keeping existing files.');
120
+ return false;
121
+ }
122
+
123
+ const answer = raw.trim().toLowerCase();
87
124
 
88
125
  if (answer === '') {
89
126
  return false;
@@ -97,6 +134,12 @@ async function promptYesNo(line, question) {
97
134
  return false;
98
135
  }
99
136
 
137
+ // A real terminal stays open after a bad answer, so re-prompting is safe —
138
+ // but if the stream just closed mid-loop, bail out as EOF.
139
+ if (line.closed === true) {
140
+ console.log('[UKit] Input ended (EOF) — keeping existing files.');
141
+ return false;
142
+ }
100
143
  console.log('[UKit] Please answer y/yes or n/no.');
101
144
  }
102
145
  }
@@ -191,7 +234,11 @@ export async function pruneDeselectedAdapters({
191
234
  console.log(`[UKit] Removed ${removedCount} ${adapter.label} path(s).`);
192
235
  }
193
236
  } finally {
194
- line.close();
237
+ try {
238
+ line.close();
239
+ } catch {
240
+ // close() is best-effort cleanup — never let it mask the real result.
241
+ }
195
242
  }
196
243
 
197
244
  return { retainedManagedPaths: [...new Set(retainedManagedPaths)] };
@@ -44,6 +44,11 @@ export async function runUpdate({ packageVersion, argv = [], cwd = process.cwd()
44
44
  console.log('[UKit] Run `ukit install` inside a project to set it up.');
45
45
  return;
46
46
  }
47
+ if (reason === 'global-root-query-timeout') {
48
+ console.log('[UKit] Update complete, but locating the upgraded CLI timed out.');
49
+ console.log('[UKit] `npm root -g` did not answer within 15s — check for a wedged npm/network, then run `ukit install` to refresh this project.');
50
+ return;
51
+ }
47
52
  if (reason === 'global-bin-not-found') {
48
53
  console.log('[UKit] Update complete, but the upgraded CLI could not be located.');
49
54
  console.log('[UKit] Run `ukit install` to refresh this project.');
@@ -353,6 +353,81 @@ function buildRecoveryHintSummary(summary, rawPath, { tokensBefore = 0 } = {}) {
353
353
  return null;
354
354
  }
355
355
 
356
+ // --- tee/ cache bound (BUG-C21-07, FR-006) -----------------------------------
357
+ // Mirror of the bound in templates/.claude/ukit/runtime/output-compression.mjs —
358
+ // keep the two implementations identical. persistRawOutput wrote one preserved
359
+ // file per compression and nothing ever deleted from tee/. Sweeps are SAMPLED
360
+ // (~1/16 writes, UKIT_TEE_SWEEP_PROBABILITY to force/disable) and BOUNDED
361
+ // (<= maxEntries stats, <= maxRemovals unlinks per sweep).
362
+ const TEE_SWEEP_PROBABILITY_DEFAULT = 1 / 16;
363
+ const TEE_SWEEP_MAX_ENTRIES = 128;
364
+ const TEE_SWEEP_MAX_REMOVALS = 64;
365
+ const TEE_MAX_FILES = 500;
366
+ const TEE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
367
+
368
+ function teeSweepProbabilityFromEnv() {
369
+ const raw = Number(process.env.UKIT_TEE_SWEEP_PROBABILITY);
370
+ if (!Number.isFinite(raw)) return TEE_SWEEP_PROBABILITY_DEFAULT;
371
+ return Math.min(1, Math.max(0, raw));
372
+ }
373
+
374
+ // See the runtime mirror for the contract: removes entries older than maxAgeMs,
375
+ // then the oldest scanned entries while the dir count exceeds maxFiles — all
376
+ // bounded by maxEntries/maxRemovals so an oversized dir is amortized down.
377
+ export async function sweepTeeCache(dir, {
378
+ now = Date.now,
379
+ maxAgeMs = TEE_MAX_AGE_MS,
380
+ maxFiles = TEE_MAX_FILES,
381
+ maxEntries = TEE_SWEEP_MAX_ENTRIES,
382
+ maxRemovals = TEE_SWEEP_MAX_REMOVALS,
383
+ } = {}) {
384
+ let names;
385
+ try {
386
+ names = await fs.readdir(dir);
387
+ } catch {
388
+ return { sampled: true, scanned: 0, removed: 0 };
389
+ }
390
+ const cutoff = now() - maxAgeMs;
391
+ const entries = [];
392
+ let scanned = 0;
393
+ for (const name of names) {
394
+ if (scanned >= maxEntries) break;
395
+ scanned += 1;
396
+ try {
397
+ const stats = await fs.stat(path.join(dir, name));
398
+ if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
399
+ } catch { /* raced away — fine */ }
400
+ }
401
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
402
+ // Overflow counts eligible FILES only — foreign entries (non-matching names,
403
+ // dirs) inflate `names.length` and would evict real entries while the dir is
404
+ // actually under the cap.
405
+ const overflow = Math.max(0, entries.length - maxFiles);
406
+ let removed = 0;
407
+ for (const entry of entries) {
408
+ if (removed >= maxRemovals) break;
409
+ if (removed < overflow || entry.mtimeMs < cutoff) {
410
+ try {
411
+ await fs.rm(path.join(dir, entry.name), { force: true });
412
+ removed += 1;
413
+ } catch { /* raced away — fine */ }
414
+ }
415
+ }
416
+ return { sampled: true, scanned, removed };
417
+ }
418
+
419
+ export async function maybeSweepTeeCache(dir, {
420
+ probability,
421
+ random = Math.random,
422
+ ...options
423
+ } = {}) {
424
+ const p = Number.isFinite(probability)
425
+ ? Math.min(1, Math.max(0, probability))
426
+ : teeSweepProbabilityFromEnv();
427
+ if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
428
+ return sweepTeeCache(dir, options);
429
+ }
430
+
356
431
  async function persistRawOutput(projectRoot, {
357
432
  command = '',
358
433
  summary = '',
@@ -379,6 +454,8 @@ async function persistRawOutput(projectRoot, {
379
454
 
380
455
  await fs.mkdir(teeCacheDir, { recursive: true });
381
456
  await fs.writeFile(absolutePath, rawOutputText, 'utf8');
457
+ // Bounded, sampled tee/ prune (BUG-C21-07): advisory only.
458
+ await maybeSweepTeeCache(teeCacheDir).catch(() => {});
382
459
 
383
460
  return {
384
461
  rawSaved: true,
@@ -10,6 +10,22 @@ export const UKIT_PACKAGE_NAME = '@ngockhoale/ukit';
10
10
  // CLAUDE.md and edits .gitignore — an unwanted surprise in, say, a home directory).
11
11
  const INSTALLED_MARKERS = ['.ukit', '.claude', 'CLAUDE.md', 'AGENTS.md', '.codex'];
12
12
 
13
+ // BUG-C21-12 (SPEC FR-005): spawnSync defaults to NO timeout, so a wedged npm
14
+ // (network partition, hung credential helper) would block the CLI forever.
15
+ // Per-operation CLI budgets: a pure `npm root -g` resolution is cheap (~15s);
16
+ // `npm install -g` and the post-update reinstall child get a generous 10-minute
17
+ // bound so slow-but-alive installs still finish.
18
+ // killSignal SIGKILL, not the default SIGTERM: a wedged child that ignores
19
+ // SIGTERM (npm's signal-exit cleanup handler can itself wedge) would keep
20
+ // spawnSync waiting past the timeout — verified on Node v22: SIGTERM on a
21
+ // signal-ignoring child blocks indefinitely, SIGKILL returns ~immediately with
22
+ // ETIMEDOUT. Direct-child kill only (no process-group tree kill): these are
23
+ // short-lived CLI one-shots; the detached-group TERM→KILL posture stays in
24
+ // templates/.claude/ukit/runtime/hook-process.mjs where hooks need it.
25
+ export const NPM_QUERY_TIMEOUT_MS = 15_000;
26
+ export const NPM_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
27
+ const KILL_ON_TIMEOUT = 'SIGKILL';
28
+
13
29
  export function hasUkitInstalled(projectRoot) {
14
30
  return INSTALLED_MARKERS.some((marker) => fs.existsSync(path.join(projectRoot, marker)));
15
31
  }
@@ -23,12 +39,23 @@ export function hasUkitInstalled(projectRoot) {
23
39
  * Only a fresh child process picks up the newly written package.
24
40
  */
25
41
  export function resolveGlobalUkitBin({ spawnSync = defaultSpawnSync } = {}) {
26
- const result = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
42
+ return resolveGlobalUkitBinDetailed({ spawnSync }).binPath;
43
+ }
44
+
45
+ // Detailed variant: also reports WHY resolution failed, so callers can surface
46
+ // a timeout as actionable ("npm query timed out — is the network/proxy wedged?")
47
+ // instead of misattributing it to a missing file.
48
+ function resolveGlobalUkitBinDetailed({ spawnSync = defaultSpawnSync } = {}) {
49
+ const result = spawnSync('npm', ['root', '-g'], {
50
+ encoding: 'utf8',
51
+ timeout: NPM_QUERY_TIMEOUT_MS,
52
+ killSignal: KILL_ON_TIMEOUT,
53
+ });
27
54
  if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
28
- return null;
55
+ return { binPath: null, timedOut: result.error?.code === 'ETIMEDOUT' };
29
56
  }
30
57
  const binPath = path.join(result.stdout.trim(), ...UKIT_PACKAGE_NAME.split('/'), 'bin', 'ukit');
31
- return fs.existsSync(binPath) ? binPath : null;
58
+ return { binPath: fs.existsSync(binPath) ? binPath : null, timedOut: false };
32
59
  }
33
60
 
34
61
  /**
@@ -45,14 +72,16 @@ export function runInstallAfterUpdate({
45
72
  return { ran: false, reason: 'not-a-ukit-project' };
46
73
  }
47
74
 
48
- const binPath = resolveGlobalUkitBin({ spawnSync });
75
+ const { binPath, timedOut } = resolveGlobalUkitBinDetailed({ spawnSync });
49
76
  if (!binPath) {
50
- return { ran: false, reason: 'global-bin-not-found' };
77
+ return { ran: false, reason: timedOut ? 'global-root-query-timeout' : 'global-bin-not-found' };
51
78
  }
52
79
 
53
80
  const result = spawnSync(execPath, [binPath, 'install'], {
54
81
  cwd: projectRoot,
55
82
  stdio: 'inherit',
83
+ timeout: NPM_INSTALL_TIMEOUT_MS,
84
+ killSignal: KILL_ON_TIMEOUT,
56
85
  });
57
86
  if (result.error) {
58
87
  return { ran: false, reason: `install-failed: ${result.error.message}` };
@@ -66,6 +95,8 @@ export function runInstallAfterUpdate({
66
95
  export function updateUkit({ spawnSync = defaultSpawnSync } = {}) {
67
96
  const result = spawnSync('npm', ['install', '-g', UKIT_PACKAGE_NAME], {
68
97
  stdio: 'inherit',
98
+ timeout: NPM_INSTALL_TIMEOUT_MS,
99
+ killSignal: KILL_ON_TIMEOUT,
69
100
  });
70
101
 
71
102
  if (result.error) {
@@ -49,8 +49,8 @@ PLAN.md → "# PLAN\n_(empty)_"
49
49
  SPEC.md → restore the untouched template (keep section headings, clear content)
50
50
  INDEX.md → empty table header only
51
51
  ACTIVE.md → "# ACTIVE\n_(no active cycle)_"
52
- RUN.md → set `Phase: done` (or delete) — a live non-done cursor makes the Stop gate
53
- refuse the next session's stops; clearing a cycle MUST close the cursor.
52
+ RUN.md → set `Phase: done` (or delete) — a live cursor that is neither `done` nor `blocked`
53
+ makes the Stop gate refuse the next session's stops; clearing a cycle MUST close the cursor.
54
54
  tasks/TASK-*.md → delete all (keep _TEMPLATE.md)
55
55
  ```
56
56
 
@@ -77,13 +77,16 @@ interrupted run into a continuable one.
77
77
 
78
78
  ### Resume — when the run is re-invoked mid-flight
79
79
 
80
- If `docs/AI_HANDOFF/RUN.md` exists with `Phase:` not `done`, this is a **continuation, not a
81
- new cycle**. Do NOT re-plan, do NOT overwrite `PLAN.md`, do NOT ask the human whether to
82
- continue. Read the cursor, then jump straight to `Next:` and carry on. Tasks already `done`
83
- or `pending_review` are skipped; only `ready`, `in_progress`, `changes_requested` and
84
- `blocked` tasks are picked up.
85
-
86
- Only when `RUN.md` is absent or `Phase: done` does a new request start a fresh cycle.
80
+ If `docs/AI_HANDOFF/RUN.md` exists with `Phase:` not `done` or `blocked`, this is a
81
+ **continuation, not a new cycle**. Do NOT re-plan, do NOT overwrite `PLAN.md`, do NOT ask
82
+ the human whether to continue. Read the cursor, then jump straight to `Next:` and carry on.
83
+ Tasks already `done` or `pending_review` are skipped; only `ready`, `in_progress`,
84
+ `changes_requested` and `blocked` tasks are picked up.
85
+
86
+ `Phase: blocked` is terminal for the run — the machinery (Stop gate, hooks, stop-coordinator)
87
+ all treats it as closed. It is *paused*, not abandoned: an explicit `/ukit:handoff-fullstack`
88
+ re-invocation resumes it as a continuation; `ukit handoff-clear` abandons it. Only when
89
+ `RUN.md` is absent or `Phase: done` does a new request start a fresh cycle.
87
90
 
88
91
  ---
89
92
 
@@ -11,7 +11,15 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
11
11
  source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
12
12
  trap ukit_cleanup_hook_input EXIT
13
13
  # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
14
- ukit_stage_hook_input 2097152 truncate || exit 0
14
+ ukit_stage_hook_input 2097152 truncate
15
+ __ukit_main_stage_rc=$?
16
+ if [ "$__ukit_main_stage_rc" -ne 0 ]; then
17
+ # Infra failure during staging (mktemp/truncate): payload was never
18
+ # inspected — announce the degrade (SPEC §8), never a silent pass.
19
+ ukit_emit_input_degraded advisory "auto-allow-bash"
20
+ fi
21
+ # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
22
+ ukit_input_degraded && ukit_emit_input_degraded advisory "auto-allow-bash"
15
23
  else
16
24
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
17
25
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -38,10 +46,14 @@ else
38
46
  wait "$__ukit_waiter" 2>/dev/null
39
47
  exec 8<&-
40
48
  __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
41
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
42
- # already degrades on - the shared helper's truncate posture (R4.5).
43
- if [ "$__ukit_size" -gt 2097152 ]; then
44
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
49
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
50
+ # BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
51
+ # must be announced, never silently acted on — same shape as the helper
52
+ # path's ukit_emit_input_degraded.
53
+ rm -f "$UKIT_INPUT_FILE"
54
+ UKIT_INPUT_FILE=""
55
+ printf '%s\n' '{"systemMessage":"UKit auto-allow-bash: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
56
+ exit 0
45
57
  fi
46
58
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
47
59
  fi
@@ -11,7 +11,15 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
11
11
  source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
12
12
  trap ukit_cleanup_hook_input EXIT
13
13
  # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
14
- ukit_stage_hook_input 2097152 truncate || exit 0
14
+ ukit_stage_hook_input 2097152 truncate
15
+ __ukit_main_stage_rc=$?
16
+ if [ "$__ukit_main_stage_rc" -ne 0 ]; then
17
+ # Infra failure during staging (mktemp/truncate): payload was never
18
+ # inspected — announce the degrade (SPEC §8), never a silent pass.
19
+ ukit_emit_input_degraded failclosed "dangerous-command gate"
20
+ fi
21
+ # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
22
+ ukit_input_degraded && ukit_emit_input_degraded failclosed "dangerous-command gate"
15
23
  else
16
24
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
17
25
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -38,10 +46,15 @@ else
38
46
  wait "$__ukit_waiter" 2>/dev/null
39
47
  exec 8<&-
40
48
  __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
41
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
42
- # already degrades on - the shared helper's truncate posture (R4.5).
43
- if [ "$__ukit_size" -gt 2097152 ]; then
44
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
49
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
50
+ # BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
51
+ # must be announced, never silently acted on — same shape as the helper
52
+ # path's ukit_emit_input_degraded.
53
+ rm -f "$UKIT_INPUT_FILE"
54
+ UKIT_INPUT_FILE=""
55
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so dangerous-command gate cannot prove it safe. UKit defers this to a human decision."}}'
56
+ echo "BLOCKED: dangerous-command gate could not inspect a truncated/stalled payload; deferred to human." >&2
57
+ exit 2
45
58
  fi
46
59
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
47
60
  fi
@@ -22,7 +22,15 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
22
22
  source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
23
23
  trap ukit_cleanup_hook_input EXIT
24
24
  # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
25
- ukit_stage_hook_input 2097152 truncate || exit 0
25
+ ukit_stage_hook_input 2097152 truncate
26
+ __ukit_main_stage_rc=$?
27
+ if [ "$__ukit_main_stage_rc" -ne 0 ]; then
28
+ # Infra failure during staging (mktemp/truncate): payload was never
29
+ # inspected — announce the degrade (SPEC §8), never a silent pass.
30
+ ukit_emit_input_degraded advisory "stop gate"
31
+ fi
32
+ # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
33
+ ukit_input_degraded && ukit_emit_input_degraded advisory "stop gate"
26
34
  else
27
35
  # wrapper's own fail-loud behavior below alive.
28
36
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
@@ -50,10 +58,14 @@ else
50
58
  wait "$__ukit_waiter" 2>/dev/null
51
59
  exec 8<&-
52
60
  __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
53
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
54
- # already degrades on - the shared helper's truncate posture (R4.5).
55
- if [ "$__ukit_size" -gt 2097152 ]; then
56
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
61
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
62
+ # BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
63
+ # must be announced, never silently acted on — same shape as the helper
64
+ # path's ukit_emit_input_degraded.
65
+ rm -f "$UKIT_INPUT_FILE"
66
+ UKIT_INPUT_FILE=""
67
+ printf '%s\n' '{"systemMessage":"UKit stop gate: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
68
+ exit 0
57
69
  fi
58
70
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
59
71
  fi
@@ -18,7 +18,15 @@ if [ -f "$SCRIPT_PATH" ]; then
18
18
  trap ukit_cleanup_hook_input EXIT
19
19
  # Bounded stdin (H01): compressed tool output can be large, so the staged
20
20
  # file is the transport and the shell var only mirrors the capped payload.
21
- ukit_stage_hook_input 33554432 temp-file || exit 0
21
+ ukit_stage_hook_input 33554432 temp-file
22
+ __ukit_main_stage_rc=$?
23
+ if [ "$__ukit_main_stage_rc" -ne 0 ]; then
24
+ # Infra failure during staging (mktemp/truncate): payload was never
25
+ # inspected — announce the degrade (SPEC §8), never a silent pass.
26
+ ukit_emit_input_degraded advisory "output compression"
27
+ fi
28
+ # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
29
+ ukit_input_degraded && ukit_emit_input_degraded advisory "output compression"
22
30
  else
23
31
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
24
32
  # inline — `cat >/dev/null` used to block forever on a producer that never
@@ -45,14 +53,15 @@ if [ -f "$SCRIPT_PATH" ]; then
45
53
  wait "$__ukit_waiter" 2>/dev/null
46
54
  exec 8<&-
47
55
  __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
48
- if [ "$__ukit_stage_rc" -ne 0 ]; then
49
- # Unstaged: the hook cannot inspect this payload.
56
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 33554432 ]; then
57
+ # Unstaged or oversized: the hook cannot inspect this payload — announce
58
+ # the degrade (SPEC §8) instead of silently compressing an empty or
59
+ # truncated input (SUS-02 / BUG-C21-01 fallback parity).
50
60
  rm -f "$UKIT_INPUT_FILE"
51
61
  UKIT_INPUT_FILE=""
62
+ printf '%s\n' '{"systemMessage":"UKit output compression: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
63
+ exit 0
52
64
  else
53
- if [ "$__ukit_size" -gt 33554432 ]; then
54
- truncate -s 33554432 "$UKIT_INPUT_FILE" 2>/dev/null
55
- fi
56
65
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
57
66
  fi
58
67
  fi