@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
|
@@ -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 "$
|
|
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 "$
|
|
38
|
-
if echo "$
|
|
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
|
-
|
|
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
|
-
|
|
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\"",
|
|
@@ -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': [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
}
|