mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 16:38:56 +02:00
Freeze boundary hook had four verified bugs: the grep-first JSON extractor truncated at escaped quotes and failed OPEN on unparseable payloads; the deny JSON was printf-interpolated so a quote- or newline-bearing path silently no-oped the block; the freeze path read stripped INTERNAL spaces (a boundary like ~/My Project could never match); and the path resolver skipped the final component, letting an in-boundary symlink write through to an out-of-boundary target. Fixes, structurally: one shared sourced helper (careful/bin/hook-extract.sh) now owns JSON extraction and JSON-encoded decision envelopes for BOTH hooks -- the two-copy drift is how freeze kept a broken extractor after careful's was fixed. Freeze is now deny-tier fail-closed (unparseable payload denies, parsed-but-no-file_path still allows), trims only leading/trailing whitespace, and resolves symlinks through the final path component. Careful gains a HIGH tier (hard deny, simple commands only): recursive delete of /, ~, or $HOME, and force-push to the repo's default branch. Compound commands always fall through to the MEDIUM ask; --force-with-lease is never HIGH. Documented as a best-effort advisory hard-stop, not a policy boundary. Plus additive-only project patterns (~/.gstack/careful-patterns.txt + per-project file): config can only ADD warn rules, never suppress a baseline family. test/hook-scripts.test.ts: 89 tests incl. malformed-payload deny, parseable deny JSON for hostile paths, space-bearing boundaries, symlink escape, HIGH tier splits, additive invariant, invalid-regex resilience. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
65 lines
3.3 KiB
Bash
65 lines
3.3 KiB
Bash
#!/usr/bin/env bash
|
|
# hook-extract.sh — SHARED JSON 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_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"
|
|
}
|