@ngockhoale/ukit 2.7.4 → 2.7.6
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 +64 -0
- package/manifests/platform.full.yaml +43 -0
- package/package.json +1 -1
- package/src/core/ompConfigMerge.js +7 -2
- package/src/core/permissionDoctor.js +59 -4
- package/src/core/permissionPolicy.js +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 -7
- 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/config.yml +9 -8
- package/templates/.omp/hooks/pre/ukit-bridge.js +20 -12
|
@@ -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"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// record-execution.mjs — C40 TASK-004 dual-mode port of record-execution.sh
|
|
2
|
+
// (SPEC §FR-004, §8): persist session-scoped source/write/verification receipts
|
|
3
|
+
// through ukit/runtime/execution-ledger.mjs.
|
|
4
|
+
//
|
|
5
|
+
// * `runHook(ctx)` — in-proc chain step. ctx = { payload, payloadText,
|
|
6
|
+
// projectRoot, env, deadlineMs, signal } -> { code, stdout, stderr }.
|
|
7
|
+
// ADVISORY: always code 0. A missing/failed runtime announces the same
|
|
8
|
+
// "runtime script failed — run: ukit install" systemMessage the .sh emits —
|
|
9
|
+
// loud, never a silent pass. In-proc mode collapses the .sh's separate
|
|
10
|
+
// "missing" vs "failed" branches into the "failed" message (an import that
|
|
11
|
+
// cannot find the module IS a failed runtime; deliberate parity note).
|
|
12
|
+
// * `isDirectRun()` CLI mode — the .sh thin wrapper execs
|
|
13
|
+
// `node record-execution.mjs` with the env contract INPUT_FILE,
|
|
14
|
+
// PROJECT_ROOT, UKIT_HOOK_DEADLINE_MS, UKIT_HARNESS (`--record` accepted
|
|
15
|
+
// for symmetry with the ledger's own CLI).
|
|
16
|
+
//
|
|
17
|
+
// Sweep-failure routing (FR-004): while this step runs it installs
|
|
18
|
+
// setLedgerMessageEmitter so a sampled exec-ledger dir-sweep failure lands in
|
|
19
|
+
// the returned stdout instead of fd 1 (which would corrupt the runner's
|
|
20
|
+
// verdict channel). The emitter is always reset in `finally`.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import fsp from 'node:fs/promises';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
export const hookFailClosed = false;
|
|
28
|
+
|
|
29
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const DEFAULT_LEDGER_PATH = path.join(MODULE_DIR, '..', 'ukit', 'runtime', 'execution-ledger.mjs');
|
|
31
|
+
|
|
32
|
+
// Byte-pinned parity with the .sh's `STATUS != 0` branch.
|
|
33
|
+
const RUNTIME_FAILED =
|
|
34
|
+
'{"systemMessage":"UKit record execution: runtime script failed — run: ukit install"}\n';
|
|
35
|
+
|
|
36
|
+
function parsePayload(rawInput) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(rawInput);
|
|
39
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Shared advisory body for both modes. `rawInput` that fails to parse is a
|
|
46
|
+
// silent pass — identical to the .sh piping garbage into `node … --record`,
|
|
47
|
+
// which JSON.parse-falls back to `{}` and early-returns on the missing
|
|
48
|
+
// tool_name.
|
|
49
|
+
async function record({ rawInput, projectRoot, env, signal, deadlineMs }) {
|
|
50
|
+
const payload = parsePayload(rawInput) ?? {};
|
|
51
|
+
const ledgerPath = env.UKIT_EXECUTION_LEDGER_PATH || DEFAULT_LEDGER_PATH;
|
|
52
|
+
let ledger;
|
|
53
|
+
try {
|
|
54
|
+
ledger = await import(pathToFileURL(ledgerPath).href);
|
|
55
|
+
} catch {
|
|
56
|
+
return { code: 0, stdout: RUNTIME_FAILED, stderr: '' };
|
|
57
|
+
}
|
|
58
|
+
if (typeof ledger.recordExecutionReceipt !== 'function') {
|
|
59
|
+
return { code: 0, stdout: RUNTIME_FAILED, stderr: '' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const emitted = [];
|
|
63
|
+
const emit = (line) => emitted.push(line);
|
|
64
|
+
if (typeof ledger.setLedgerMessageEmitter === 'function') {
|
|
65
|
+
ledger.setLedgerMessageEmitter(emit);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
await ledger.recordExecutionReceipt({
|
|
69
|
+
projectRoot,
|
|
70
|
+
payload,
|
|
71
|
+
toolName: payload.tool_name,
|
|
72
|
+
harness: env.UKIT_HARNESS || 'claude-code',
|
|
73
|
+
signal,
|
|
74
|
+
deadlineMs,
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
return { code: 0, stdout: RUNTIME_FAILED, stderr: '' };
|
|
78
|
+
} finally {
|
|
79
|
+
if (typeof ledger.setLedgerMessageEmitter === 'function') {
|
|
80
|
+
ledger.setLedgerMessageEmitter(null);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { code: 0, stdout: emitted.join(''), stderr: '' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- SPEC §8 chain-step entry ------------------------------------------------
|
|
87
|
+
export async function runHook(ctx) {
|
|
88
|
+
const env = (ctx && ctx.env) || {};
|
|
89
|
+
const projectRoot = (ctx && ctx.projectRoot) || env.PROJECT_ROOT || process.cwd();
|
|
90
|
+
let rawInput;
|
|
91
|
+
try {
|
|
92
|
+
rawInput =
|
|
93
|
+
typeof (ctx && ctx.payloadText) === 'string' && ctx.payloadText.length > 0
|
|
94
|
+
? ctx.payloadText
|
|
95
|
+
: JSON.stringify((ctx && ctx.payload) ?? {});
|
|
96
|
+
} catch {
|
|
97
|
+
rawInput = '{}';
|
|
98
|
+
}
|
|
99
|
+
return record({
|
|
100
|
+
rawInput,
|
|
101
|
+
projectRoot,
|
|
102
|
+
env,
|
|
103
|
+
signal: ctx && ctx.signal,
|
|
104
|
+
deadlineMs: ctx && ctx.deadlineMs,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- direct-run CLI mode (the .sh thin wrapper's target) ---------------------
|
|
109
|
+
export function isDirectRun() {
|
|
110
|
+
try {
|
|
111
|
+
const invoked = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
|
|
112
|
+
const self = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
113
|
+
return invoked === self;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function main() {
|
|
120
|
+
const env = process.env;
|
|
121
|
+
let rawInput = '';
|
|
122
|
+
try {
|
|
123
|
+
rawInput = await fsp.readFile(env.INPUT_FILE || '', 'utf8');
|
|
124
|
+
} catch {
|
|
125
|
+
// No staged payload — silent advisory pass, same as an unreadable INPUT.
|
|
126
|
+
}
|
|
127
|
+
const projectRoot = env.PROJECT_ROOT || process.cwd();
|
|
128
|
+
const verdict = await record({ rawInput, projectRoot, env });
|
|
129
|
+
if (verdict.stdout) process.stdout.write(verdict.stdout);
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (isDirectRun()) {
|
|
134
|
+
main().catch(() => {
|
|
135
|
+
// Advisory hook: even a catastrophic error exits 0 with the loud message.
|
|
136
|
+
try {
|
|
137
|
+
process.stdout.write(RUNTIME_FAILED);
|
|
138
|
+
} catch { /* stdout already gone */ }
|
|
139
|
+
process.exit(0);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PostToolUse hook: persist session-scoped source/write/verification receipts.
|
|
3
3
|
# ADVISORY ONLY — always exit 0. A missing or failing runtime must be loud, not a silent pass.
|
|
4
|
+
#
|
|
5
|
+
# C40 TASK-004 (SPEC §FR-004, §14): thin wrapper — stdin staging + env, then
|
|
6
|
+
# delegate to record-execution.mjs (single source of truth, shared with the
|
|
7
|
+
# in-proc chain step). The .sh used to distinguish "runtime script missing"
|
|
8
|
+
# from "runtime script failed"; the in-proc contract collapses both into the
|
|
9
|
+
# "failed" systemMessage, so this wrapper now emits "failed" for any non-zero
|
|
10
|
+
# node exit (a missing .mjs is a failed runtime too).
|
|
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
|
|
@@ -58,19 +67,14 @@ else
|
|
|
58
67
|
fi
|
|
59
68
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
60
69
|
fi
|
|
61
|
-
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
62
|
-
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
63
|
-
SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
|
|
64
|
-
|
|
65
|
-
if [ ! -f "$SCRIPT" ]; then
|
|
66
|
-
printf '%s\n' '{"systemMessage":"UKit record execution: runtime script missing — run: ukit install"}'
|
|
67
|
-
exit 0
|
|
68
|
-
fi
|
|
69
70
|
|
|
70
|
-
OUTPUT=$(
|
|
71
|
+
OUTPUT=$(INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" UKIT_HARNESS="${UKIT_HARNESS:-claude-code}" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$HOOK_DIR/record-execution.mjs")
|
|
71
72
|
STATUS=$?
|
|
72
73
|
|
|
73
74
|
if [ "$STATUS" -ne 0 ]; then
|
|
75
|
+
# In-proc parity (SPEC §FR-004): the old "runtime script missing" branch is
|
|
76
|
+
# folded into "failed" — an .mjs that is absent or crashed is the same
|
|
77
|
+
# failed-runtime condition, and the module emits this exact line itself.
|
|
74
78
|
printf '%s\n' '{"systemMessage":"UKit record execution: runtime script failed — run: ukit install"}'
|
|
75
79
|
exit 0
|
|
76
80
|
fi
|
|
@@ -78,3 +82,4 @@ fi
|
|
|
78
82
|
if [ -n "$OUTPUT" ]; then
|
|
79
83
|
printf '%s\n' "$OUTPUT"
|
|
80
84
|
fi
|
|
85
|
+
exit 0
|