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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-08 07:04:10 +00:00
1 parent e2e725ad3d
commit cf80181721
2 files changed
+21 -3

No files matched your search

+5 -3
View File
@@ -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
+16
View File
@@ -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();