From cf80181721c925d1ebb06712e8fad7e86b8a0361 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 8 Oct 2026 07:04:10 +0000 Subject: [PATCH] fix(careful): hook payload reader survives Windows text-mode Python and backslash source paths windows-free-tests (run 37739845723) showed every PowerShell-tool case falling through: Python's text-mode stdout on Windows wrote tool_name as "PowerShell\r", so dispatch never matched, and freeze denials carried stray CRs. gstack_hook_extract_tool now reads and writes bytes. hook-extract.sh also located gstack-state-root.sh with ${BASH_SOURCE%/*}, which strips nothing when the file is sourced through a C:\... path, so gstack_hook_state_root vanished (state-root parity case on the same run). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- careful/bin/hook-extract.sh | 8 +++++--- test/hook-scripts.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/careful/bin/hook-extract.sh b/careful/bin/hook-extract.sh index bfe63977b..3b892af23 100644 --- a/careful/bin/hook-extract.sh +++ b/careful/bin/hook-extract.sh @@ -43,6 +43,8 @@ sys.stdout.write(c if isinstance(c, str) else "")' "$_ghef_field" 2>/dev/null && # (the same shape $(gstack_hook_extract_field ...) gave) # 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. @@ -56,7 +58,7 @@ gstack_hook_extract_tool() { _ghet_ok=1 if command -v python3 >/dev/null 2>&1; then _ghet_out=$(printf '%s' "$_ghet_payload" | python3 -c 'import sys,json -d = json.loads(sys.stdin.read()) +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 {} @@ -66,7 +68,7 @@ for k in sys.argv[1:]: if isinstance(c, str) and c: f, v = k, c break -sys.stdout.write((t if isinstance(t, str) else "").replace("\n", " ") + "\n" + f + "\n" + v.rstrip("\n") + ".")' "$@" 2>/dev/null) && _ghet_ok=0 +sys.stdout.buffer.write(((t if isinstance(t, str) else "").replace("\n", " ") + "\n" + f + "\n" + v.rstrip("\n") + ".").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"+v.replace(/\n+$/,"")+".")})' "$@" 2>/dev/null) && _ghet_ok=0 @@ -169,7 +171,7 @@ gstack_hook_decision() { gstack_hook_state_root() { gstack_state_root } -_ghsr_twin="${BASH_SOURCE[0]%/*}/../../bin/gstack-state-root.sh" +_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 diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index f1e638e63..44ac80437 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -992,6 +992,22 @@ describe('check-careful.sh PowerShell and cmd coverage (#3067)', () => { expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); }); + test('Windows text-mode Python output (\\n written as \\r\\n) still dispatches on tool_name', () => { + // windows-free-tests run 37739845723: python3's text-mode stdout turned + // "PowerShell\n" into "PowerShell\r\n", so the PowerShell table never ran. + // A sitecustomize that rewraps stdout with newline='\r\n' reproduces it. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-crlf-')); + fs.writeFileSync(path.join(dir, 'sitecustomize.py'), + "import io, sys\nsys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', newline='\\r\\n')\n"); + try { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, psInput('Remove-Item -Recurse x'), { PYTHONPATH: dir }); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('pattern: ps_remove_item'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + test('a payload without tool_name is checked as Bash, exactly as before', () => { expect(carefulDecision(carefulInput('rm -rf /var/data')).decision).toBe('ask'); expect(carefulDecision(carefulInput('Remove-Item -Recurse x')).decision).toBeUndefined();