Skill Tracer
Skills › gstack › careful › hook-extract.sh

hook-extract.sh

201 lines · careful/bin/hook-extract.sh at 3f79d81

1#!/usr/bin/env bash
2# hook-extract.sh — SHARED JSON and path helpers for gstack PreToolUse hooks.
3# Sourced (never executed) by careful/bin/check-careful.sh and
4# freeze/bin/check-freeze.sh via a path relative to each hook script.
5#
6# ONE copy on purpose. These two hooks previously carried separate extractor
7# copies; the escaped-quote truncation bug got fixed in careful's copy while
8# freeze silently kept the broken one. Any future parsing fix lands here and
9# reaches both hooks by construction.
11# gstack_hook_extract_field PAYLOAD FIELD
12# Prints tool_input.FIELD when PAYLOAD is valid JSON and the field is a
13# string ("" when absent or non-string). Returns 1 when no parser is
14# available or the payload is not parseable JSON — the CALLER decides the
15# polarity for that case (careful asks, freeze denies).
17# python3 is tried first because it ships with macOS and most Linux distros
18# and is reliably on PATH in a hook environment; node is the fallback.
19gstack_hook_extract_field() {
20 _ghef_payload="$1"
21 _ghef_field="$2"
22 if command -v python3 >/dev/null 2>&1; then
23 printf '%s' "$_ghef_payload" | python3 -c 'import sys,json
24field = sys.argv[1]
25d = json.loads(sys.stdin.read())
26c = d.get("tool_input", {}).get(field, "")
27sys.stdout.write(c if isinstance(c, str) else "")' "$_ghef_field" 2>/dev/null && return 0
28 fi
29 if command -v node >/dev/null 2>&1; then
30 printf '%s' "$_ghef_payload" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const c=(j&&j.tool_input&&j.tool_input[process.argv[1]])||"";process.stdout.write(typeof c==="string"?c:"")}catch(e){process.exit(3)}})' "$_ghef_field" 2>/dev/null && return 0
31 fi
32 return 1
35# gstack_hook_extract_tool PAYLOAD FIELD [FIELD...]
36# One parser call that reads the top-level tool_name together with the first
37# non-empty string among tool_input.FIELD..., so tool dispatch costs no
38# extra fork on the per-command hot path. Sets three globals (stdout stays
39# the hook's decision channel):
40# GSTACK_HOOK_TOOL tool_name ("" when absent or non-string)
41# GSTACK_HOOK_FIELD the FIELD that supplied the value ("" when none did)
42# GSTACK_HOOK_VALUE that field's string value; trailing newlines are
43# dropped from a command only (the shape
44# $(gstack_hook_extract_field ...) gave). A path keeps
45# them: 'src\n' is a sibling of 'src', and dropping the
46# newline let freeze read it as the boundary (#3623).
47# Returns 1 when no parser is available or the payload is not parseable
48# JSON; the caller decides the polarity, exactly as for the field extractor.
49# Python reads and writes bytes: Windows text-mode stdout turns every "\n"
50# into "\r\n", which left tool_name as "PowerShell\r" and never matched.
51# Payload shapes (Claude Code hooks reference, PreToolUse input): Bash and
52# PowerShell carry tool_input.command; Edit and Write carry file_path;
53# NotebookEdit carries notebook_path.
54GSTACK_HOOK_TOOL=""
55GSTACK_HOOK_FIELD=""
56GSTACK_HOOK_VALUE=""
57gstack_hook_extract_tool() {
58 _ghet_payload="$1"
59 shift
60 _ghet_out=""
61 _ghet_ok=1
62 if command -v python3 >/dev/null 2>&1; then
63 _ghet_out=$(printf '%s' "$_ghet_payload" | python3 -c 'import sys,json,re
64d = json.loads(sys.stdin.buffer.read())
65t = d.get("tool_name", "") if isinstance(d, dict) else ""
66i = d.get("tool_input", {}) if isinstance(d, dict) else {}
67i = i if isinstance(i, dict) else {}
68f = v = ""
69for k in sys.argv[1:]:
70 c = i.get(k, "")
71 if isinstance(c, str) and c:
72 f, v = k, c
73 break
74sys.stdout.buffer.write(((t if isinstance(t, str) else "").replace("\n", " ") + "\n" + f + "\n" + (v.rstrip("\n") if f == "command" else re.sub("[\ud800-\udfff]", "\ufffd", v)) + ".").encode("utf-8", "surrogatepass"))' "$@" 2>/dev/null) && _ghet_ok=0
75 fi
76 if [ "$_ghet_ok" -ne 0 ] && command -v node >/dev/null 2>&1; then
77 _ghet_out=$(printf '%s' "$_ghet_payload" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j;try{j=JSON.parse(s)}catch(e){process.exit(3)}const o=j&&typeof j==="object"?j:{};const t=typeof o.tool_name==="string"?o.tool_name:"";const i=o.tool_input&&typeof o.tool_input==="object"?o.tool_input:{};let f="",v="";for(const k of process.argv.slice(1)){const c=i[k];if(typeof c==="string"&&c){f=k;v=c;break}}process.stdout.write(t.replace(/\n/g," ")+"\n"+f+"\n"+(f==="command"?v.replace(/\n+$/,""):v)+".")})' "$@" 2>/dev/null) && _ghet_ok=0
78 fi
79 [ "$_ghet_ok" -eq 0 ] || return 1
80 _ghet_out="${_ghet_out%.}"
81 GSTACK_HOOK_TOOL="${_ghet_out%%$'\n'*}"
82 _ghet_out="${_ghet_out#*$'\n'}"
83 GSTACK_HOOK_FIELD="${_ghet_out%%$'\n'*}"
84 GSTACK_HOOK_VALUE="${_ghet_out#*$'\n'}"
85 return 0
88# Windows bash (Git Bash / MSYS / Cygwin), read from bash's own OSTYPE so the
89# check never costs a fork: careful sources this file on every Bash call.
90case "${OSTYPE:-}" in
91 msys*|cygwin*|win32*) GSTACK_HOOK_IS_WINDOWS=1 ;;
92 *) GSTACK_HOOK_IS_WINDOWS=0 ;;
93esac
94_GSTACK_HOOK_AZ_UPPER=ABCDEFGHIJKLMNOPQRSTUVWXYZ
95_GSTACK_HOOK_AZ_LOWER=abcdefghijklmnopqrstuvwxyz
96GSTACK_HOOK_PATH=""
98# gstack_hook_normalize_path PATH
99# The one path canonicalization for freeze's boundary AND the tool's
100# file_path (#2876). Leaves the result in GSTACK_HOOK_PATH (stdout is the
101# hook's decision channel, and $(...) would cost a fork):
102# C:\dev\x c:/dev/x -> /c/dev/x (separators, drive-letter case)
103# \\srv\share\x -> //srv/share/x (UNC: the leading // is kept)
104# /cygdrive/c/x -> /c/x (Windows bash only)
105# Drive-letter and UNC shapes are recognised lexically on every platform.
106# A path without those shapes is rewritten only on Windows bash: on POSIX
107# '\' is a legal filename character, and rewriting it would let a name like
108# 'a\..\..\etc\x' change which directory the check sees. Remaining case
109# differences on Windows are handled by comparing under nocasematch.
110# Builtins only, bash 3.2 compatible.
111gstack_hook_normalize_path() {
112 GSTACK_HOOK_PATH="$1"
113 case "$GSTACK_HOOK_PATH" in
114 [A-Za-z]:[\\/]*|[A-Za-z]:|\\\\[!\\/]*) ;;
115 *) [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ] || return 0 ;;
116 esac
117 GSTACK_HOOK_PATH="${GSTACK_HOOK_PATH//\\//}"
118 case "$GSTACK_HOOK_PATH" in
119 [A-Za-z]:/*|[A-Za-z]:)
120 _ghnp_d="${GSTACK_HOOK_PATH%%:*}"
121 _ghnp_pre="${_GSTACK_HOOK_AZ_UPPER%%"$_ghnp_d"*}"
122 [ "$_ghnp_pre" = "$_GSTACK_HOOK_AZ_UPPER" ] || _ghnp_d="${_GSTACK_HOOK_AZ_LOWER:${#_ghnp_pre}:1}"
123 GSTACK_HOOK_PATH="/$_ghnp_d${GSTACK_HOOK_PATH#?:}"
124 ;;
125 /cygdrive/[A-Za-z]/*|/cygdrive/[A-Za-z]) GSTACK_HOOK_PATH="${GSTACK_HOOK_PATH#/cygdrive}" ;;
126 esac
127 return 0
130# gstack_hook_json_string TEXT
131# Prints TEXT as a JSON string literal (surrounding quotes included),
132# encoding quotes, backslashes, control characters and newlines. Never build
133# hook JSON with printf/sed interpolation: a path containing a quote or a
134# newline produces malformed JSON, and Claude Code silently ignores the
135# whole decision — a deny that no-ops exactly when it matters.
136gstack_hook_json_string() {
137 _ghjs_text="$1"
138 if command -v python3 >/dev/null 2>&1; then
139 printf '%s' "$_ghjs_text" | python3 -c 'import sys,json; sys.stdout.write(json.dumps(sys.stdin.read()))' 2>/dev/null && return 0
140 fi
141 if command -v node >/dev/null 2>&1; then
142 printf '%s' "$_ghjs_text" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.stringify(s)))' 2>/dev/null && return 0
143 fi
144 # Last-resort fallback (no parser on PATH): strip to a safe charset so the
145 # envelope stays valid JSON even if the message loses characters.
146 printf '"%s"' "$(printf '%s' "$_ghjs_text" | tr -cd 'a-zA-Z0-9 ._/:@=+-' )"
149# gstack_hook_decision DECISION REASON
150# Emits the full PreToolUse hookSpecificOutput envelope with REASON safely
151# JSON-encoded. DECISION is "ask" or "deny". The decision MUST be nested
152# under hookSpecificOutput — Claude Code ignores a top-level
153# permissionDecision, which silently no-ops the block.
154gstack_hook_decision() {
155 _ghd_decision="$1"
156 _ghd_reason="$2"
157 _ghd_encoded=$(gstack_hook_json_string "$_ghd_reason")
158 printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":%s}}\n' "$_ghd_decision" "$_ghd_encoded"
161# gstack_hook_state_root
162# Print the gstack state root: a thin wrapper over gstack_state_root from
163# bin/gstack-state-root.sh, the one bash implementation of the chain
164# bin/gstack-paths uses (docs/state-root.md). Hooks run on every Edit/Bash
165# call, so the twin is pure bash — never spawn gstack-paths from a hook. The
166# writers (/freeze, /guard, /unfreeze, /investigate) resolve through
167# gstack-paths; a reader that used a different chain failed OPEN whenever
168# GSTACK_HOME was set (#1459). test/hook-scripts.test.ts pins parity.
169# Printed WITHOUT a trailing newline: callers capture with a sentinel
170# (`r="$(gstack_hook_state_root; printf x)"; r="${r%x}"`) so a root that
171# itself ends in a newline round-trips exactly as gstack-paths' %q does.
172# When the twin is missing (partial upgrade) the wrapper is removed, so
173# callers take their own fallback: careful asks, freeze fails closed.
174gstack_hook_state_root() {
175 gstack_state_root
177_ghsr_twin="${BASH_SOURCE[0]%[/\\]*}/../../bin/gstack-state-root.sh"
178if ! { [ -f "$_ghsr_twin" ] && . "$_ghsr_twin" 2>/dev/null; } || ! command -v gstack_state_root >/dev/null 2>&1; then
179 unset -f gstack_hook_state_root
180fi
182# gstack_hook_log_fire SKILL PATTERN
183# Append a hook_fire analytics record (pattern name only, never command
184# content) under the resolved state root, the same root every other
185# analytics writer and reader (gstack-skill-start, gstack-retro-metrics,
186# gstack-analytics) uses, so the usage log stays one file and tests never
187# pollute the operator's real analytics file. Best-effort: failures (or a
188# missing twin) never affect the hook decision.
189gstack_hook_log_fire() {
190 command -v gstack_state_root >/dev/null 2>&1 || return 0
191 _ghlf_dir="$(gstack_state_root; printf x)"; _ghlf_dir="${_ghlf_dir%x}/analytics"
192 mkdir -p "$_ghlf_dir" 2>/dev/null || true
193 # Fields are JSON-encoded (a repo basename can carry quotes/backslashes) —
194 # same rule this file states for decisions: never raw-interpolate into JSON.
195 _ghlf_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")
196 printf '{"event":"hook_fire","skill":%s,"pattern":%s,"ts":"%s","repo":%s}\n' \
197 "$(gstack_hook_json_string "$1")" \
198 "$(gstack_hook_json_string "$2")" \
199 "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
200 "$(gstack_hook_json_string "$_ghlf_repo")" >> "$_ghlf_dir/skill-usage.jsonl" 2>/dev/null || true