@ngockhoale/ukit 2.7.3 → 2.7.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 +66 -0
- package/manifests/platform.full.yaml +43 -0
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +14 -0
- package/src/core/hookChainDoctor.js +149 -0
- package/templates/.claude/hooks/block-dangerous.mjs +377 -0
- package/templates/.claude/hooks/block-dangerous.sh +24 -223
- package/templates/.claude/hooks/record-execution.mjs +141 -0
- package/templates/.claude/hooks/record-execution.sh +14 -9
- package/templates/.claude/hooks/sensitive-data-guard.mjs +513 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +1 -353
- package/templates/.claude/hooks/session-episode.sh +26 -1
- package/templates/.claude/settings.json +6 -6
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +17 -1
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +134 -26
- package/templates/.claude/ukit/runtime/hook-field-salvage.mjs +119 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +20 -12
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// block-dangerous.mjs — C40 TASK-003 port of block-dangerous.sh into a
|
|
2
|
+
// dual-mode module (SPEC §FR-006, §8):
|
|
3
|
+
//
|
|
4
|
+
// * `runHook(ctx)` — in-proc chain step. ctx = { payload, payloadText,
|
|
5
|
+
// projectRoot, env, deadlineMs, signal } -> { code, stdout, stderr }.
|
|
6
|
+
// Byte-identical verdicts to the .sh: dangerous pattern / unsafe rm verdict
|
|
7
|
+
// -> identical permissionDecision JSON + `BLOCKED: <reason>` stderr; safe
|
|
8
|
+
// command -> code 0 empty. Under the chain marker (UKIT_HOOK_CHAIN_RUNNER)
|
|
9
|
+
// decisions are `ask` + code 2; on the direct host they are `deny` + code 0
|
|
10
|
+
// (bypassPermissions auto-approves `ask` — TASK-223/probe verdict).
|
|
11
|
+
// * `isDirectRun()` CLI mode — the .sh thin wrapper execs
|
|
12
|
+
// `node block-dangerous.mjs` with the exact env contract the bash glue
|
|
13
|
+
// produced (INPUT_FILE PROJECT_ROOT UKIT_HOOK_DEADLINE_MS
|
|
14
|
+
// UKIT_SALVAGE_ACTIVE UKIT_SALVAGED_COMMAND).
|
|
15
|
+
//
|
|
16
|
+
// FAIL-CLOSED gate. The raw command and matched pattern are NEVER echoed —
|
|
17
|
+
// arguments and comments can carry secrets, and the pattern text itself
|
|
18
|
+
// restates the dangerous command. Truncated/degraded input salvages only a
|
|
19
|
+
// provably-complete tool_input.command via ukit/runtime/hook-field-salvage.mjs;
|
|
20
|
+
// anything else refuses `deny` — a partial command is never evaluated.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import fsp from 'node:fs/promises';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { salvageField } from '../ukit/runtime/hook-field-salvage.mjs';
|
|
27
|
+
|
|
28
|
+
export const hookFailClosed = true;
|
|
29
|
+
|
|
30
|
+
// POSIX [[:space:]] exactly (grep -E semantics; JS \s also matches unicode).
|
|
31
|
+
const SP = '[\\t\\n\\v\\f\\r ]';
|
|
32
|
+
|
|
33
|
+
// --- byte-pinned verdicts ----------------------------------------------------
|
|
34
|
+
const REASON_DANGEROUS =
|
|
35
|
+
'Dangerous command pattern detected; it could cause irreversible damage. UKit defers this to a human decision.';
|
|
36
|
+
const REASON_UNSAFE_TARGET =
|
|
37
|
+
'Unsafe delete target detected; recursive force-delete could cause irreversible damage. UKit defers this to a human decision.';
|
|
38
|
+
const REASON_GENERIC_RM =
|
|
39
|
+
'Recursive force-delete outside safe cleanup targets (dist/build/coverage/.next/.nuxt/.turbo/tmp/.cache/node_modules) is destructive. UKit defers this to a human decision.';
|
|
40
|
+
const REASON_SALVAGE_REFUSAL =
|
|
41
|
+
'UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound) and the command could not be recovered, so dangerous-command gate cannot prove it safe.';
|
|
42
|
+
|
|
43
|
+
const TIMEOUT_REFUSAL = {
|
|
44
|
+
code: 2,
|
|
45
|
+
stdout:
|
|
46
|
+
'{"systemMessage":"UKit dangerous-command gate timed out before finishing its scan; blocked fail-closed rather than pass an uninspected payload."}\n',
|
|
47
|
+
stderr:
|
|
48
|
+
'BLOCKED: dangerous-command gate timed out; blocked fail-closed rather than pass an uninspected payload.\n',
|
|
49
|
+
};
|
|
50
|
+
const CRASH_REFUSAL = {
|
|
51
|
+
code: 2,
|
|
52
|
+
stdout:
|
|
53
|
+
'{"systemMessage":"UKit dangerous-command gate crashed before finishing its scan; blocked fail-closed rather than pass an uninspected payload. Run `ukit install` if this repeats."}\n',
|
|
54
|
+
stderr: 'BLOCKED: dangerous-command gate crashed; payload never inspected (fail-closed).\n',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ukit_emit_permission_decision parity (hook-input.sh): the JSON shape is
|
|
58
|
+
// harness-agnostic, the exit code is not — direct host exits 0 (non-zero
|
|
59
|
+
// discards stdout), chain exits 2 (bridge parses stdout only on code 2).
|
|
60
|
+
// stderr always carries the BLOCKED line.
|
|
61
|
+
function emitDecision(decision, reason, env) {
|
|
62
|
+
const inChain = Boolean(env.UKIT_HOOK_CHAIN_RUNNER);
|
|
63
|
+
return {
|
|
64
|
+
code: inChain ? 2 : 0,
|
|
65
|
+
stdout:
|
|
66
|
+
`{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"${decision}",` +
|
|
67
|
+
`"permissionDecisionReason":"${reason}"}}\n`,
|
|
68
|
+
stderr: `BLOCKED: ${reason}\n`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const emitDangerous = (reason, env) =>
|
|
72
|
+
emitDecision(env.UKIT_HOOK_CHAIN_RUNNER ? 'ask' : 'deny', reason, env);
|
|
73
|
+
const emitRefusal = (env) => emitDecision('deny', REASON_SALVAGE_REFUSAL, env);
|
|
74
|
+
|
|
75
|
+
// --- command extraction ------------------------------------------------------
|
|
76
|
+
// jq/node heredoc parity: tool_input.command string, else ''.
|
|
77
|
+
function extractCommand({ rawInput, env }) {
|
|
78
|
+
if (env.UKIT_SALVAGE_ACTIVE === '1') {
|
|
79
|
+
// TASK-003 degraded path: the wrapper already recovered a COMPLETE command —
|
|
80
|
+
// skip normal extraction (the staged prefix would not parse anyway).
|
|
81
|
+
return { command: env.UKIT_SALVAGED_COMMAND ?? '' };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const payload = JSON.parse(rawInput || '{}');
|
|
85
|
+
const command = payload?.tool_input?.command;
|
|
86
|
+
return { command: typeof command === 'string' ? command : '' };
|
|
87
|
+
} catch {
|
|
88
|
+
return { unparseable: true };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- verbatim ports of the .sh scan semantics ---------------------------------
|
|
93
|
+
// CMD_HEAD=$(echo "$COMMAND" | sed -E 's/^[[:space:]]*//' | awk '{print $1}' | basename)
|
|
94
|
+
const SAFE_TEXT_HEADS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'awk', 'sed', 'echo', 'printf', 'cat']);
|
|
95
|
+
const PIPE_TO_SHELL_RE = new RegExp(`\\|${SP}*(bash|sh|zsh|eval|xargs)(${SP}|$)`, 'm');
|
|
96
|
+
|
|
97
|
+
// DANGEROUS_PATTERNS — grep -E semantics preserved. Verified against BSD/GNU
|
|
98
|
+
// grep: the fork-bomb entry's `{` is an invalid interval, so it (and every
|
|
99
|
+
// other entry) is a literal substring except the three that carry real ERE.
|
|
100
|
+
const DANGEROUS_LITERALS = [
|
|
101
|
+
'rm -rf /',
|
|
102
|
+
'rm -rf ~',
|
|
103
|
+
'git push --force',
|
|
104
|
+
'git push -f ',
|
|
105
|
+
'git reset --hard',
|
|
106
|
+
'git clean -fd',
|
|
107
|
+
'> /dev/sda',
|
|
108
|
+
'mkfs.',
|
|
109
|
+
':(){ :|:& };:',
|
|
110
|
+
'dd if=/dev/',
|
|
111
|
+
];
|
|
112
|
+
const DANGEROUS_REGEXES = [
|
|
113
|
+
// "rm -rf \.?\.([[:space:]]|$)" — rm -rf . / rm -rf .. (+ trailing ws/EOL)
|
|
114
|
+
new RegExp(`rm -rf \\.?\\.(${SP}|$)`, 'm'),
|
|
115
|
+
/git checkout \./,
|
|
116
|
+
/git restore \./,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// rm segment analysis — byte-identical port of the .sh per-segment allowlist.
|
|
120
|
+
const SAFE_NAMES = '(?:dist|build|coverage|\\.next|\\.nuxt|\\.turbo|tmp|temp|\\.cache|node_modules)';
|
|
121
|
+
const SAFE_ONE_TARGET_RE = new RegExp(`^(?:\\./)?${SAFE_NAMES}(?:/.*)?$`);
|
|
122
|
+
const UNSAFE_ONE_TARGET_RE = /^(\/.*|~|~\/.*|\.\.|\.\.\/.*|\.)$/;
|
|
123
|
+
const RM_WORD_RE = new RegExp(`(^|[;&|${SP.slice(1, -1)}"'])rm([${SP.slice(1, -1)}"']|$)`, 'm');
|
|
124
|
+
const UNSAFE_TARGET_RE = new RegExp(`(^|${SP})(\\/|~|\\.\\.?($|\\/)|\\.($|\\/))`, 'm');
|
|
125
|
+
const SAFE_DELETE_RE = new RegExp(`(^|${SP})(\\./)?${SAFE_NAMES}(\\/|${SP}|$)`, 'm');
|
|
126
|
+
const HAS_RECURSIVE_RE = new RegExp(`(^|${SP})--recursive|(^|${SP})-[a-zA-Z]*[rR]`, 'm');
|
|
127
|
+
const HAS_FORCE_RE = new RegExp(`(^|${SP})--force|(^|${SP})-[a-zA-Z]*f`, 'm');
|
|
128
|
+
|
|
129
|
+
const basename = (token) => token.split('/').pop();
|
|
130
|
+
|
|
131
|
+
// ukit_canonical_path parity: realpath -m semantics — resolve the deepest
|
|
132
|
+
// existing ancestor, append the lexical remainder; unresolvable -> '' (the
|
|
133
|
+
// caller fails closed, never silently allows). UKIT_REALPATH_MISSING=1 (set by
|
|
134
|
+
// the wrapper when no system realpath exists) forces the same fail-closed —
|
|
135
|
+
// the pre-port gate could not prove containment without realpath.
|
|
136
|
+
function canonicalPath(p, env) {
|
|
137
|
+
if (env && env.UKIT_REALPATH_MISSING === '1') return '';
|
|
138
|
+
try {
|
|
139
|
+
const out = fs.realpathSync.native(p);
|
|
140
|
+
if (out) return out;
|
|
141
|
+
} catch {}
|
|
142
|
+
let rest = '';
|
|
143
|
+
let cur = p;
|
|
144
|
+
const existsOrLink = (q) => {
|
|
145
|
+
try {
|
|
146
|
+
fs.lstatSync(q);
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
while (cur && cur !== '/' && !existsOrLink(cur)) {
|
|
153
|
+
rest = `/${path.basename(cur)}${rest}`;
|
|
154
|
+
const parent = cur.slice(0, cur.lastIndexOf('/')) || (cur.startsWith('/') ? '/' : cur);
|
|
155
|
+
if (parent === cur) break;
|
|
156
|
+
cur = parent;
|
|
157
|
+
}
|
|
158
|
+
if (!cur) cur = '/';
|
|
159
|
+
try {
|
|
160
|
+
const out = fs.realpathSync.native(cur);
|
|
161
|
+
return `${out}${rest}`;
|
|
162
|
+
} catch {
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function evaluate({ command, projectRoot, env }) {
|
|
168
|
+
if (!command) return { code: 0, stdout: '', stderr: '' };
|
|
169
|
+
|
|
170
|
+
// Quoted-span scrub: only when the invoked command is a pure text tool AND
|
|
171
|
+
// its output cannot reach a shell — `echo "rm -rf /" | bash` executes the
|
|
172
|
+
// quoted text, so it must never be scrubbed.
|
|
173
|
+
const head = (command.trimStart().split(/\s+/)[0] || '');
|
|
174
|
+
const cmdHead = basename(head);
|
|
175
|
+
let scan = command;
|
|
176
|
+
if (SAFE_TEXT_HEADS.has(cmdHead) && !PIPE_TO_SHELL_RE.test(command)) {
|
|
177
|
+
scan = command.replace(/'[^']*'/g, "'Q'").replace(/"[^"]*"/g, '"Q"');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const literal of DANGEROUS_LITERALS) {
|
|
181
|
+
if (scan.includes(literal)) return emitDangerous(REASON_DANGEROUS, env);
|
|
182
|
+
}
|
|
183
|
+
for (const re of DANGEROUS_REGEXES) {
|
|
184
|
+
if (re.test(scan)) return emitDangerous(REASON_DANGEROUS, env);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// rm handling: allow safe cleanup targets only; everything else defers.
|
|
188
|
+
const projectRootCanon = canonicalPath(projectRoot, env);
|
|
189
|
+
|
|
190
|
+
let verdictUnsafe = false;
|
|
191
|
+
let verdictGeneric = false;
|
|
192
|
+
let directSeen = false;
|
|
193
|
+
|
|
194
|
+
for (const rawSegment of scan.split(/[;&|\n]/)) {
|
|
195
|
+
const segment = rawSegment;
|
|
196
|
+
if (!segment) continue;
|
|
197
|
+
const tokens = segment.trimStart().split(/\s+/).filter(Boolean);
|
|
198
|
+
if (basename(tokens[0] || '') !== 'rm') continue;
|
|
199
|
+
|
|
200
|
+
// flags: leading -x tokens until `--` or the first non-flag word.
|
|
201
|
+
let hasR = false;
|
|
202
|
+
let hasF = false;
|
|
203
|
+
let i = 1;
|
|
204
|
+
for (; i < tokens.length; i += 1) {
|
|
205
|
+
const t = tokens[i];
|
|
206
|
+
if (t === '--') break;
|
|
207
|
+
if (!t.startsWith('-')) break;
|
|
208
|
+
if (t === '--recursive') hasR = true;
|
|
209
|
+
else if (t === '--force') hasF = true;
|
|
210
|
+
else if (t.startsWith('--')) { /* other long flag: ignore */ }
|
|
211
|
+
else {
|
|
212
|
+
if (/[rR]/.test(t)) hasR = true;
|
|
213
|
+
if (t.includes('f')) hasF = true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!(hasR && hasF)) continue;
|
|
217
|
+
directSeen = true;
|
|
218
|
+
|
|
219
|
+
// targets: the first non-flag word and every later non-dash word.
|
|
220
|
+
const targets = [];
|
|
221
|
+
{
|
|
222
|
+
let saw = false;
|
|
223
|
+
for (let j = 1; j < tokens.length; j += 1) {
|
|
224
|
+
const t = tokens[j];
|
|
225
|
+
if (!saw) {
|
|
226
|
+
if (t === '--') {
|
|
227
|
+
saw = true;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (t.startsWith('-')) continue;
|
|
231
|
+
saw = true;
|
|
232
|
+
targets.push(t);
|
|
233
|
+
} else if (!t.startsWith('-')) {
|
|
234
|
+
targets.push(t);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let allSafe = true;
|
|
240
|
+
let anyUnsafe = false;
|
|
241
|
+
for (let target of targets) {
|
|
242
|
+
// Strip ONE trailing slash (never shrink a bare `/` or `~` to '').
|
|
243
|
+
if (target.length > 1 && target.endsWith('/')) target = target.slice(0, -1);
|
|
244
|
+
if (UNSAFE_ONE_TARGET_RE.test(target)) {
|
|
245
|
+
anyUnsafe = true;
|
|
246
|
+
allSafe = false;
|
|
247
|
+
} else if (SAFE_ONE_TARGET_RE.test(target)) {
|
|
248
|
+
// TASK-013 containment gate: the allowlisted NAME is not enough — the
|
|
249
|
+
// canonical target must live inside the canonical project root.
|
|
250
|
+
if (target.includes('..')) {
|
|
251
|
+
allSafe = false;
|
|
252
|
+
} else {
|
|
253
|
+
const abs = target.startsWith('/') ? target : `${projectRootCanon}/${target}`;
|
|
254
|
+
const canon = canonicalPath(abs, env);
|
|
255
|
+
if (!projectRootCanon || !canon || !canon.startsWith(`${projectRootCanon}/`)) {
|
|
256
|
+
allSafe = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
allSafe = false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (anyUnsafe) verdictUnsafe = true;
|
|
264
|
+
else if (!allSafe) verdictGeneric = true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!directSeen && RM_WORD_RE.test(scan) && HAS_RECURSIVE_RE.test(scan) && HAS_FORCE_RE.test(scan)) {
|
|
268
|
+
// Whole-command fallback (wrapped/quoted rm, flags after targets): unsafe
|
|
269
|
+
// wins over the allowlist — a safe word elsewhere never legitimises an
|
|
270
|
+
// absolute/home/parent target we can also see.
|
|
271
|
+
if (UNSAFE_TARGET_RE.test(scan)) verdictUnsafe = true;
|
|
272
|
+
else if (!SAFE_DELETE_RE.test(scan)) verdictGeneric = true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (verdictUnsafe) return emitDangerous(REASON_UNSAFE_TARGET, env);
|
|
276
|
+
if (verdictGeneric) return emitDangerous(REASON_GENERIC_RM, env);
|
|
277
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- SPEC §8 chain-step entry --------------------------------------------------
|
|
281
|
+
export async function runHook(ctx) {
|
|
282
|
+
const env = (ctx && ctx.env) || {};
|
|
283
|
+
const projectRoot =
|
|
284
|
+
env.CLAUDE_PROJECT_DIR || (ctx && ctx.projectRoot) || env.PROJECT_ROOT || process.cwd();
|
|
285
|
+
const deadlineMs =
|
|
286
|
+
Number.parseInt((ctx && ctx.deadlineMs) ?? env.UKIT_HOOK_DEADLINE_MS ?? '', 10) || 3000;
|
|
287
|
+
|
|
288
|
+
const rawInput =
|
|
289
|
+
typeof (ctx && ctx.payloadText) === 'string' && ctx.payloadText.length > 0
|
|
290
|
+
? ctx.payloadText
|
|
291
|
+
: (() => {
|
|
292
|
+
try {
|
|
293
|
+
return JSON.stringify((ctx && ctx.payload) ?? {});
|
|
294
|
+
} catch {
|
|
295
|
+
return '';
|
|
296
|
+
}
|
|
297
|
+
})();
|
|
298
|
+
|
|
299
|
+
let extracted = extractCommand({ rawInput, env });
|
|
300
|
+
if (extracted.unparseable) {
|
|
301
|
+
// The .sh only salvages on a staging-degraded flag; in-proc there is no flag
|
|
302
|
+
// — an unparseable payloadText IS the degradation. `{"…`-prefixed text that
|
|
303
|
+
// fails to parse is a truncated payload: salvage tool_input.command only
|
|
304
|
+
// when provably complete, else refuse deny (a partial command is never
|
|
305
|
+
// evaluated). Non-JSON garbage is nothing to scan -> pass, same as jq
|
|
306
|
+
// failing on the .sh path.
|
|
307
|
+
if (rawInput.trimStart().startsWith('{')) {
|
|
308
|
+
const salvaged = salvageField(rawInput, 'tool_input.command');
|
|
309
|
+
if (!salvaged.complete || salvaged.value === null) return emitRefusal(env);
|
|
310
|
+
extracted = { command: salvaged.value };
|
|
311
|
+
} else {
|
|
312
|
+
extracted = { command: '' };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let timer;
|
|
317
|
+
const timeout = new Promise((resolve) => {
|
|
318
|
+
timer = setTimeout(() => resolve(TIMEOUT_REFUSAL), Math.max(1, deadlineMs));
|
|
319
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
320
|
+
});
|
|
321
|
+
try {
|
|
322
|
+
const run = (async () => {
|
|
323
|
+
if (ctx && ctx.signal && ctx.signal.aborted) return TIMEOUT_REFUSAL;
|
|
324
|
+
return evaluate({ command: extracted.command, projectRoot, env });
|
|
325
|
+
})();
|
|
326
|
+
return await Promise.race([run, timeout]);
|
|
327
|
+
} catch {
|
|
328
|
+
return CRASH_REFUSAL;
|
|
329
|
+
} finally {
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- direct-run CLI mode (the .sh thin wrapper's target) -----------------------
|
|
335
|
+
export function isDirectRun() {
|
|
336
|
+
try {
|
|
337
|
+
const invoked = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
|
|
338
|
+
const self = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
339
|
+
return invoked === self;
|
|
340
|
+
} catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function main() {
|
|
346
|
+
const env = process.env;
|
|
347
|
+
const HOOK_DEADLINE_MS = Number.parseInt(env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
348
|
+
// .sh parity: the deadline bounded the extraction node — expiry produced an
|
|
349
|
+
// empty command and a pass (exit 0, no output).
|
|
350
|
+
const timer = setTimeout(() => process.exit(0), HOOK_DEADLINE_MS);
|
|
351
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
352
|
+
|
|
353
|
+
let rawInput = '';
|
|
354
|
+
try {
|
|
355
|
+
rawInput = await fsp.readFile(env.INPUT_FILE || '', 'utf8');
|
|
356
|
+
} catch {}
|
|
357
|
+
const projectRoot = env.PROJECT_ROOT || env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const extracted = extractCommand({ rawInput, env });
|
|
361
|
+
const command = extracted.unparseable ? '' : extracted.command;
|
|
362
|
+
const verdict = evaluate({ command, projectRoot, env });
|
|
363
|
+
if (verdict.stdout) process.stdout.write(verdict.stdout);
|
|
364
|
+
if (verdict.stderr) process.stderr.write(verdict.stderr);
|
|
365
|
+
process.exit(verdict.code);
|
|
366
|
+
} catch {
|
|
367
|
+
// Fail-closed (guard parity): a crash means the payload was never
|
|
368
|
+
// inspected — refuse, never a silent non-blocking hook error.
|
|
369
|
+
if (CRASH_REFUSAL.stdout) process.stdout.write(CRASH_REFUSAL.stdout);
|
|
370
|
+
if (CRASH_REFUSAL.stderr) process.stderr.write(CRASH_REFUSAL.stderr);
|
|
371
|
+
process.exit(CRASH_REFUSAL.code);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (isDirectRun()) {
|
|
376
|
+
main();
|
|
377
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: Block dangerous bash commands
|
|
3
3
|
# Matched on: Bash
|
|
4
|
+
#
|
|
5
|
+
# C40 TASK-003: this script is a THIN WRAPPER. The single source of truth for
|
|
6
|
+
# command extraction, quoted-span scrub, the pattern table, the rm allowlist
|
|
7
|
+
# and every verdict byte is block-dangerous.mjs (SPEC §14 — one implementation
|
|
8
|
+
# of a fail-closed gate; this shell keeps staging, the degraded-input salvage
|
|
9
|
+
# path, telemetry, and the env contract so direct/debug invocation and
|
|
10
|
+
# git-revert rollback keep working).
|
|
4
11
|
|
|
5
12
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
HOOK_DIR="$SCRIPT_DIR"
|
|
14
|
+
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
6
15
|
# shellcheck source=/dev/null
|
|
7
16
|
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
17
|
# TASK-019: arm advisory latency telemetry; the finish row is written by the
|
|
@@ -75,228 +84,20 @@ else
|
|
|
75
84
|
fi
|
|
76
85
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
77
86
|
fi
|
|
78
|
-
if [ "${UKIT_SALVAGE_ACTIVE:-0}" = "1" ]; then
|
|
79
|
-
# TASK-003: degraded path already recovered a COMPLETE command — skip the
|
|
80
|
-
# normal extraction (the staged prefix would not parse anyway).
|
|
81
|
-
COMMAND="$UKIT_SALVAGED_COMMAND"
|
|
82
|
-
else
|
|
83
|
-
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
84
|
-
# jq is not installed on stock macOS. Keep jq as the low-latency normal path, but use
|
|
85
|
-
# UKit's required Node runtime as a fallback so its absence cannot silently disable the gate.
|
|
86
|
-
if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
|
|
87
|
-
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
|
|
88
|
-
else
|
|
89
|
-
COMMAND=$(printf '%s' "$INPUT" | node -e '
|
|
90
|
-
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
|
|
91
|
-
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
92
|
-
const chunks = [];
|
|
93
|
-
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
94
|
-
process.stdin.on("end", () => {
|
|
95
|
-
try {
|
|
96
|
-
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
97
|
-
process.stdout.write(typeof payload?.tool_input?.command === "string" ? payload.tool_input.command : "");
|
|
98
|
-
} catch {}
|
|
99
|
-
});
|
|
100
|
-
' 2>/dev/null)
|
|
101
|
-
fi
|
|
102
|
-
fi
|
|
103
|
-
|
|
104
|
-
if [ -z "$COMMAND" ]; then
|
|
105
|
-
exit 0
|
|
106
|
-
fi
|
|
107
87
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
88
|
+
# Delegate to the module: identical env contract the bash glue consumed.
|
|
89
|
+
# realpath parity (TASK-013): the pre-port gate failed closed when the system
|
|
90
|
+
# `realpath` was unavailable; the module resolves in-proc via Node, so the
|
|
91
|
+
# wrapper forwards the dependency signal — no realpath binary -> canonical
|
|
92
|
+
# containment cannot be proven -> allowlist targets defer, never allow.
|
|
93
|
+
command -v realpath >/dev/null 2>&1 || UKIT_REALPATH_MISSING=1
|
|
94
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" UKIT_SALVAGE_ACTIVE="${UKIT_SALVAGE_ACTIVE:-0}" UKIT_SALVAGED_COMMAND="${UKIT_SALVAGED_COMMAND:-}" UKIT_REALPATH_MISSING="${UKIT_REALPATH_MISSING:-0}" node "$HOOK_DIR/block-dangerous.mjs"
|
|
95
|
+
NODE_RC=$?
|
|
96
|
+
# BUG-C21-03 parity (fail-closed): a node crash means the payload was never
|
|
97
|
+
# fully inspected — announce + block, never a silent non-blocking hook error.
|
|
98
|
+
if [ "$NODE_RC" -ne 0 ] && [ "$NODE_RC" -ne 2 ]; then
|
|
99
|
+
printf '%s\n' '{"systemMessage":"UKit dangerous-command gate crashed before finishing its scan; blocked fail-closed rather than pass an uninspected payload. Run `ukit install` if this repeats."}'
|
|
100
|
+
echo "BLOCKED: dangerous-command gate crashed (exit $NODE_RC); payload never inspected (fail-closed)." >&2
|
|
101
|
+
exit 2
|
|
122
102
|
fi
|
|
123
|
-
|
|
124
|
-
# Dangerous patterns to block
|
|
125
|
-
DANGEROUS_PATTERNS=(
|
|
126
|
-
"rm -rf /"
|
|
127
|
-
"rm -rf ~"
|
|
128
|
-
"rm -rf \\.?\\.([[:space:]]|$)"
|
|
129
|
-
"git push --force"
|
|
130
|
-
"git push -f "
|
|
131
|
-
"git reset --hard"
|
|
132
|
-
"git clean -fd"
|
|
133
|
-
"git checkout \."
|
|
134
|
-
"git restore \."
|
|
135
|
-
"> /dev/sda"
|
|
136
|
-
"mkfs\."
|
|
137
|
-
":(){ :|:& };:"
|
|
138
|
-
"dd if=/dev/"
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
# Dangerous detections surface as a structured decision (stdout JSON). TASK-223:
|
|
142
|
-
# emission is centralized in ukit_emit_permission_decision — exit 0 on the direct
|
|
143
|
-
# host (where a non-zero exit would discard the JSON as a bare "hook error"),
|
|
144
|
-
# exit 2 inside the omp chain (whose bridge only parses stdout on code 2).
|
|
145
|
-
# Probe verdict (2026-09-20, spec step 4): this repo runs
|
|
146
|
-
# permissions.defaultMode=bypassPermissions, where an exit-0 `ask` auto-approves —
|
|
147
|
-
# dead for refusals. So the direct host gets `deny` (a proper denied surface, still
|
|
148
|
-
# fail-closed); the omp chain keeps `ask` (the host boundary prompts the human).
|
|
149
|
-
# The raw command and the matched pattern are NEVER echoed — arguments and comments can
|
|
150
|
-
# carry secrets, and the pattern text itself restates the dangerous command.
|
|
151
|
-
emit_dangerous_decision() {
|
|
152
|
-
REASON="$1"
|
|
153
|
-
if command -v ukit_emit_permission_decision >/dev/null 2>&1; then
|
|
154
|
-
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
155
|
-
ukit_emit_permission_decision ask "$REASON"
|
|
156
|
-
else
|
|
157
|
-
ukit_emit_permission_decision deny "$REASON"
|
|
158
|
-
fi
|
|
159
|
-
fi
|
|
160
|
-
# Helper unavailable (pre-install tree): keep the safe inline parity — the
|
|
161
|
-
# direct host gets deny + exit 0, the chain gets ask + exit 2.
|
|
162
|
-
if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then
|
|
163
|
-
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
|
|
164
|
-
echo "BLOCKED: $REASON" >&2
|
|
165
|
-
exit 2
|
|
166
|
-
fi
|
|
167
|
-
printf '%s\n' "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}"
|
|
168
|
-
echo "BLOCKED: $REASON" >&2
|
|
169
|
-
exit 0
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
|
|
173
|
-
if echo "$SCAN_COMMAND" | grep -qE "$pattern"; then
|
|
174
|
-
emit_dangerous_decision "Dangerous command pattern detected; it could cause irreversible damage. UKit defers this to a human decision."
|
|
175
|
-
fi
|
|
176
|
-
done
|
|
177
|
-
|
|
178
|
-
# Handle rm commands: allow safe cleanup targets only, defer everything else to a human.
|
|
179
|
-
# Direct rm invocations are parsed per shell segment (`;`, `&`, `|` split) with per-target
|
|
180
|
-
# allowlist evaluation: split flags (`rm -r -f`, `rm --force -r`) register, and one
|
|
181
|
-
# allowlisted word can never legitimise a sibling target (`rm -rf dist /`). A whole-command
|
|
182
|
-
# fallback still catches rm inside wrapped/quoted contexts (`bash -c "rm -r -f /x"`) and
|
|
183
|
-
# flags placed after targets (`rm src -rf`), where segment parsing cannot see rm as a head.
|
|
184
|
-
SAFE_DELETE_REGEX='(^|[[:space:]])(\./)?(dist|build|coverage|\.next|\.nuxt|\.turbo|tmp|temp|\.cache|node_modules)(/|[[:space:]]|$)'
|
|
185
|
-
UNSAFE_TARGET_REGEX='(^|[[:space:]])(/|~|\.\.?($|/)|\.($|/))'
|
|
186
|
-
SAFE_ONE_TARGET_REGEX='^(\./)?(dist|build|coverage|\.next|\.nuxt|\.turbo|tmp|temp|\.cache|node_modules)(/.*)?$'
|
|
187
|
-
UNSAFE_ONE_TARGET_REGEX='^(/.*|~|~/.*|\.\.|\.\./.*|\.)$'
|
|
188
|
-
RM_WORD_REGEX=$'(^|[;&|[:space:]"\x27])rm([[:space:]"\x27]|$)'
|
|
189
|
-
|
|
190
|
-
# TASK-013 / SPEC §10: canonical-path containment for the name allowlist. A
|
|
191
|
-
# target that passes SAFE_ONE_TARGET_REGEX must ALSO resolve inside
|
|
192
|
-
# realpath(projectRoot) — a `dist` symlinked outside the project (or a `..`
|
|
193
|
-
# segment) downgrades the segment to the generic verdict, never allow.
|
|
194
|
-
#
|
|
195
|
-
# ukit_canonical_path mirrors GNU `realpath -m` semantics portably:
|
|
196
|
-
# 1. `realpath -m` (GNU/coreutils) when supported;
|
|
197
|
-
# 2. ONE documented fallback — stock macOS `realpath` cannot resolve
|
|
198
|
-
# nonexistent paths, so resolve the deepest existing ancestor and append
|
|
199
|
-
# the lexical remainder (equivalent for containment);
|
|
200
|
-
# 3. `realpath` itself absent → empty output → fail closed (unresolved-
|
|
201
|
-
# generic), never silently allow.
|
|
202
|
-
ukit_canonical_path() {
|
|
203
|
-
__u_p="$1"
|
|
204
|
-
__u_out=$(realpath -m -- "$__u_p" 2>/dev/null) && [ -n "$__u_out" ] && { printf '%s' "$__u_out"; return 0; }
|
|
205
|
-
__u_rest=""
|
|
206
|
-
while [ -n "$__u_p" ] && [ "$__u_p" != "/" ] && [ ! -e "$__u_p" ] && [ ! -L "$__u_p" ]; do
|
|
207
|
-
__u_rest="/${__u_p##*/}${__u_rest}"
|
|
208
|
-
__u_p="${__u_p%/*}"
|
|
209
|
-
done
|
|
210
|
-
[ -z "$__u_p" ] && __u_p="/"
|
|
211
|
-
__u_out=$(realpath -- "$__u_p" 2>/dev/null) || return 1
|
|
212
|
-
printf '%s%s' "$__u_out" "$__u_rest"
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
# Project-root canonical prefix, resolved once before the segment loop. Targets
|
|
216
|
-
# are interpreted relative to the project root (the host's rm cwd), not the
|
|
217
|
-
# hook's own cwd. An unresolvable root fails closed: no allowlist target can
|
|
218
|
-
# prove containment against an unknown root.
|
|
219
|
-
UKIT_PROJECT_ROOT_CANON=$(ukit_canonical_path "${CLAUDE_PROJECT_DIR:-$PWD}")
|
|
220
|
-
|
|
221
|
-
RM_VERDICT_UNSAFE=0
|
|
222
|
-
RM_VERDICT_GENERIC=0
|
|
223
|
-
RM_DIRECT_SEEN=0
|
|
224
|
-
set -f
|
|
225
|
-
while IFS= read -r segment; do
|
|
226
|
-
[ -n "$segment" ] || continue
|
|
227
|
-
seg_head=$(printf '%s' "$segment" | sed -E 's/^[[:space:]]*//' | awk '{print $1}' | xargs -I{} basename {} 2>/dev/null)
|
|
228
|
-
[ "$seg_head" = "rm" ] || continue
|
|
229
|
-
seg_flags=$(printf '%s' "$segment" | awk '{for(i=2;i<=NF;i++){if($i=="--")break;if($i ~ /^-/)printf "%s\n",$i;else break}}')
|
|
230
|
-
seg_has_r=0
|
|
231
|
-
seg_has_f=0
|
|
232
|
-
for flag in $seg_flags; do
|
|
233
|
-
case "$flag" in
|
|
234
|
-
--recursive) seg_has_r=1 ;;
|
|
235
|
-
--force) seg_has_f=1 ;;
|
|
236
|
-
--*) ;;
|
|
237
|
-
-*) case "$flag" in *[rR]*) seg_has_r=1 ;; esac
|
|
238
|
-
case "$flag" in *f*) seg_has_f=1 ;; esac ;;
|
|
239
|
-
esac
|
|
240
|
-
done
|
|
241
|
-
[ "$seg_has_r" -eq 1 ] && [ "$seg_has_f" -eq 1 ] || continue
|
|
242
|
-
RM_DIRECT_SEEN=1
|
|
243
|
-
seg_targets=$(printf '%s' "$segment" | awk '{saw=0;for(i=2;i<=NF;i++){if(!saw){if($i=="--"){saw=1;continue}if($i ~ /^-/)continue;saw=1;printf "%s\n",$i}else if($i !~ /^-/)printf "%s\n",$i}}')
|
|
244
|
-
seg_all_safe=1
|
|
245
|
-
seg_any_unsafe=0
|
|
246
|
-
for target in $seg_targets; do
|
|
247
|
-
# Strip ONE trailing slash so `dist/` matches the allowlist — but never shrink a
|
|
248
|
-
# bare `/` (or `~`) to an empty string, which would silently dodge the unsafe check.
|
|
249
|
-
if [ "${#target}" -gt 1 ]; then target="${target%/}"; fi
|
|
250
|
-
if printf '%s' "$target" | grep -qE "$UNSAFE_ONE_TARGET_REGEX"; then
|
|
251
|
-
seg_any_unsafe=1
|
|
252
|
-
seg_all_safe=0
|
|
253
|
-
elif printf '%s' "$target" | grep -qE "$SAFE_ONE_TARGET_REGEX"; then
|
|
254
|
-
# Containment gate (TASK-013): the allowlisted NAME is not enough — the
|
|
255
|
-
# canonical target must live inside the canonical project root. `..`
|
|
256
|
-
# segments and unresolvable/missing realpath fail closed to generic.
|
|
257
|
-
case "$target" in
|
|
258
|
-
*..*) seg_all_safe=0 ;;
|
|
259
|
-
*)
|
|
260
|
-
case "$target" in /*) __t_abs="$target" ;; *) __t_abs="$UKIT_PROJECT_ROOT_CANON/$target" ;; esac
|
|
261
|
-
__t_canon=$(ukit_canonical_path "$__t_abs")
|
|
262
|
-
if [ -z "$UKIT_PROJECT_ROOT_CANON" ] || [ -z "$__t_canon" ]; then
|
|
263
|
-
seg_all_safe=0
|
|
264
|
-
else
|
|
265
|
-
case "$__t_canon" in
|
|
266
|
-
"$UKIT_PROJECT_ROOT_CANON"/*) : ;;
|
|
267
|
-
*) seg_all_safe=0 ;;
|
|
268
|
-
esac
|
|
269
|
-
fi
|
|
270
|
-
;;
|
|
271
|
-
esac
|
|
272
|
-
else
|
|
273
|
-
seg_all_safe=0
|
|
274
|
-
fi
|
|
275
|
-
done
|
|
276
|
-
if [ "$seg_any_unsafe" -eq 1 ]; then RM_VERDICT_UNSAFE=1
|
|
277
|
-
elif [ "$seg_all_safe" -eq 0 ]; then RM_VERDICT_GENERIC=1
|
|
278
|
-
fi
|
|
279
|
-
done <<SEGMENTS
|
|
280
|
-
$(printf '%s' "$SCAN_COMMAND" | tr ';&|' '\n\n\n')
|
|
281
|
-
SEGMENTS
|
|
282
|
-
set +f
|
|
283
|
-
|
|
284
|
-
if [ "$RM_DIRECT_SEEN" -eq 0 ] && echo "$SCAN_COMMAND" | grep -qE "$RM_WORD_REGEX" \
|
|
285
|
-
&& echo "$SCAN_COMMAND" | grep -qE "(^|[[:space:]])--recursive|(^|[[:space:]])-[a-zA-Z]*[rR]" \
|
|
286
|
-
&& echo "$SCAN_COMMAND" | grep -qE "(^|[[:space:]])--force|(^|[[:space:]])-[a-zA-Z]*f"; then
|
|
287
|
-
# Unsafe wins over the allowlist here: a safe word elsewhere in the line must never
|
|
288
|
-
# legitimise an absolute/home/parent target we can also see.
|
|
289
|
-
if echo "$SCAN_COMMAND" | grep -qE "$UNSAFE_TARGET_REGEX"; then
|
|
290
|
-
RM_VERDICT_UNSAFE=1
|
|
291
|
-
elif ! echo "$SCAN_COMMAND" | grep -qE "$SAFE_DELETE_REGEX"; then
|
|
292
|
-
RM_VERDICT_GENERIC=1
|
|
293
|
-
fi
|
|
294
|
-
fi
|
|
295
|
-
|
|
296
|
-
if [ "$RM_VERDICT_UNSAFE" -eq 1 ]; then
|
|
297
|
-
emit_dangerous_decision "Unsafe delete target detected; recursive force-delete could cause irreversible damage. UKit defers this to a human decision."
|
|
298
|
-
elif [ "$RM_VERDICT_GENERIC" -eq 1 ]; then
|
|
299
|
-
emit_dangerous_decision "Recursive force-delete outside safe cleanup targets (dist/build/coverage/.next/.nuxt/.turbo/tmp/.cache/node_modules) is destructive. UKit defers this to a human decision."
|
|
300
|
-
fi
|
|
301
|
-
|
|
302
|
-
exit 0
|
|
103
|
+
exit "$NODE_RC"
|