@ngockhoale/ukit 2.7.4 → 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 +38 -0
- package/manifests/platform.full.yaml +43 -0
- package/package.json +1 -1
- 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/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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.7.5 - 2026-09-20
|
|
6
|
+
|
|
7
|
+
Hook performance work — cycle C40 (TASK-001..007): `.mjs` module steps,
|
|
8
|
+
fail-closed registry, and a hook budget guard.
|
|
9
|
+
|
|
10
|
+
- `hook-chain-runner` supports `"<path>.mjs[:N]"` module steps: the arg is
|
|
11
|
+
imported in-proc and its `runHook(ctx)` is invoked with
|
|
12
|
+
`{payload, payloadText, projectRoot, env, deadlineMs, signal}` — no child
|
|
13
|
+
process spawn on the hot path. A throw reports `error`, a
|
|
14
|
+
`deadlineMs`/`AbortSignal` breach reports `timeout` + `killed`; infra
|
|
15
|
+
failures exit 2 when the step is fail-closed, else 1. Every step appends a
|
|
16
|
+
`recordHookTiming` telemetry row under its `<name>.mjs` hook name, and
|
|
17
|
+
`UKIT_HOOK_DEADLINE_MS` is scrubbed from `process.env`/`ctx.env` around the
|
|
18
|
+
import so a stale host deadline cannot leak into module steps.
|
|
19
|
+
- Fail-closed registry extended: `sensitive-data-guard.mjs` and
|
|
20
|
+
`block-dangerous.mjs` join `FAIL_CLOSED_SCRIPTS`, so module-step verdicts
|
|
21
|
+
keep the same fail-closed posture as the `.sh` wrappers they replaced.
|
|
22
|
+
- `sensitive-data-guard`, `block-dangerous`, and `record-execution` ported to
|
|
23
|
+
dual-mode modules: each `.mjs` exports `runHook(ctx)` for chain execution
|
|
24
|
+
plus an `isDirectRun()` CLI path for standalone/debug invocation;
|
|
25
|
+
`hookFailClosed` is exported per module (`true` for the two gates, `false`
|
|
26
|
+
for the telemetry sink). The `.sh` files remain as thin wrappers — staging,
|
|
27
|
+
salvage glue (`UKIT_SALVAGED_*` env), telemetry, then a single
|
|
28
|
+
`node <module>.mjs` call — with byte-identical verdicts verified against the
|
|
29
|
+
pre-port implementations. `record-execution.mjs` also exposes
|
|
30
|
+
`setLedgerMessageEmitter` on `execution-ledger.mjs` so in-proc runs route
|
|
31
|
+
the `noteSweepFailure` systemMessage through the emitter instead of fd 1.
|
|
32
|
+
- Settings + bridge wiring: `settings.json` chains and the omp bridge
|
|
33
|
+
`HOOK_EVENT_MAP` now point at the `.mjs` steps; the bridge's
|
|
34
|
+
`TIMEOUT_STAYS_CLOSED` / payload-stays-closed checks cover
|
|
35
|
+
`block-dangerous.mjs` so verdicts stay identical after the swap.
|
|
36
|
+
- Hook budget guard: `tests/hooks/hookChainBudget.test.js` enforces ≤8 chain
|
|
37
|
+
steps per group, requires a `:N` suffix on every `.sh|mjs` arg, and pins
|
|
38
|
+
outer `timeout` ≥ Σ(:N) + 10s — adding or removing a hook without updating
|
|
39
|
+
the outer fails loudly. The same suite asserts the settings↔bridge mirror
|
|
40
|
+
1:1 for every event/matcher lane. The contract is documented under
|
|
41
|
+
`UKIT_INTERNALS.md → Hook budget`.
|
|
42
|
+
|
|
5
43
|
## 2.7.4 - 2026-09-20
|
|
6
44
|
|
|
7
45
|
Deferred hook findings + residual risks — cycle C39 (TASK-001..005).
|
|
@@ -1096,6 +1096,20 @@ items:
|
|
|
1096
1096
|
packs:
|
|
1097
1097
|
- core
|
|
1098
1098
|
|
|
1099
|
+
# TASK-004 (C40): advisory module the thin .sh wrapper delegates to and the
|
|
1100
|
+
# chain runner loads in-proc.
|
|
1101
|
+
- id: hook-record-execution-module
|
|
1102
|
+
type: hook
|
|
1103
|
+
sourceTemplate: .claude/hooks/record-execution.mjs
|
|
1104
|
+
targetPath: .claude/hooks/record-execution.mjs
|
|
1105
|
+
requires:
|
|
1106
|
+
- ukit-runtime-scripts
|
|
1107
|
+
mergeStrategy: overwrite_with_backup
|
|
1108
|
+
variables: []
|
|
1109
|
+
enabledByDefault: true
|
|
1110
|
+
packs:
|
|
1111
|
+
- core
|
|
1112
|
+
|
|
1099
1113
|
- id: hook-completion-gate
|
|
1100
1114
|
type: hook
|
|
1101
1115
|
sourceTemplate: .claude/hooks/completion-gate.sh
|
|
@@ -1141,6 +1155,20 @@ items:
|
|
|
1141
1155
|
packs:
|
|
1142
1156
|
- core
|
|
1143
1157
|
|
|
1158
|
+
# TASK-003 (C40): fail-closed module the thin .sh wrapper delegates to and
|
|
1159
|
+
# the chain runner loads in-proc.
|
|
1160
|
+
- id: hook-block-dangerous-module
|
|
1161
|
+
type: hook
|
|
1162
|
+
sourceTemplate: .claude/hooks/block-dangerous.mjs
|
|
1163
|
+
targetPath: .claude/hooks/block-dangerous.mjs
|
|
1164
|
+
requires:
|
|
1165
|
+
- ukit-runtime-scripts
|
|
1166
|
+
mergeStrategy: overwrite_with_backup
|
|
1167
|
+
variables: []
|
|
1168
|
+
enabledByDefault: true
|
|
1169
|
+
packs:
|
|
1170
|
+
- core
|
|
1171
|
+
|
|
1144
1172
|
# requires: [ukit-runtime-scripts] is forward-declared for the shared sensitive-value
|
|
1145
1173
|
# scanner (consumed by the guard once TASK-039 wires it). The guard is fail-closed, so
|
|
1146
1174
|
# its runtime dependency must be installed first — keeps the ordering posture explicit.
|
|
@@ -1156,6 +1184,21 @@ items:
|
|
|
1156
1184
|
packs:
|
|
1157
1185
|
- core
|
|
1158
1186
|
|
|
1187
|
+
# TASK-002 (C40): dual-mode module the thin .sh wrapper delegates to and the
|
|
1188
|
+
# chain runner loads in-proc. Requires ukit-runtime-scripts for the scanner
|
|
1189
|
+
# AND hook-field-salvage.mjs (both ship via that directory item).
|
|
1190
|
+
- id: hook-sensitive-data-guard-module
|
|
1191
|
+
type: hook
|
|
1192
|
+
sourceTemplate: .claude/hooks/sensitive-data-guard.mjs
|
|
1193
|
+
targetPath: .claude/hooks/sensitive-data-guard.mjs
|
|
1194
|
+
requires:
|
|
1195
|
+
- ukit-runtime-scripts
|
|
1196
|
+
mergeStrategy: overwrite_with_backup
|
|
1197
|
+
variables: []
|
|
1198
|
+
enabledByDefault: true
|
|
1199
|
+
packs:
|
|
1200
|
+
- core
|
|
1201
|
+
|
|
1159
1202
|
- id: hook-handoff-model-guard
|
|
1160
1203
|
type: hook
|
|
1161
1204
|
sourceTemplate: .claude/hooks/handoff-model-guard.sh
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|