Files
gstack/careful/bin/hook-extract.sh
510dd07d6b v1.92.9.0 fix: /freeze checks where an edit really lands, so new-folder paths can't escape it (#3623) (#3758)
* fix(freeze): resolve the full path before the boundary check, fail closed when it can't (#3623)

check-freeze.sh canonicalized only an existing parent directory and compared
the raw string otherwise, so any path through a directory that did not exist
yet kept its literal in-boundary prefix: '<b>/../outside/new/x',
'<b>/link-out/new/x', '<b>/..', '~/.zshrc' with cwd inside, and
'C:\proj\src\..\..\other\new\x' were all allowed.

_resolve_path now walks the path one component at a time, follows symlinks at
any depth, and requires both the OS reading and a lexical normalizer's reading
of '..' to land inside. Paths it cannot place deny with a reason: symlink
loops and chains over 40 links, line breaks, Windows drive- and root-relative
paths, names Windows trims, odd UNC roots. Windows rules keep '..' at the drive
or share root. The shared extractor keeps trailing newlines and maps lone
surrogates on path fields only; /careful's command values are unchanged.

Co-authored-by: Quinn O'Donnell <90010678+Quinnod345@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(freeze): deny paths over PATH_MAX before the quadratic walk (#3623)

A PreToolUse hook that outlives the host's timeout does not block the edit,
and the component walk is quadratic in path length. Deny a file_path over
4096 characters, and links that grow the walk past 8192, before walking.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(freeze): bound the total length of followed link targets (#3623)

The PATH_MAX cap bounded only the file_path text; each link hop could add
up to 8192 characters again, and a padded 40-link chain kept the quadratic
walk busy for 104s. Count every target against one 8192-character budget.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(freeze): cheap link-chain budget test; name the path in the budget reason (#3623)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): /freeze boundary escape fix (#3623)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(freeze): describe full-path resolution and fail-closed cases (#3623)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* freeze: deny Windows link targets relative to a drive or its root (#3623)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* freeze: read a leading // as / on POSIX hosts, never as a UNC prefix (#3623)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* freeze: compare Git Bash paths by cygpath's drive spelling (#3623)

Git Bash mounts %TEMP% at /tmp and converts HOME through its mounts, so
'~/x' and 'D:\...\x' named one file in two spellings and the hook denied
a legit edit (windows-free-shard 2 on #3758).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* freeze: name the edited path as given in the deny, with where it lands (#3623)

cygpath's drive spelling made a Windows deny print /c/Program Files/Git/etc/hosts
for /etc/hosts (windows-free-shard 2 on #3758, 'Edit denials name the tool and
file_path too').

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Quinn O'Donnell <90010678+Quinnod345@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 02:06:25 +00:00

202 lines
11 KiB
Bash

#!/usr/bin/env bash
# hook-extract.sh — SHARED JSON and path helpers for gstack PreToolUse hooks.
# Sourced (never executed) by careful/bin/check-careful.sh and
# freeze/bin/check-freeze.sh via a path relative to each hook script.
#
# ONE copy on purpose. These two hooks previously carried separate extractor
# copies; the escaped-quote truncation bug got fixed in careful's copy while
# freeze silently kept the broken one. Any future parsing fix lands here and
# reaches both hooks by construction.
# gstack_hook_extract_field PAYLOAD FIELD
# Prints tool_input.FIELD when PAYLOAD is valid JSON and the field is a
# string ("" when absent or non-string). Returns 1 when no parser is
# available or the payload is not parseable JSON — the CALLER decides the
# polarity for that case (careful asks, freeze denies).
#
# python3 is tried first because it ships with macOS and most Linux distros
# and is reliably on PATH in a hook environment; node is the fallback.
gstack_hook_extract_field() {
_ghef_payload="$1"
_ghef_field="$2"
if command -v python3 >/dev/null 2>&1; then
printf '%s' "$_ghef_payload" | python3 -c 'import sys,json
field = sys.argv[1]
d = json.loads(sys.stdin.read())
c = d.get("tool_input", {}).get(field, "")
sys.stdout.write(c if isinstance(c, str) else "")' "$_ghef_field" 2>/dev/null && return 0
fi
if command -v node >/dev/null 2>&1; then
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
fi
return 1
}
# gstack_hook_extract_tool PAYLOAD FIELD [FIELD...]
# One parser call that reads the top-level tool_name together with the first
# non-empty string among tool_input.FIELD..., so tool dispatch costs no
# extra fork on the per-command hot path. Sets three globals (stdout stays
# the hook's decision channel):
# GSTACK_HOOK_TOOL tool_name ("" when absent or non-string)
# GSTACK_HOOK_FIELD the FIELD that supplied the value ("" when none did)
# GSTACK_HOOK_VALUE that field's string value; trailing newlines are
# dropped from a command only (the shape
# $(gstack_hook_extract_field ...) gave). A path keeps
# them: 'src\n' is a sibling of 'src', and dropping the
# newline let freeze read it as the boundary (#3623).
# Returns 1 when no parser is available or the payload is not parseable
# JSON; the caller decides the polarity, exactly as for the field extractor.
# Python reads and writes bytes: Windows text-mode stdout turns every "\n"
# into "\r\n", which left tool_name as "PowerShell\r" and never matched.
# Payload shapes (Claude Code hooks reference, PreToolUse input): Bash and
# PowerShell carry tool_input.command; Edit and Write carry file_path;
# NotebookEdit carries notebook_path.
GSTACK_HOOK_TOOL=""
GSTACK_HOOK_FIELD=""
GSTACK_HOOK_VALUE=""
gstack_hook_extract_tool() {
_ghet_payload="$1"
shift
_ghet_out=""
_ghet_ok=1
if command -v python3 >/dev/null 2>&1; then
_ghet_out=$(printf '%s' "$_ghet_payload" | python3 -c 'import sys,json,re
d = json.loads(sys.stdin.buffer.read())
t = d.get("tool_name", "") if isinstance(d, dict) else ""
i = d.get("tool_input", {}) if isinstance(d, dict) else {}
i = i if isinstance(i, dict) else {}
f = v = ""
for k in sys.argv[1:]:
c = i.get(k, "")
if isinstance(c, str) and c:
f, v = k, c
break
sys.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
fi
if [ "$_ghet_ok" -ne 0 ] && command -v node >/dev/null 2>&1; then
_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
fi
[ "$_ghet_ok" -eq 0 ] || return 1
_ghet_out="${_ghet_out%.}"
GSTACK_HOOK_TOOL="${_ghet_out%%$'\n'*}"
_ghet_out="${_ghet_out#*$'\n'}"
GSTACK_HOOK_FIELD="${_ghet_out%%$'\n'*}"
GSTACK_HOOK_VALUE="${_ghet_out#*$'\n'}"
return 0
}
# Windows bash (Git Bash / MSYS / Cygwin), read from bash's own OSTYPE so the
# check never costs a fork: careful sources this file on every Bash call.
case "${OSTYPE:-}" in
msys*|cygwin*|win32*) GSTACK_HOOK_IS_WINDOWS=1 ;;
*) GSTACK_HOOK_IS_WINDOWS=0 ;;
esac
_GSTACK_HOOK_AZ_UPPER=ABCDEFGHIJKLMNOPQRSTUVWXYZ
_GSTACK_HOOK_AZ_LOWER=abcdefghijklmnopqrstuvwxyz
GSTACK_HOOK_PATH=""
# gstack_hook_normalize_path PATH
# The one path canonicalization for freeze's boundary AND the tool's
# file_path (#2876). Leaves the result in GSTACK_HOOK_PATH (stdout is the
# hook's decision channel, and $(...) would cost a fork):
# C:\dev\x c:/dev/x -> /c/dev/x (separators, drive-letter case)
# \\srv\share\x -> //srv/share/x (UNC: the leading // is kept)
# /cygdrive/c/x -> /c/x (Windows bash only)
# Drive-letter and UNC shapes are recognised lexically on every platform.
# A path without those shapes is rewritten only on Windows bash: on POSIX
# '\' is a legal filename character, and rewriting it would let a name like
# 'a\..\..\etc\x' change which directory the check sees. Remaining case
# differences on Windows are handled by comparing under nocasematch.
# Builtins only, bash 3.2 compatible.
gstack_hook_normalize_path() {
GSTACK_HOOK_PATH="$1"
case "$GSTACK_HOOK_PATH" in
[A-Za-z]:[\\/]*|[A-Za-z]:|\\\\[!\\/]*) ;;
*) [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ] || return 0 ;;
esac
GSTACK_HOOK_PATH="${GSTACK_HOOK_PATH//\\//}"
case "$GSTACK_HOOK_PATH" in
[A-Za-z]:/*|[A-Za-z]:)
_ghnp_d="${GSTACK_HOOK_PATH%%:*}"
_ghnp_pre="${_GSTACK_HOOK_AZ_UPPER%%"$_ghnp_d"*}"
[ "$_ghnp_pre" = "$_GSTACK_HOOK_AZ_UPPER" ] || _ghnp_d="${_GSTACK_HOOK_AZ_LOWER:${#_ghnp_pre}:1}"
GSTACK_HOOK_PATH="/$_ghnp_d${GSTACK_HOOK_PATH#?:}"
;;
/cygdrive/[A-Za-z]/*|/cygdrive/[A-Za-z]) GSTACK_HOOK_PATH="${GSTACK_HOOK_PATH#/cygdrive}" ;;
esac
return 0
}
# gstack_hook_json_string TEXT
# Prints TEXT as a JSON string literal (surrounding quotes included),
# encoding quotes, backslashes, control characters and newlines. Never build
# hook JSON with printf/sed interpolation: a path containing a quote or a
# newline produces malformed JSON, and Claude Code silently ignores the
# whole decision — a deny that no-ops exactly when it matters.
gstack_hook_json_string() {
_ghjs_text="$1"
if command -v python3 >/dev/null 2>&1; then
printf '%s' "$_ghjs_text" | python3 -c 'import sys,json; sys.stdout.write(json.dumps(sys.stdin.read()))' 2>/dev/null && return 0
fi
if command -v node >/dev/null 2>&1; then
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
fi
# Last-resort fallback (no parser on PATH): strip to a safe charset so the
# envelope stays valid JSON even if the message loses characters.
printf '"%s"' "$(printf '%s' "$_ghjs_text" | tr -cd 'a-zA-Z0-9 ._/:@=+-' )"
}
# gstack_hook_decision DECISION REASON
# Emits the full PreToolUse hookSpecificOutput envelope with REASON safely
# JSON-encoded. DECISION is "ask" or "deny". The decision MUST be nested
# under hookSpecificOutput — Claude Code ignores a top-level
# permissionDecision, which silently no-ops the block.
gstack_hook_decision() {
_ghd_decision="$1"
_ghd_reason="$2"
_ghd_encoded=$(gstack_hook_json_string "$_ghd_reason")
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":%s}}\n' "$_ghd_decision" "$_ghd_encoded"
}
# gstack_hook_state_root
# Print the gstack state root: a thin wrapper over gstack_state_root from
# bin/gstack-state-root.sh, the one bash implementation of the chain
# bin/gstack-paths uses (docs/state-root.md). Hooks run on every Edit/Bash
# call, so the twin is pure bash — never spawn gstack-paths from a hook. The
# writers (/freeze, /guard, /unfreeze, /investigate) resolve through
# gstack-paths; a reader that used a different chain failed OPEN whenever
# GSTACK_HOME was set (#1459). test/hook-scripts.test.ts pins parity.
# Printed WITHOUT a trailing newline: callers capture with a sentinel
# (`r="$(gstack_hook_state_root; printf x)"; r="${r%x}"`) so a root that
# itself ends in a newline round-trips exactly as gstack-paths' %q does.
# When the twin is missing (partial upgrade) the wrapper is removed, so
# callers take their own fallback: careful asks, freeze fails closed.
gstack_hook_state_root() {
gstack_state_root
}
_ghsr_twin="${BASH_SOURCE[0]%[/\\]*}/../../bin/gstack-state-root.sh"
if ! { [ -f "$_ghsr_twin" ] && . "$_ghsr_twin" 2>/dev/null; } || ! command -v gstack_state_root >/dev/null 2>&1; then
unset -f gstack_hook_state_root
fi
# gstack_hook_log_fire SKILL PATTERN
# Append a hook_fire analytics record (pattern name only, never command
# content) under the resolved state root, the same root every other
# analytics writer and reader (gstack-skill-start, gstack-retro-metrics,
# gstack-analytics) uses, so the usage log stays one file and tests never
# pollute the operator's real analytics file. Best-effort: failures (or a
# missing twin) never affect the hook decision.
gstack_hook_log_fire() {
command -v gstack_state_root >/dev/null 2>&1 || return 0
_ghlf_dir="$(gstack_state_root; printf x)"; _ghlf_dir="${_ghlf_dir%x}/analytics"
mkdir -p "$_ghlf_dir" 2>/dev/null || true
# Fields are JSON-encoded (a repo basename can carry quotes/backslashes) —
# same rule this file states for decisions: never raw-interpolate into JSON.
_ghlf_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")
printf '{"event":"hook_fire","skill":%s,"pattern":%s,"ts":"%s","repo":%s}\n' \
"$(gstack_hook_json_string "$1")" \
"$(gstack_hook_json_string "$2")" \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"$(gstack_hook_json_string "$_ghlf_repo")" >> "$_ghlf_dir/skill-usage.jsonl" 2>/dev/null || true
}