@ngockhoale/ukit 2.6.11 → 2.7.0

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.
@@ -16,10 +16,22 @@ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
16
16
  if [ "$__ukit_main_stage_rc" -ne 0 ]; then
17
17
  # Infra failure during staging (mktemp/truncate): payload was never
18
18
  # inspected — announce the degrade (SPEC §8), never a silent pass.
19
- ukit_emit_input_degraded failclosed "dangerous-command gate"
19
+ # TASK-003: `deny`, not `ask` — ask auto-approves under bypassPermissions.
20
+ ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so dangerous-command gate cannot prove it safe."
20
21
  fi
21
- # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
22
- ukit_input_degraded && ukit_emit_input_degraded failclosed "dangerous-command gate"
22
+ # BUG-C21-01 / TASK-003: a truncated/stalled staged payload used to be announced
23
+ # as a blanket `ask` — but `ask` auto-approves under bypassPermissions (direct
24
+ # host) = the gate silently skipped. Now the degraded path first attempts a
25
+ # bounded salvage of tool_input.command: a COMPLETE recovered command still gets
26
+ # the normal verdict below; an unrecoverable/incomplete one is refused with
27
+ # `deny` (the only verdict both hosts treat as closed — review R1-1).
28
+ if ukit_input_degraded; then
29
+ if UKIT_SALVAGED_COMMAND="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_input.command")"; then
30
+ UKIT_SALVAGE_ACTIVE=1
31
+ else
32
+ ukit_emit_permission_decision deny "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."
33
+ fi
34
+ fi
23
35
  else
24
36
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
25
37
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -52,16 +64,22 @@ else
52
64
  # path's ukit_emit_input_degraded.
53
65
  rm -f "$UKIT_INPUT_FILE"
54
66
  UKIT_INPUT_FILE=""
55
- # TASK-223: same emit shape as ukit_emit_permission_decision — under a
56
- # direct host the JSON only reaches the permission pipeline on exit 0; the
57
- # omp chain needs exit 2 (marker exported by hook-chain-runner.mjs).
58
- printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so dangerous-command gate cannot prove it safe. UKit defers this to a human decision."}}'
59
- echo "BLOCKED: dangerous-command gate could not inspect a truncated/stalled payload; deferred to human." >&2
67
+ # TASK-223 + TASK-003: same emit shape as ukit_emit_permission_decision —
68
+ # under a direct host the JSON only reaches the permission pipeline on exit
69
+ # 0; the omp chain needs exit 2 (marker exported by hook-chain-runner.mjs).
70
+ # The decision is `deny`, never `ask`: bypassPermissions auto-approves ask.
71
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so dangerous-command gate cannot prove it safe."}}'
72
+ echo "BLOCKED: dangerous-command gate could not inspect a truncated/stalled payload; refused fail-closed." >&2
60
73
  if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
61
74
  exit 0
62
75
  fi
63
76
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
64
77
  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
65
83
  INPUT="$(cat "$UKIT_INPUT_FILE")"
66
84
  # jq is not installed on stock macOS. Keep jq as the low-latency normal path, but use
67
85
  # UKit's required Node runtime as a fallback so its absence cannot silently disable the gate.
@@ -81,6 +99,7 @@ process.stdin.on("end", () => {
81
99
  });
82
100
  ' 2>/dev/null)
83
101
  fi
102
+ fi
84
103
 
85
104
  if [ -z "$COMMAND" ]; then
86
105
  exit 0
@@ -168,6 +187,37 @@ SAFE_ONE_TARGET_REGEX='^(\./)?(dist|build|coverage|\.next|\.nuxt|\.turbo|tmp|tem
168
187
  UNSAFE_ONE_TARGET_REGEX='^(/.*|~|~/.*|\.\.|\.\./.*|\.)$'
169
188
  RM_WORD_REGEX=$'(^|[;&|[:space:]"\x27])rm([[:space:]"\x27]|$)'
170
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
+
171
221
  RM_VERDICT_UNSAFE=0
172
222
  RM_VERDICT_GENERIC=0
173
223
  RM_DIRECT_SEEN=0
@@ -201,7 +251,24 @@ while IFS= read -r segment; do
201
251
  seg_any_unsafe=1
202
252
  seg_all_safe=0
203
253
  elif printf '%s' "$target" | grep -qE "$SAFE_ONE_TARGET_REGEX"; then
204
- :
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
205
272
  else
206
273
  seg_all_safe=0
207
274
  fi
@@ -44,10 +44,19 @@ source "$HOOK_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
44
44
  if [ "$__ukit_main_stage_rc" -ne 0 ]; then
45
45
  # Infra failure during staging (mktemp/truncate): payload was never
46
46
  # inspected — announce the degrade (SPEC §8), never a silent pass.
47
- ukit_emit_input_degraded failclosed "context hard-cap gate"
47
+ # TASK-003: `deny`, not `ask` — ask auto-approves under bypassPermissions.
48
+ ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so context hard-cap gate cannot prove it safe."
49
+ fi
50
+ # BUG-C21-01 / TASK-003: degraded input first salvages tool_name — a COMPLETE
51
+ # recovered name takes the normal verdict path below; an unrecoverable one is
52
+ # refused with `deny` (never `ask`: bypassPermissions auto-approves ask).
53
+ if ukit_input_degraded; then
54
+ if UKIT_SALVAGED_TOOL_NAME="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_name")"; then
55
+ UKIT_SALVAGE_ACTIVE=1
56
+ else
57
+ ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound) and the tool name could not be recovered, so context hard-cap gate cannot prove it safe."
58
+ fi
48
59
  fi
49
- # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
50
- ukit_input_degraded && ukit_emit_input_degraded failclosed "context hard-cap gate"
51
60
  else
52
61
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
53
62
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -80,17 +89,18 @@ else
80
89
  # path's ukit_emit_input_degraded.
81
90
  rm -f "$UKIT_INPUT_FILE"
82
91
  UKIT_INPUT_FILE=""
83
- # TASK-223: same emit shape as ukit_emit_permission_decision — exit 0 on the
84
- # direct host (non-zero discards stdout there), exit 2 inside the omp chain.
85
- printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so context hard-cap gate cannot prove it safe. UKit defers this to a human decision."}}'
86
- echo "BLOCKED: context hard-cap gate could not inspect a truncated/stalled payload; deferred to human." >&2
92
+ # TASK-223 + TASK-003: same emit shape as ukit_emit_permission_decision —
93
+ # exit 0 on the direct host (non-zero discards stdout there), exit 2 inside
94
+ # the omp chain. `deny`, never `ask`: bypassPermissions auto-approves ask.
95
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so context hard-cap gate cannot prove it safe."}}'
96
+ echo "BLOCKED: context hard-cap gate could not inspect a truncated/stalled payload; refused fail-closed." >&2
87
97
  if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
88
98
  exit 0
89
99
  fi
90
100
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
91
101
  fi
92
102
 
93
- INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" node <<'NODE'
103
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" UKIT_SALVAGE_ACTIVE="${UKIT_SALVAGE_ACTIVE:-0}" UKIT_SALVAGED_TOOL_NAME="${UKIT_SALVAGED_TOOL_NAME:-}" node <<'NODE'
94
104
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
95
105
  // SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
96
106
  // degrade (fail-open posture kept: still exit 0).
@@ -164,6 +174,14 @@ async function readRunCursor() {
164
174
  return {};
165
175
  }
166
176
  })();
177
+ // TASK-003: on the degraded path the staged prefix cannot parse, so the shell
178
+ // handed us the salvaged tool_name — reconstruct just enough payload for the
179
+ // gate's normal verdict.
180
+ if (process.env.UKIT_SALVAGE_ACTIVE === '1'
181
+ && typeof process.env.UKIT_SALVAGED_TOOL_NAME === 'string'
182
+ && process.env.UKIT_SALVAGED_TOOL_NAME) {
183
+ payload.tool_name = process.env.UKIT_SALVAGED_TOOL_NAME;
184
+ }
167
185
  const toolName = payload?.tool_name || '';
168
186
  if (!GATED_TOOLS.has(toolName)) {
169
187
  process.exit(0);
@@ -6,6 +6,58 @@
6
6
  # session. No renderer logic lives here — the shared runtime module renders.
7
7
 
8
8
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
10
+ SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/project-important.mjs"
11
+ UNREADABLE_WARNING='[UKit] PROJECT_IMPORTANT.md was not injected: the file could not be read. Run `ukit doctor`.'
12
+ PROJECT_IMPORTANT_FILE="$PROJECT_ROOT/PROJECT_IMPORTANT.md"
13
+ PROJECT_IMPORTANT_CACHE_DIR="$PROJECT_ROOT/.ukit/storage/cache"
14
+ PROJECT_IMPORTANT_CACHE="$PROJECT_IMPORTANT_CACHE_DIR/project-important.cache"
15
+
16
+ # Cache invalidation uses both metadata and a cheap content checksum. Metadata
17
+ # avoids reading a stable file twice in the common case, while the checksum
18
+ # catches in-place edits that preserve size and timestamp.
19
+ ukit_pi_fingerprint() {
20
+ local metadata checksum
21
+ # GNU coreutils first: it rejects '%m:%z:%i' outright, while BSD stat would
22
+ # accept the GNU format string and silently print a filesystem summary.
23
+ metadata="$(/usr/bin/stat -c '%Y:%s:%i' "$PROJECT_IMPORTANT_FILE" 2>/dev/null)"
24
+ if [ -z "$metadata" ]; then
25
+ metadata="$(/usr/bin/stat -f '%m:%z:%i' "$PROJECT_IMPORTANT_FILE" 2>/dev/null)"
26
+ fi
27
+ [ -n "$metadata" ] || return 1
28
+ checksum="$(cksum < "$PROJECT_IMPORTANT_FILE" 2>/dev/null)" || return 1
29
+ [ -n "$checksum" ] || return 1
30
+ printf '%s|%s' "$metadata" "$checksum"
31
+ }
32
+
33
+ # Resolve the runtime before consulting the cache. A stale cache must never
34
+ # hide a missing install or renderer, while a valid hit intentionally skips
35
+ # stdin staging: this advisory hook does not inspect its SessionStart payload,
36
+ # and waiting on a producer that holds the pipe open is the stall this hook is
37
+ # being fixed for (auto-prune-bash.sh, wired beside it, never reads stdin either).
38
+ if [ ! -f "$SCRIPT" ]; then
39
+ printf '%s\n' "$UNREADABLE_WARNING"
40
+ exit 0
41
+ fi
42
+
43
+ if [ -f "$PROJECT_IMPORTANT_FILE" ] && [ -f "$PROJECT_IMPORTANT_CACHE" ]; then
44
+ __ukit_pi_key="$(ukit_pi_fingerprint 2>/dev/null)"
45
+ if [ -n "$__ukit_pi_key" ]; then
46
+ __ukit_pi_cache="$(cat "$PROJECT_IMPORTANT_CACHE" 2>/dev/null)"
47
+ # Key on the first line; the rest is the renderer's exact stdout bytes.
48
+ # Anything malformed (empty, key-only, wrong key) falls through to the
49
+ # cold path, so a corrupt cache is a re-render, never a wrong answer.
50
+ if [ "${__ukit_pi_cache%%$'\n'*}" = "$__ukit_pi_key" ] \
51
+ && [ "$__ukit_pi_cache" != "$__ukit_pi_key" ]; then
52
+ __ukit_pi_hit="${__ukit_pi_cache#"$__ukit_pi_key"$'\n'}"
53
+ if [ -n "$__ukit_pi_hit" ]; then
54
+ printf '%s\n' "$__ukit_pi_hit"
55
+ exit 0
56
+ fi
57
+ fi
58
+ fi
59
+ fi
60
+
9
61
  # shellcheck source=/dev/null
10
62
  if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
11
63
  # Arm advisory latency telemetry when available; a missing runtime (pre-install
@@ -60,15 +112,6 @@ else
60
112
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
61
113
  fi
62
114
 
63
- PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
64
- SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/project-important.mjs"
65
- UNREADABLE_WARNING='[UKit] PROJECT_IMPORTANT.md was not injected: the file could not be read. Run `ukit doctor`.'
66
-
67
- if [ ! -f "$SCRIPT" ]; then
68
- printf '%s\n' "$UNREADABLE_WARNING"
69
- exit 0
70
- fi
71
-
72
115
  # The wrapped runtime self-arms on UKIT_HOOK_DEADLINE_MS (SCRIPT_ARM/EXIT_ARM),
73
116
  # so a wedged read exits 0 inside the deadline instead of orphaning a node child.
74
117
  INPUT="$(cat "$UKIT_INPUT_FILE" 2>/dev/null)"
@@ -80,5 +123,23 @@ if [ "$STATUS" -ne 0 ] || [ -z "$OUTPUT" ]; then
80
123
  exit 0
81
124
  fi
82
125
 
126
+ # Cache writes are advisory and atomic. Recompute the key after rendering so a
127
+ # file edit racing the renderer cannot publish bytes under an old fingerprint.
128
+ __ukit_pi_render_key=""
129
+ if [ -f "$PROJECT_IMPORTANT_FILE" ]; then
130
+ __ukit_pi_render_key="$(ukit_pi_fingerprint 2>/dev/null)"
131
+ fi
132
+ if [ -n "$__ukit_pi_render_key" ]; then
133
+ if mkdir -p "$PROJECT_IMPORTANT_CACHE_DIR" 2>/dev/null; then
134
+ __ukit_pi_tmp="$PROJECT_IMPORTANT_CACHE.tmp.$$"
135
+ if printf '%s\n%s' "$__ukit_pi_render_key" "$OUTPUT" > "$__ukit_pi_tmp" 2>/dev/null \
136
+ && mv -f "$__ukit_pi_tmp" "$PROJECT_IMPORTANT_CACHE" 2>/dev/null; then
137
+ :
138
+ else
139
+ rm -f "$__ukit_pi_tmp" 2>/dev/null || true
140
+ fi
141
+ fi
142
+ fi
143
+
83
144
  printf '%s\n' "$OUTPUT"
84
145
  exit 0
@@ -16,10 +16,20 @@ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
16
16
  if [ "$__ukit_main_stage_rc" -ne 0 ]; then
17
17
  # Infra failure during staging (mktemp/truncate): payload was never
18
18
  # inspected — announce the degrade (SPEC §8), never a silent pass.
19
- ukit_emit_input_degraded failclosed "protected-file gate"
19
+ # TASK-003: `deny`, not `ask` — ask auto-approves under bypassPermissions.
20
+ ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so protected-file gate cannot prove it safe."
20
21
  fi
21
- # BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
22
- ukit_input_degraded && ukit_emit_input_degraded failclosed "protected-file gate"
22
+ # BUG-C21-01 / TASK-003: degraded input first attempts a bounded salvage of
23
+ # tool_input.file_path — a COMPLETE recovered path still gets the normal verdict
24
+ # below; an unrecoverable/incomplete one is refused with `deny` (never `ask`:
25
+ # bypassPermissions auto-approves ask on the direct host — review R1-1).
26
+ if ukit_input_degraded; then
27
+ if UKIT_SALVAGED_FILE_PATH="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_input.file_path")"; then
28
+ UKIT_SALVAGE_ACTIVE=1
29
+ else
30
+ ukit_emit_permission_decision deny "UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound) and the file path could not be recovered, so protected-file gate cannot prove it safe."
31
+ fi
32
+ fi
23
33
  else
24
34
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
25
35
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -52,15 +62,21 @@ else
52
62
  # path's ukit_emit_input_degraded.
53
63
  rm -f "$UKIT_INPUT_FILE"
54
64
  UKIT_INPUT_FILE=""
55
- # TASK-223: same emit shape as ukit_emit_permission_decision — exit 0 on the
56
- # direct host (non-zero discards stdout there), exit 2 inside the omp chain.
57
- printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so protected-file gate cannot prove it safe. UKit defers this to a human decision."}}'
58
- echo "BLOCKED: protected-file gate could not inspect a truncated/stalled payload; deferred to human." >&2
65
+ # TASK-223 + TASK-003: same emit shape as ukit_emit_permission_decision —
66
+ # exit 0 on the direct host (non-zero discards stdout there), exit 2 inside
67
+ # the omp chain. `deny`, never `ask`: bypassPermissions auto-approves ask.
68
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so protected-file gate cannot prove it safe."}}'
69
+ echo "BLOCKED: protected-file gate could not inspect a truncated/stalled payload; refused fail-closed." >&2
59
70
  if [ -n "${UKIT_HOOK_CHAIN_RUNNER:-}" ]; then exit 2; fi
60
71
  exit 0
61
72
  fi
62
73
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
63
74
  fi
75
+ if [ "${UKIT_SALVAGE_ACTIVE:-0}" = "1" ]; then
76
+ # TASK-003: degraded path already recovered a COMPLETE file_path — skip the
77
+ # normal extraction (the staged prefix would not parse anyway).
78
+ FILE_PATH="$UKIT_SALVAGED_FILE_PATH"
79
+ else
64
80
  INPUT="$(cat "$UKIT_INPUT_FILE")"
65
81
  # jq is absent on stock macOS. Keep jq as the low-latency normal path, but fall back to
66
82
  # UKit's required Node runtime so a missing optional binary cannot disable this gate.
@@ -80,6 +96,7 @@ process.stdin.on("end", () => {
80
96
  });
81
97
  ' 2>/dev/null)
82
98
  fi
99
+ fi
83
100
 
84
101
  if [ -z "$FILE_PATH" ]; then
85
102
  exit 0
@@ -30,9 +30,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
30
  HOOK_DIR="$SCRIPT_DIR"
31
31
  SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/sensitive-value-scanner.mjs"
32
32
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
33
- # Bounded stdin (H01) — fail-closed overflow: an oversized payload is REFUSED
34
- # (silent exit 2, nothing staged, nothing echoed). The gate never truncates
35
- # unknown overflow and never materializes it in a shell variable.
33
+ # Bounded stdin (H01). TASK-003: staging uses `truncate` so a cut payload can be
34
+ # SALVAGED — when the decision-relevant field is provably complete the guard
35
+ # runs the real scan on it; when it cannot be recovered the gate refuses
36
+ # fail-closed (exit 2 + §8 systemMessage, never `ask`: bypassPermissions
37
+ # auto-approves `ask` on the direct host — review R1-1).
36
38
  # shellcheck source=/dev/null
37
39
  if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
38
40
  # TASK-019: arm advisory latency telemetry; the finish row is written by the
@@ -40,7 +42,7 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
40
42
  # runtime (pre-install tree) simply leaves telemetry off.
41
43
  source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
42
44
  trap ukit_cleanup_hook_input EXIT
43
- ukit_stage_hook_input 2097152 refuse
45
+ ukit_stage_hook_input 2097152 truncate
44
46
  STAGE_RC=$?
45
47
  if [ "$STAGE_RC" -ne 0 ]; then
46
48
  # BUG-C21-02: a fail-closed refusal must never be a SILENT block — emit the
@@ -51,6 +53,40 @@ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
51
53
  echo 'BLOCKED (sensitive data gate): payload could not be staged within bounds; never scanned (fail-closed).' >&2
52
54
  exit 2
53
55
  fi
56
+ # TASK-003 degraded-path salvage: recover hook_event_name + the channel's
57
+ # scanned field (prompt for UserPromptSubmit; tool_name + the decision field
58
+ # for PreToolUse Read/Grep/Bash). Every salvage step must prove its field
59
+ # COMPLETE; any failure refuses fail-closed — a partial field is never
60
+ # scanned (a truncated dangerous shape could parse benign = fail-open).
61
+ if ukit_input_degraded; then
62
+ __ukit_salvage_fail() {
63
+ printf '%s\n' '{"systemMessage":"UKit sensitive-data gate blocked this call: the tool-call payload was truncated or stalled during stdin staging and its scanned field could not be recovered intact, so it was never scanned. Fail-closed — reduce the payload or check for a stalled producer."}'
64
+ echo 'BLOCKED (sensitive data gate): truncated/stalled payload; scanned field unrecoverable — refused fail-closed.' >&2
65
+ exit 2
66
+ }
67
+ UKIT_SALVAGED_EVENT="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "hook_event_name")" || __ukit_salvage_fail
68
+ UKIT_SALVAGED_TOOL_NAME=""
69
+ UKIT_SALVAGED_FIELD_NAME=""
70
+ UKIT_SALVAGED_FIELD_VALUE=""
71
+ case "$UKIT_SALVAGED_EVENT" in
72
+ UserPromptSubmit)
73
+ UKIT_SALVAGED_FIELD_NAME="prompt"
74
+ UKIT_SALVAGED_FIELD_VALUE="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "prompt")" || __ukit_salvage_fail
75
+ ;;
76
+ PreToolUse)
77
+ UKIT_SALVAGED_TOOL_NAME="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_name")" || __ukit_salvage_fail
78
+ case "$UKIT_SALVAGED_TOOL_NAME" in
79
+ Read) UKIT_SALVAGED_FIELD_NAME="file_path" ;;
80
+ Grep) UKIT_SALVAGED_FIELD_NAME="path" ;;
81
+ Bash) UKIT_SALVAGED_FIELD_NAME="command" ;;
82
+ *) __ukit_salvage_fail ;;
83
+ esac
84
+ UKIT_SALVAGED_FIELD_VALUE="$(ukit_salvage_tool_field "$UKIT_INPUT_FILE" "tool_input.$UKIT_SALVAGED_FIELD_NAME")" || __ukit_salvage_fail
85
+ ;;
86
+ *) __ukit_salvage_fail ;;
87
+ esac
88
+ UKIT_SALVAGE_ACTIVE=1
89
+ fi
54
90
  else
55
91
  # Runtime helper missing (pre-install tree): the SAME bounded staging,
56
92
  # inline - `cat >/dev/null` used to block forever on a producer that never
@@ -90,7 +126,7 @@ else
90
126
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
91
127
  fi
92
128
 
93
- INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_PATH="$SCRIPT_PATH" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node <<'NODE'
129
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_PATH="$SCRIPT_PATH" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" UKIT_SALVAGE_ACTIVE="${UKIT_SALVAGE_ACTIVE:-0}" UKIT_SALVAGED_EVENT="${UKIT_SALVAGED_EVENT:-}" UKIT_SALVAGED_TOOL_NAME="${UKIT_SALVAGED_TOOL_NAME:-}" UKIT_SALVAGED_FIELD_NAME="${UKIT_SALVAGED_FIELD_NAME:-}" UKIT_SALVAGED_FIELD_VALUE="${UKIT_SALVAGED_FIELD_VALUE:-}" node <<'NODE'
94
130
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
95
131
  // BUG-C21-03 (chosen posture: FAIL-CLOSED, parity with the staging refusal):
96
132
  // a wedged scan must never pass an uninspected payload. The deadline emits a
@@ -127,6 +163,22 @@ const payload = (() => {
127
163
  }
128
164
  })();
129
165
 
166
+ // TASK-003: on the degraded path the staged prefix cannot parse, so the shell
167
+ // handed us the provably-COMPLETE salvaged fields — reconstruct just enough
168
+ // payload for the normal channel scan below.
169
+ if (process.env.UKIT_SALVAGE_ACTIVE === '1' && process.env.UKIT_SALVAGED_EVENT) {
170
+ payload.hook_event_name = process.env.UKIT_SALVAGED_EVENT;
171
+ if (process.env.UKIT_SALVAGED_EVENT === 'PreToolUse' && process.env.UKIT_SALVAGED_TOOL_NAME) {
172
+ payload.tool_name = process.env.UKIT_SALVAGED_TOOL_NAME;
173
+ payload.tool_input = {};
174
+ if (process.env.UKIT_SALVAGED_FIELD_NAME) {
175
+ payload.tool_input[process.env.UKIT_SALVAGED_FIELD_NAME] = process.env.UKIT_SALVAGED_FIELD_VALUE;
176
+ }
177
+ } else if (process.env.UKIT_SALVAGED_EVENT === 'UserPromptSubmit') {
178
+ payload.prompt = process.env.UKIT_SALVAGED_FIELD_VALUE;
179
+ }
180
+ }
181
+
130
182
  const projectRoot = process.env.PROJECT_ROOT || process.cwd();
131
183
 
132
184
  async function readJsonSafe(filePath, fallback) {