@ngockhoale/ukit 2.2.3 → 2.2.5

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 CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.2.5 - 2026-08-27
6
+
7
+ Follow-up to the user's report that UKit had become too heavy-handed: too many hook scripts firing per tool call, too much blocking, and a concrete false-positive in the destructive-command gate itself. This wave trims default hook wiring and fixes the false positive, while explicitly preserving `rm`/destructive-command protection as instructed.
8
+
9
+ ### Changed
10
+
11
+ - **`PreToolUse:Bash` now runs 4 hook scripts instead of 6.** `verification-guard.sh` was removed from the default Bash chain (both `.claude/settings.json` copies, the omp bridge's `HOOK_EVENT_MAP`/`FAIL_CLOSED_SCRIPTS`, and `hook-chain-runner.mjs`'s own classification). Its hard-block path is gated behind `UKIT_VERIFICATION_GUARD_ENFORCE=1`, unset by default, so it was pure per-call overhead (a full `node` spawn) for zero present safety value. The script and its standalone tests are untouched — still shippable, opt-in.
12
+ - **`PreToolUse:Read|Grep|Glob` no longer runs `skill-router.sh`.** Automatic skill activation still runs on `UserPromptSubmit` and `Edit|Write`, where a skill choice actually matters; it no longer re-evaluates on every read-only tool call. Traded away: skill-router no longer sees "which files were Read/Grep'd" as mid-task evidence — only the original prompt and Edit/Write targets.
13
+
14
+ ### Fixed
15
+
16
+ - **`block-dangerous.sh` misidentified dangerous *text* as a dangerous *command*.** Both its per-pattern denylist and its `rm` handling matched against the entire raw command string with no awareness of shell quoting, so `grep -rn "rm -rf" .` or `echo "rm -rf /tmp/foo"` — which merely mention the text — were blocked as if they executed it. Quoted spans are now scrubbed before matching, but only when the command's own invoked program is a pure text tool (`grep|egrep|fgrep|rg|ag|awk|sed|echo|printf|cat`) and it does not pipe into a shell/eval sink (`| bash|sh|zsh|eval|xargs`), since `echo "rm -rf /" | bash` really does execute the quoted text. Every other command shape is scanned exactly as strictly as before.
17
+ - **`rm -rf ./dist` (and other allowlisted safe-cleanup targets with a `./` prefix) was blocked as an "unsafe delete target."** The unsafe-target check ran before the safe-cleanup allowlist check and its pattern misfired on the literal `./` prefix. Reordered so the allowlist is checked first; genuinely risky targets like `rm -rf ../important` still block.
18
+
19
+ ### Added
20
+
21
+ - **11 regression tests** in `tests/hooks/blockDangerous.test.js` covering the false-positive fix, the closed shell-pipe escape hatch, the `./dist` allowlist fix, and all pre-existing destructive-command blocks (`rm -rf /`, `rm -rf ~`, `rm -rf .`, `rm -rf ../important`, `git push --force`, `git reset --hard`).
22
+
23
+ ## 2.2.4 - 2026-08-27
24
+
25
+ Follow-up to 2.2.3's hook-chain collapse: a real omp session on a CloudMounter/FUSE-mounted project reported every tool call — including harmless `echo`, `pwd`, `ls`, and `Edit` — failing with `invalid hook-chain output`, and in one case `protect-files.sh hook chain was killed before safety gates completed`, despite `protect-files.sh` itself passing trivially. The bridge was misattributing any aggregate-runner transport failure to the first fail-closed script in the chain, whether or not that script ever ran.
26
+
27
+ ### Fixed
28
+
29
+ - **Outer transport failures no longer impersonate a specific safety gate.** `runScriptChain` in `ukit-bridge.js` treated a killed/timed-out `pi.exec`, empty stdout, or malformed JSON from `hook-chain-runner.mjs` as proof that the chain's first fail-closed script (e.g. `protect-files.sh`) had failed — without verifying that script ever ran. It now distinguishes "the runner returned a verifiable per-script result" from "the runner itself failed before producing one," and on the latter reports the real failure (killed/code/elapsedMs/runtime) instead of a fabricated gate name. Diagnostics are appended to `.ukit/storage/cache/hook-errors/<session>.jsonl`.
30
+ - **Bash and Read/Grep/Glob no longer fail closed on a bridge/runner outage.** Only `Edit`/`Write` chains still block when the runner can't produce a verdict, since those guard file mutation. Ordinary shell commands and read-only tools now fail open (with a logged warning), so a runner-level hiccup can no longer brick command execution or diagnosis tools.
31
+ - **`process.execPath` is no longer trusted blindly for the runner subprocess.** The bridge resolves `process.env.UKIT_NODE_PATH || process.execPath` and records the resolved runtime in the failure diagnostic, so a Bun-hosted omp launching the Node-only runner under the wrong binary is now visible instead of silently assumed.
32
+
33
+ ### Added
34
+
35
+ - **6 regression tests** in `tests/hooks/ompHookBridge.test.js` (`case 13`) covering a killed outer exec, malformed stdout, a non-zero exit with empty stdout before any script ran, fail-open behavior for Bash and Read chains under the same failures, and `UKIT_NODE_PATH` override.
36
+
5
37
  ## 2.2.3 - 2026-08-26
6
38
 
7
39
  Sessions were stalling mid-task — the model would read source, then simply stop, with the work half done and nothing wrong upstream. This wave stops treating that as a prompting problem. Instructions asking a model to "continue until the edit is made" are advisory by nature: every hidden backend behind `unic-lite` / `unic-code` / `unic-smart` / `unic-vision` reads them slightly differently, and the ones that read them loosely stall. UKit now records what actually happened — real Edit/Write receipts, real verification exit codes — and enforces completion mechanically at the point of stopping, identically on Claude Code and omp. Nothing in the enforcement path branches on provider branding.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.2.3",
3
+ "version": "2.2.5",
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",
@@ -9,6 +9,22 @@ if [ -z "$COMMAND" ]; then
9
9
  exit 0
10
10
  fi
11
11
 
12
+ # A command like `grep "rm -rf" file.sh` or `echo "rm -rf /"` merely mentions the
13
+ # text of a dangerous pattern as data — it does not execute it. Naively substring-matching
14
+ # the raw command line false-positives on any search/print of that text. Scrub quoted
15
+ # spans before pattern-matching, but ONLY when the invoked command is a pure text tool
16
+ # AND its output cannot reach a shell (no piping into bash/sh/zsh/eval/xargs), since
17
+ # `echo "rm -rf /" | bash` really does execute the quoted text.
18
+ SAFE_TEXT_CMD_REGEX='^(grep|egrep|fgrep|rg|ag|awk|sed|echo|printf|cat)$'
19
+ PIPE_TO_SHELL_REGEX='\|[[:space:]]*(bash|sh|zsh|eval|xargs)([[:space:]]|$)'
20
+
21
+ CMD_HEAD=$(echo "$COMMAND" | sed -E 's/^[[:space:]]*//' | awk '{print $1}' | xargs -I{} basename {} 2>/dev/null)
22
+
23
+ SCAN_COMMAND="$COMMAND"
24
+ if echo "$CMD_HEAD" | grep -qE "$SAFE_TEXT_CMD_REGEX" && ! echo "$COMMAND" | grep -qE "$PIPE_TO_SHELL_REGEX"; then
25
+ SCAN_COMMAND=$(printf '%s' "$COMMAND" | sed -E "s/'[^']*'/'Q'/g; s/\"[^\"]*\"/\"Q\"/g")
26
+ fi
27
+
12
28
  # Dangerous patterns to block
13
29
  DANGEROUS_PATTERNS=(
14
30
  "rm -rf /"
@@ -27,24 +43,26 @@ DANGEROUS_PATTERNS=(
27
43
  )
28
44
 
29
45
  for pattern in "${DANGEROUS_PATTERNS[@]}"; do
30
- if echo "$COMMAND" | grep -qE "$pattern"; then
46
+ if echo "$SCAN_COMMAND" | grep -qE "$pattern"; then
31
47
  echo "BLOCKED: Dangerous command detected matching pattern '$pattern'. This command could cause irreversible damage. Ask the user to run it manually if truly needed." >&2
32
48
  exit 2
33
49
  fi
34
50
  done
35
51
 
36
52
  # Handle rm commands: allow safe cleanup targets, block risky recursive force-deletes
37
- if echo "$COMMAND" | grep -qE "(^|[;&|[:space:]])rm([[:space:]]|$)"; then
38
- if echo "$COMMAND" | grep -qE "rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|--recursive\s+--force|--force\s+--recursive|--recursive\s+-f|-f\s+--recursive)"; then
53
+ if echo "$SCAN_COMMAND" | grep -qE "(^|[;&|[:space:]])rm([[:space:]]|$)"; then
54
+ if echo "$SCAN_COMMAND" | grep -qE "rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|--recursive\s+--force|--force\s+--recursive|--recursive\s+-f|-f\s+--recursive)"; then
39
55
  SAFE_DELETE_REGEX='(^|[[:space:]])(\./)?(dist|build|coverage|\.next|\.nuxt|\.turbo|tmp|temp|\.cache|node_modules)(/|[[:space:]]|$)'
40
56
  UNSAFE_TARGET_REGEX='(^|[[:space:]])(/|~|\.\.?($|/)|\.($|/))'
41
57
 
42
- if echo "$COMMAND" | grep -qE "$UNSAFE_TARGET_REGEX"; then
58
+ # Check the allowlist first: `./dist` legitimately contains a leading "./" that would
59
+ # otherwise be mistaken for a bare current-directory target by UNSAFE_TARGET_REGEX below.
60
+ if echo "$SCAN_COMMAND" | grep -qE "$SAFE_DELETE_REGEX"; then
61
+ :
62
+ elif echo "$SCAN_COMMAND" | grep -qE "$UNSAFE_TARGET_REGEX"; then
43
63
  echo "BLOCKED: Unsafe delete target detected. Refusing recursive force-delete." >&2
44
64
  exit 2
45
- fi
46
-
47
- if ! echo "$COMMAND" | grep -qE "$SAFE_DELETE_REGEX"; then
65
+ else
48
66
  echo "BLOCKED: 'rm -rf' is only auto-allowed for safe cleanup targets (dist/build/coverage/.next/.nuxt/.turbo/tmp/.cache/node_modules)." >&2
49
67
  exit 2
50
68
  fi
@@ -54,13 +54,7 @@
54
54
  "PreToolUse": [
55
55
  {
56
56
  "matcher": "Read|Grep|Glob",
57
- "hooks": [
58
- {
59
- "type": "command",
60
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
61
- "timeout": 8
62
- }
63
- ]
57
+ "hooks": []
64
58
  },
65
59
  {
66
60
  "matcher": "Edit|Write",
@@ -105,21 +99,11 @@
105
99
  {
106
100
  "matcher": "Bash",
107
101
  "hooks": [
108
- {
109
- "type": "command",
110
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\"",
111
- "timeout": 8
112
- },
113
102
  {
114
103
  "type": "command",
115
104
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\"",
116
105
  "timeout": 8
117
106
  },
118
- {
119
- "type": "command",
120
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
121
- "timeout": 8
122
- },
123
107
  {
124
108
  "type": "command",
125
109
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh\"",
@@ -11,7 +11,6 @@ const FAIL_CLOSED_SCRIPTS = new Set([
11
11
  'vision-gate.sh',
12
12
  'context-hardcap-gate.sh',
13
13
  'block-dangerous.sh',
14
- 'verification-guard.sh',
15
14
  ]);
16
15
 
17
16
  const TOTAL_BUDGET_MS = 10000;
@@ -5,6 +5,7 @@
5
5
  // payloads, executes the same ordered script chains, and translates only the
6
6
  // result fields that omp consumes.
7
7
 
8
+ import fs from 'node:fs';
8
9
  import path from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import {
@@ -17,7 +18,7 @@ import {
17
18
 
18
19
  export const HOOK_EVENT_MAP = {
19
20
  tool_call: {
20
- 'Read|Grep|Glob': ['skill-router.sh'],
21
+ 'Read|Grep|Glob': [],
21
22
  'Edit|Write': [
22
23
  'protect-files.sh',
23
24
  'stale-spec-guard.sh',
@@ -28,9 +29,7 @@ export const HOOK_EVENT_MAP = {
28
29
  'context-hardcap-gate.sh',
29
30
  ],
30
31
  Bash: [
31
- 'verification-guard.sh',
32
32
  'auto-allow-bash.sh',
33
- 'skill-router.sh',
34
33
  'block-dangerous.sh',
35
34
  'handoff-model-guard.sh',
36
35
  'context-hardcap-gate.sh',
@@ -85,7 +84,6 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
85
84
  'vision-gate.sh',
86
85
  'context-hardcap-gate.sh',
87
86
  'block-dangerous.sh',
88
- 'verification-guard.sh',
89
87
  ]);
90
88
 
91
89
  export const ADVISORY_SCRIPTS = new Set([
@@ -172,7 +170,23 @@ export { translateExecResult };
172
170
 
173
171
  const HOOK_CHAIN_TIMEOUT_MS = 12000;
174
172
 
175
- export async function runScriptChain(pi, scripts, payload, { projectRoot }) {
173
+ function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic) {
174
+ try {
175
+ const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-errors');
176
+ fs.mkdirSync(dir, { recursive: true });
177
+ const safeSession = String(sessionId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
178
+ fs.appendFileSync(path.join(dir, `${safeSession}.jsonl`), `${JSON.stringify(diagnostic)}\n`, 'utf8');
179
+ } catch {
180
+ // Diagnostics are advisory and must never block or throw.
181
+ }
182
+ }
183
+
184
+ export async function runScriptChain(
185
+ pi,
186
+ scripts,
187
+ payload,
188
+ { projectRoot, failClosedOnTransportError = false },
189
+ ) {
176
190
  const invoked = [];
177
191
  const context = [];
178
192
  if (scripts.length === 0) {
@@ -181,50 +195,64 @@ export async function runScriptChain(pi, scripts, payload, { projectRoot }) {
181
195
 
182
196
  const runnerPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'hook-chain-runner.mjs');
183
197
  const scriptPaths = scripts.map((scriptName) => path.join(projectRoot, '.claude', 'hooks', scriptName));
198
+ // Do not blindly trust process.execPath: under a Bun-hosted omp, it points at bun, not node.
199
+ const nodeExecutable = process.env.UKIT_NODE_PATH || process.execPath;
200
+ const startedAt = Date.now();
184
201
  let execResult;
185
202
  try {
186
203
  execResult = await pi.exec(
187
- process.execPath,
204
+ nodeExecutable,
188
205
  [runnerPath, JSON.stringify(payload), ...scriptPaths],
189
206
  { cwd: projectRoot, timeout: HOOK_CHAIN_TIMEOUT_MS },
190
207
  );
191
208
  } catch (error) {
192
209
  execResult = { code: 1, stdout: '', stderr: error?.message ?? String(error), killed: false };
193
210
  }
211
+ const elapsedMs = Date.now() - startedAt;
194
212
 
195
- if (execResult?.killed) {
196
- const firstGate = scripts.find((scriptName) => FAIL_CLOSED_SCRIPTS.has(scriptName));
197
- return firstGate
198
- ? {
199
- block: true,
200
- reason: `${firstGate} hook chain was killed before safety gates completed`,
201
- context,
202
- invoked,
203
- }
204
- : { block: false, context, invoked };
205
- }
206
-
207
- let chainResult;
208
- try {
209
- chainResult = JSON.parse(execResult?.stdout || '{}');
210
- } catch {
211
- chainResult = { results: [], wrapperError: execResult?.stderr || 'invalid hook-chain output' };
213
+ let chainResult = null;
214
+ let parseError = null;
215
+ if (!execResult?.killed) {
216
+ try {
217
+ chainResult = JSON.parse(execResult?.stdout || '{}');
218
+ } catch (error) {
219
+ parseError = error;
220
+ }
212
221
  }
213
222
 
214
- if (chainResult.wrapperError || execResult?.code) {
215
- const firstGate = scripts.find((scriptName) => FAIL_CLOSED_SCRIPTS.has(scriptName));
216
- if (firstGate) {
217
- return {
218
- block: true,
219
- reason: chainResult.wrapperError || execResult?.stderr || `${firstGate} hook chain failed`,
220
- context,
221
- invoked,
222
- };
223
+ const hasUsableResults = Boolean(chainResult) && Array.isArray(chainResult.results) && chainResult.results.length > 0;
224
+ const transportFailed = Boolean(execResult?.killed) || Boolean(parseError) || Boolean(chainResult?.wrapperError) || !hasUsableResults;
225
+
226
+ if (transportFailed) {
227
+ // The aggregate runner produced no verifiable per-script verdict. Never relabel this as a
228
+ // specific fail-closed script's decision -- that script may never have run.
229
+ const diagnostic = {
230
+ ts: Date.now(),
231
+ scripts,
232
+ killed: Boolean(execResult?.killed),
233
+ code: execResult?.code ?? null,
234
+ stdoutLength: (execResult?.stdout || '').length,
235
+ stderr: execResult?.stderr || '',
236
+ elapsedMs,
237
+ nodeExecutable,
238
+ nodeVersion: process.version,
239
+ runnerPath,
240
+ parseError: parseError?.message || null,
241
+ wrapperError: chainResult?.wrapperError || null,
242
+ };
243
+ recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
244
+ const reason = `UKit OMP hook runner failed before producing a valid result `
245
+ + `(killed=${diagnostic.killed}, code=${diagnostic.code}, elapsedMs=${diagnostic.elapsedMs}, `
246
+ + `runtime=${diagnostic.nodeExecutable}). No safety-gate verdict was available for [${scripts.join(', ')}]. `
247
+ + `See .ukit/storage/cache/hook-errors/.`;
248
+ if (failClosedOnTransportError) {
249
+ return { block: true, reason, context, invoked };
223
250
  }
224
- pi.logger?.warn?.(`[UKit] hook chain failed open: ${chainResult.wrapperError || execResult?.stderr || 'unknown error'}`);
251
+ pi.logger?.warn?.(`[UKit] ${reason}`);
252
+ return { block: false, context, invoked };
225
253
  }
226
254
 
227
- for (const item of chainResult.results ?? []) {
255
+ for (const item of chainResult.results) {
228
256
  const scriptName = item?.scriptName;
229
257
  if (!scriptName || !scripts.includes(scriptName)) continue;
230
258
  invoked.push(scriptName);
@@ -314,7 +342,13 @@ export async function runToolCall(pi, event, { projectRoot, context: extensionCo
314
342
  toolUseId: event.toolCallId,
315
343
  ...metadata,
316
344
  });
317
- const result = await runScriptChain(pi, scriptsForToolCall(toolName), payload, { projectRoot });
345
+ // Only Edit|Write stays fail-closed on a bridge/runner transport failure; Bash and
346
+ // Read|Grep|Glob fail open so diagnosis/recovery tools are never bricked by a runner outage.
347
+ const failClosedOnTransportError = matcherGroupFor(toolName) === 'Edit|Write';
348
+ const result = await runScriptChain(pi, scriptsForToolCall(toolName), payload, {
349
+ projectRoot,
350
+ failClosedOnTransportError,
351
+ });
318
352
  if (!result.block) sendContext(pi, result.context, 'steer');
319
353
  return { ...result, toolName };
320
354
  }