diff --git a/careful/bin/hook-extract.sh b/careful/bin/hook-extract.sh index 3b892af23..1bab63457 100644 --- a/careful/bin/hook-extract.sh +++ b/careful/bin/hook-extract.sh @@ -39,8 +39,11 @@ sys.stdout.write(c if isinstance(c, str) else "")' "$_ghef_field" 2>/dev/null && # 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 dropped -# (the same shape $(gstack_hook_extract_field ...) gave) +# 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" @@ -57,7 +60,7 @@ gstack_hook_extract_tool() { _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 + _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 {} @@ -68,10 +71,10 @@ for k in sys.argv[1:]: 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") + ".").encode("utf-8", "surrogatepass"))' "$@" 2>/dev/null) && _ghet_ok=0 +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"+v.replace(/\n+$/,"")+".")})' "$@" 2>/dev/null) && _ghet_ok=0 + _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%.}" @@ -108,7 +111,7 @@ GSTACK_HOOK_PATH="" gstack_hook_normalize_path() { GSTACK_HOOK_PATH="$1" case "$GSTACK_HOOK_PATH" in - [A-Za-z]:[\\/]*|[A-Za-z]:|\\\\[!\\]*) ;; + [A-Za-z]:[\\/]*|[A-Za-z]:|\\\\[!\\/]*) ;; *) [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ] || return 0 ;; esac GSTACK_HOOK_PATH="${GSTACK_HOOK_PATH//\\//}" diff --git a/freeze/bin/check-freeze.sh b/freeze/bin/check-freeze.sh index 30d13502b..c0d2d821e 100755 --- a/freeze/bin/check-freeze.sh +++ b/freeze/bin/check-freeze.sh @@ -94,9 +94,32 @@ if [ -z "$FREEZE_DIR" ]; then exit 0 fi +# A deny for a path whose landing spot cannot be worked out: never fall back +# to comparing the raw string (that fallback was the fail-open in #3623). +_freeze_unresolved() { + gstack_hook_log_fire freeze boundary_deny + gstack_hook_decision deny "[freeze] Blocked: cannot tell where $1 lands ($2), so the freeze boundary cannot be checked (fail closed). Use a plain absolute path, or run /unfreeze to remove the boundary." + _FREEZE_DECIDED=1 + exit 0 +} + +# Windows path rules apply on Windows bash and to drive-letter or UNC paths +# on any platform (the normalizer rewrites those everywhere): there '..' +# never climbs above X:\ or \\server\share, and names lose trailing dots +# and spaces. +_freeze_win_shaped() { + [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ] && return 0 + case "$1" in + [A-Za-z]:[\\/]*|[A-Za-z]:|\\\\[!\\/]*) return 0 ;; + esac + return 1 +} + # Both sides of the comparison go through one canonicalization: the state # file may hold C:\dev\proj\ or the /c/dev/proj/ that `pwd` prints in Git # Bash, and Claude Code on Windows reports file_path as C:\... (#2876). +FREEZE_DIR_WIN=0 +if _freeze_win_shaped "$FREEZE_DIR"; then FREEZE_DIR_WIN=1; fi gstack_hook_normalize_path "$FREEZE_DIR" FREEZE_DIR="$GSTACK_HOOK_PATH" @@ -135,76 +158,190 @@ if [ -z "$FILE_PATH" ]; then exit 0 fi +# bash 3.2 expands $'...' only outside double quotes, so keep these in +# variables for use inside parameter expansions. +_FREEZE_NL=$'\n' +_FREEZE_CR=$'\r' + +# No real file name holds a line break, and command substitution would drop +# a trailing one ('\n' is a sibling of the boundary, not the boundary). +case "$FILE_PATH" in + *"$_FREEZE_NL"*|*"$_FREEZE_CR"*) _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "a line break in the path" ;; +esac + +# A leading ~ is the home directory to the host's file tools, never a +# directory named '~' under cwd: joined onto cwd, '~/.zshrc' passed whenever +# cwd sat inside the boundary (#3623). Windows bash also writes '~\'. +case "$FILE_PATH" in + "~") FILE_PATH="$HOME" ;; + "~/"*) FILE_PATH="$HOME/${FILE_PATH#\~/}" ;; + "~\\"*) if [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ]; then FILE_PATH="$HOME/${FILE_PATH#\~?}"; fi ;; +esac + +# 'C:', 'C:x' and 'C:..\x' are relative to drive C's own working directory, and +# '\c\dev' to the current drive's root; this hook sees neither, and the +# normalizer would read them as cwd-relative or as drive C. +if [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ]; then + case "$FILE_PATH" in + [A-Za-z]:|[A-Za-z]:[!\\/]*) _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "a drive-relative path" ;; + \\[!\\]*) _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "a path relative to the current drive's root" ;; + esac +fi + +FILE_PATH_WIN=0 +if _freeze_win_shaped "$FILE_PATH"; then FILE_PATH_WIN=1; fi # Canonicalize before the absolute test: a drive-letter path does not start # with '/', so it used to be joined onto cwd and every edit was denied. gstack_hook_normalize_path "$FILE_PATH" FILE_PATH="$GSTACK_HOOK_PATH" -# Resolve file_path to absolute if it isn't already +# Resolve file_path to absolute if it isn't already, against the physical +# cwd the host process has (a logical $PWD through a symlink differs, and +# command substitution would drop a trailing newline from either). case "$FILE_PATH" in /*) ;; # already absolute *) - FILE_PATH="$(pwd)/$FILE_PATH" + _FREEZE_PWD=$(pwd -P && printf x) || _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "the working directory cannot be read" + _FREEZE_PWD="${_FREEZE_PWD%"$_FREEZE_NL"x}" + case "$_FREEZE_PWD" in + *"$_FREEZE_NL"*|*"$_FREEZE_CR"*) _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "a line break in the working directory" ;; + esac + FILE_PATH="$_FREEZE_PWD/$FILE_PATH" ;; esac -# Normalize: remove double slashes and trailing slash. A leading '//' is a UNC -# host (//server/share) and is kept, or the check would compare a local path. -_FP_LEAD="" -case "$FILE_PATH" in - //[!/]*) _FP_LEAD="/" ;; -esac -FILE_PATH=$(printf '%s' "$FILE_PATH" | sed 's|/\+|/|g;s|/$||') -FILE_PATH="$_FP_LEAD$FILE_PATH" -[ -n "$FILE_PATH" ] || FILE_PATH="/" - -# Resolve symlinks and .. sequences (POSIX-portable, works on macOS). -# The FULL path is resolved, including the FINAL component: the previous -# version resolved only the parent directory, so an in-boundary symlink -# pointing at an out-of-boundary target sailed through the check while the -# actual write landed outside the boundary. A final component that is a -# symlink is followed (bounded, cycle-safe) so the TARGET gets checked; a -# final component that does not exist yet (new file) has nothing to follow -# and parent resolution is the correct behavior. +# Resolve an absolute path to where a write would land (bash 3.2 builtins +# plus readlink). Components are walked left to right: empty and '.' drop, +# '..' pops, and a symlink at ANY depth is replaced by its target, even when +# the rest of the path does not exist yet. The old resolver followed only the +# final component and ran 'cd && pwd -P' on the immediate parent, comparing +# the raw string when that parent did not exist, so '/../x/new/f', +# '/link-out/new/f' and '/..' all passed (#3623). The walk also replaces +# a per-line sed squeeze that let a newline split '..' off a name. +# $2 lexical: apply '..' to the text and follow no symlink, the reading of a +# path normalizer such as Node's path.resolve. +# $3 1: Windows rules ('..' stops at /c or //server/share; link targets +# use Windows syntax; a name ending in a dot or space, which Windows +# trims, three leading slashes, and a UNC server or share of '', '.', +# '..' or '?' cannot be placed). Without it a target like 'C:\..' is one +# POSIX file name. On a POSIX host a Windows path names no local file, +# so it is resolved as text and no local symlink is followed. +# Returns 1 for a symlink loop, a chain over 40 links, an unreadable or +# line-broken link, or an unplaceable Windows name; the caller fails closed. _resolve_path() { - local _p="$1" _dir _base _tgt _i=0 - while [ -L "$_p" ] && [ "$_i" -lt 40 ]; do - _tgt=$(readlink "$_p" 2>/dev/null) || break - case "$_tgt" in - /*) _p="$_tgt" ;; - *) _p="$(dirname "$_p")/$_tgt" ;; + local _rem="$1" _win="${3:-0}" _lead="" _cur="" _n=0 _min=0 _reroot=1 + local _part _tgt _p _tail="" _hops=0 _probe=1 + if [ "$_win" = 1 ] && [ "$GSTACK_HOOK_IS_WINDOWS" != 1 ]; then _probe=0; fi + while :; do + if [ "$_reroot" = 1 ]; then + _reroot=0; _lead=""; _cur=""; _n=0; _min=0 + case "$_rem" in + ///*) + if [ "$_win" = 1 ]; then return 1; fi + while [ "${_rem#/}" != "$_rem" ]; do _rem="${_rem#/}"; done + ;; + //[!/]*) + _lead="/"; _rem="${_rem#//}" + if [ "$_win" = 1 ]; then _min=2; fi + ;; + *) + while [ "${_rem#/}" != "$_rem" ]; do _rem="${_rem#/}"; done + if [ "$_win" = 1 ]; then + case "$_rem" in [A-Za-z]|[A-Za-z]/*) _min=1 ;; esac + fi + ;; + esac + fi + [ -n "$_rem" ] || break + _part="${_rem%%/*}" + case "$_rem" in */*) _rem="${_rem#*/}" ;; *) _rem="" ;; esac + if [ "$_win" = 1 ] && [ -n "$_lead" ] && [ "$_n" -lt 2 ]; then + case "$_part" in ''|.|..|'?') return 1 ;; esac + fi + case "$_part" in + ''|.) continue ;; + ..) + if [ "$_n" -gt "$_min" ]; then _cur="${_cur%/*}"; _n=$((_n - 1)); fi + continue + ;; esac - _i=$((_i + 1)) + if [ "$_win" = 1 ]; then + case "$_part" in *[.\ ]) return 1 ;; esac + fi + if [ "${2:-}" = lexical ] || [ "$_probe" = 0 ] || [ ! -L "$_lead$_cur/$_part" ]; then + _cur="$_cur/$_part"; _n=$((_n + 1)) + continue + fi + _hops=$((_hops + 1)) + [ "$_hops" -le 40 ] || return 1 + _tgt=$(readlink "$_lead$_cur/$_part" && printf x) || return 1 + _tgt="${_tgt%x}"; _tgt="${_tgt%"$_FREEZE_NL"}" + case "$_tgt" in ''|*"$_FREEZE_NL"*|*"$_FREEZE_CR"*) return 1 ;; esac + if [ "$_win" = 1 ]; then + gstack_hook_normalize_path "$_tgt" + _tgt="$GSTACK_HOOK_PATH" + fi + case "$_tgt" in /*) _reroot=1 ;; esac + _rem="${_tgt%/}${_rem:+/$_rem}" done - _dir="$(dirname "$_p")" - _base="$(basename "$_p")" - if [ "$_base" = / ]; then printf '/'; return; fi - _dir="$(cd "$_dir" 2>/dev/null && pwd -P || printf '%s' "$_dir")" - # pwd -P answers in the platform's own dialect (/cygdrive/c/... on Cygwin). - gstack_hook_normalize_path "${_dir%/}/$_base" + _p="$_lead$_cur" + if [ "${2:-}" != lexical ] && [ "$_probe" = 1 ]; then + # pwd -P on the deepest existing directory answers in the platform's own + # dialect (/cygdrive/c/... on Cygwin) and sees links -L misses there. + while [ -n "$_cur" ] && [ ! -d "$_lead$_cur" ]; do + _tail="/${_cur##*/}$_tail" + _cur="${_cur%/*}" + done + if [ -n "$_cur" ]; then + if _p=$(cd "$_lead$_cur" 2>/dev/null && pwd -P && printf x); then + _p="${_p%"$_FREEZE_NL"x}" + case "$_p" in *"$_FREEZE_NL"*|*"$_FREEZE_CR"*) return 1 ;; esac + else + _p="$_lead$_cur" + fi + _p="${_p%/}$_tail" + else + _p="$_lead$_tail" + fi + fi + [ -n "$_p" ] || _p="/" + gstack_hook_normalize_path "$_p" printf '%s' "$GSTACK_HOOK_PATH" } -FILE_PATH=$(_resolve_path "$FILE_PATH") -FREEZE_DIR=$(_resolve_path "$FREEZE_DIR") + +_FREEZE_WHY="a symlink loop, a chain over 40 links, an unreadable link, or a Windows name that cannot be placed" +FREEZE_DIR_RAW="$FREEZE_DIR" +FREEZE_DIR=$(_resolve_path "$FREEZE_DIR_RAW" physical "$FREEZE_DIR_WIN") || _freeze_unresolved "the freeze boundary $FREEZE_DIR_RAW" "$_FREEZE_WHY" +# Hosts disagree on '/..': the OS means the target's parent, a lexical +# normalizer means the link's parent. The edit is inside only when both +# readings land inside the boundary. +FILE_PATH_RAW="$FILE_PATH" +FILE_PATH=$(_resolve_path "$FILE_PATH_RAW" physical "$FILE_PATH_WIN") || _freeze_unresolved "$TOOL_LABEL $FILE_PATH_RAW" "$_FREEZE_WHY" +FILE_PATH_LEXICAL=$(_resolve_path "$FILE_PATH_RAW" lexical "$FILE_PATH_WIN") || _freeze_unresolved "$TOOL_LABEL $FILE_PATH_RAW" "$_FREEZE_WHY" +FILE_PATH_LEXICAL=$(_resolve_path "$FILE_PATH_LEXICAL" physical "$FILE_PATH_WIN") || _freeze_unresolved "$TOOL_LABEL $FILE_PATH_RAW" "$_FREEZE_WHY" # Check: does the file path start with the freeze directory? Windows paths # are case-insensitive, so compare that way there. [ "$GSTACK_HOOK_IS_WINDOWS" = 1 ] && shopt -s nocasematch -case "$FILE_PATH" in - "${FREEZE_DIR%/}/"*|"${FREEZE_DIR}") - # Inside freeze boundary — allow - _FREEZE_DECIDED=1 - echo '{}' - ;; - *) - # Outside freeze boundary — deny - # Log hook fire event (shared helper respects GSTACK_HOME) - gstack_hook_log_fire freeze boundary_deny +_freeze_inside() { + case "$1" in + "${FREEZE_DIR%/}/"*|"${FREEZE_DIR}") return 0 ;; + esac + return 1 +} +if _freeze_inside "$FILE_PATH" && _freeze_inside "$FILE_PATH_LEXICAL"; then + # Inside freeze boundary — allow + _FREEZE_DECIDED=1 + echo '{}' +else + # Outside freeze boundary — deny + # Log hook fire event (shared helper respects GSTACK_HOME) + gstack_hook_log_fire freeze boundary_deny + if _freeze_inside "$FILE_PATH"; then FILE_PATH="$FILE_PATH_LEXICAL"; fi - # The reason is JSON-encoded by the shared helper. Never interpolate paths - # into hand-built JSON: a path containing a quote or newline produced - # malformed JSON here, and the deny silently no-oped. - gstack_hook_decision deny "[freeze] Blocked: $TOOL_LABEL $FILE_PATH is outside the freeze boundary ($FREEZE_DIR). Only edits within the frozen directory are allowed; run /unfreeze to remove the boundary." - _FREEZE_DECIDED=1 - ;; -esac + # The reason is JSON-encoded by the shared helper. Never interpolate paths + # into hand-built JSON: a path containing a quote or newline produced + # malformed JSON here, and the deny silently no-oped. + gstack_hook_decision deny "[freeze] Blocked: $TOOL_LABEL $FILE_PATH is outside the freeze boundary ($FREEZE_DIR). Only edits within the frozen directory are allowed; run /unfreeze to remove the boundary." + _FREEZE_DECIDED=1 +fi diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index 621bf5143..feb66dcc5 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -1245,6 +1245,29 @@ describe('check-careful.sh PowerShell and cmd coverage (#3067)', () => { // ============================================================ // check-freeze.sh tests // ============================================================ +// The freeze fix (#3623) made the shared extractor keep trailing newlines and +// map lone surrogates on PATH fields only; a command must come out exactly as +// before, on both extractor branches. +describe('shared extractor keeps /careful command values unchanged (#3623)', () => { + test.skipIf(process.platform === 'win32').each(['python3', 'node'])('with %s failing, a command still drops trailing newlines and nothing else', (broken) => { + const shim = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-extract-shim-')); + try { + fs.writeFileSync(path.join(shim, broken), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + const env = { ...process.env, PATH: shim + path.delimiter + (process.env.PATH ?? '') }; + const payload = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'rm -rf /var/data \n# note\n\n\n' } }); + const r = spawnSync('bash', ['-c', '. "$1" && gstack_hook_extract_tool "$2" command && printf "%s|%s|%s" "$GSTACK_HOOK_TOOL" "$GSTACK_HOOK_FIELD" "$GSTACK_HOOK_VALUE"', 'x', HOOK_EXTRACT, payload], { env, encoding: 'utf-8', timeout: 5000 }); + expect(r.status).toBe(0); + expect(r.stdout).toBe('Bash|command|rm -rf /var/data \n# note'); + + const hook = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /var/data\n\n'), { PATH: env.PATH }); + expect(hook.output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(hook.output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); + } finally { + fs.rmSync(shim, { recursive: true, force: true }); + } + }); +}); + describe('check-freeze.sh', () => { describe('edits inside freeze boundary', () => { @@ -1466,6 +1489,254 @@ describe('check-freeze.sh', () => { } }); }); + + describe('boundary escape shapes (#3623)', () => { + // The old resolver canonicalized only an EXISTING parent directory and + // otherwise compared the raw string, so any path through a directory that + // did not exist yet kept its literal in-boundary prefix: '..' segments and + // symlinks in it were never applied. Each case names the escape shape. + function decide(boundary: string, filePath: string, home: string, cwd?: string): string { + let decision = ''; + withFreezeDir(boundary + '/', (stateDir) => { + const r = runHook(FREEZE_SCRIPT, freezeInput(filePath), freezeEnv(stateDir, { HOME: home }), cwd); + expect(r.exitCode).toBe(0); + decision = r.output.hookSpecificOutput?.permissionDecision ?? (r.raw === '{}' ? 'allow' : r.raw); + }); + return decision; + } + + function withTree(fn: (t: { base: string; home: string; src: string; outside: string }) => void) { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-esc-'))); + const home = path.join(base, 'home'); + const src = path.join(home, 'proj', 'src'); + const outside = path.join(base, 'outside'); + fs.mkdirSync(src, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + try { + fn({ base, home, src, outside }); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + } + + test('dotdot through a missing directory, a trailing dotdot, tilde and cwd-relative paths are resolved before the check', () => { + withTree(({ base, home, src }) => { + expect(decide(src, src + '/../../../outside/new-dir/x.ts', home, base)).toBe('deny'); + expect(decide(src, src + '/..', home, base)).toBe('deny'); + expect(decide(src, '~/.zshrc', home, src)).toBe('deny'); + expect(decide(src, '../../new-dir/x.ts', home, src)).toBe('deny'); + + expect(decide(src, src + '/new-dir/x.ts', home, base)).toBe('allow'); + expect(decide(src, src + '/a/../b/x.ts', home, base)).toBe('allow'); + expect(decide(src, '~/proj/src/x.ts', home, base)).toBe('allow'); + expect(decide(src, 'new-dir/x.ts', home, src)).toBe('allow'); + }); + }); + + // A per-line sed squeezed slashes, so a newline split '..' off the next + // name ('/../\nfoo.ts' read as inside, landed in the parent), and the + // extractor dropped a trailing one ('\n' is a sibling of the boundary). + // No real file name holds a line break, so any path with one denies. + test('a line break in the path denies instead of hiding a dotdot or a sibling', () => { + withTree(({ base, home, src }) => { + expect(decide(src, src + '/../\nfoo.ts', home, base)).toBe('deny'); + expect(decide(src, src + '/../\n/x.ts', home, base)).toBe('deny'); + expect(decide(src, src + '\n', home, base)).toBe('deny'); + expect(decide(src, src + '/new\r/x.ts', home, base)).toBe('deny'); + }); + }); + + test('Windows separators: a C:\\ dotdot escape through a missing directory denies, a new C:\\ subdirectory allows', () => { + withTree(({ base, home }) => { + expect(decide('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\..\\..\\other\\new\\x.ts', home, base)).toBe('deny'); + expect(decide('C:\\dev\\proj\\src', 'C:/dev/proj/src/../../other/new/x.ts', home, base)).toBe('deny'); + expect(decide('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\new\\x.ts', home, base)).toBe('allow'); + }); + }); + + // Windows never climbs above X:\ or \\server\share: 'C:\..\c\dev' is + // C:\c\dev. A walker that popped the root would read it as C:\dev. + test('dotdot stops at a drive root and a UNC share root', () => { + withTree(({ base, home }) => { + expect(decide('C:\\dev\\proj\\src', 'C:\\..\\c\\dev\\proj\\src\\x.ts', home, base)).toBe('deny'); + expect(decide('\\\\server.invalid\\share\\proj', '\\\\server.invalid\\share\\..\\..\\server.invalid\\share\\proj\\x.ts', home, base)).toBe('deny'); + expect(decide('\\\\server.invalid\\share\\proj', '\\\\server.invalid\\share\\proj\\new\\x.ts', home, base)).toBe('allow'); + }); + }); + + // Windows bash rules, run on any platform by setting OSTYPE before the + // hook loads its helpers. + function decideOnWindowsBash(boundary: string, filePath: string, home: string, cwd: string): string { + let decision = ''; + withFreezeDir(boundary + '/', (stateDir) => { + const r = spawnSync('bash', ['-c', 'OSTYPE=msys; . "$0"', FREEZE_SCRIPT], { + input: JSON.stringify(freezeInput(filePath)), + env: { ...process.env, ...freezeEnv(stateDir, { HOME: home }) }, + cwd, + timeout: 5000, + }); + expect(r.status).toBe(0); + const raw = r.stdout.toString().trim(); + decision = raw === '{}' ? 'allow' : (JSON.parse(raw).hookSpecificOutput?.permissionDecision ?? raw); + }); + return decision; + } + + test('Windows bash: drive-relative paths and names Windows trims are denied, fail closed', () => { + withTree(({ base, home, src }) => { + expect(decideOnWindowsBash(src, 'C:..\\..\\x.ts', home, src)).toBe('deny'); + expect(decideOnWindowsBash(src, 'C:x.ts', home, src)).toBe('deny'); + expect(decideOnWindowsBash('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\.. \\x.ts', home, base)).toBe('deny'); + expect(decideOnWindowsBash('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\...\\x.ts', home, base)).toBe('deny'); + expect(decideOnWindowsBash('C:\\dev\\proj\\src', '\\c\\dev\\proj\\src\\x.ts', home, base)).toBe('deny'); + expect(decideOnWindowsBash('C:\\dev\\proj\\src', '\\\\\\c\\..\\c\\dev\\proj\\src\\x.ts', home, base)).toBe('deny'); + expect(decideOnWindowsBash('\\\\server.invalid\\share\\proj', '\\\\server.invalid\\..\\share\\proj\\x.ts', home, base)).toBe('deny'); + expect(decideOnWindowsBash('C:\\dev\\proj\\src', '\\\\.\\C:\\dev\\proj\\src\\x.ts', home, base)).toBe('deny'); + expect(decideOnWindowsBash('C:\\dev\\proj\\src', '///c/dev/proj/src/x.ts', home, base)).toBe('deny'); + expect(decideOnWindowsBash('C:\\dev\\proj\\src', '\\\\/c/dev/proj/src/x.ts', home, base)).toBe('deny'); + expect(decideOnWindowsBash('C:\\', 'C:', home, base)).toBe('deny'); + expect(decideOnWindowsBash('C:\\dev\\proj\\src', 'C:\\dev\\proj\\src\\new\\x.ts', home, base)).toBe('allow'); + }); + }); + + // On POSIX '\\' is a filename character, so a name that spells '..' with + // backslashes is one directory, not a way back into the boundary. + test.skipIf(process.platform === 'win32')('a POSIX backslash name that spells dotdot never counts as inside', () => { + withTree(({ base, home, src, outside }) => { + expect(decide(src, outside + '/..\\home\\proj\\src\\x.ts', home, base)).toBe('deny'); + }); + }); + + // On POSIX, '\\/...' is a relative name, not a UNC path, and a Windows- + // shaped path names no local file: neither may borrow Windows rules or + // local symlinks to read as inside while Node writes elsewhere. + test.skipIf(process.platform === 'win32')('POSIX: a backslash prefix never turns a relative escape into a UNC path inside the boundary', () => { + withTree(({ base, home, src }) => { + const bs = src.slice(1).split('/').join('\\'); + expect(decide(src, '\\\\/' + bs + '\\a\\b\\c/../../../outside/x.ts', home, src)).toBe('deny'); + + fs.mkdirSync(path.join(base, 'real', 'bnd'), { recursive: true }); + fs.symlinkSync(path.join(base, 'real'), path.join(base, 'alias')); + const aliasBnd = path.join(base, 'alias', 'bnd'); + const abs = aliasBnd.slice(1).split('/').join('\\'); + expect(decide(aliasBnd, '\\\\' + abs + '\\a\\b/../../outside/x.ts', home, path.join(base, 'real', 'bnd'))).toBe('deny'); + }); + }); + + // The host resolves a relative path against its physical cwd; a logical + // $PWD through a symlink names a different parent for '..'. + test.skipIf(process.platform === 'win32')('a relative path resolves against the physical working directory', () => { + withTree(({ base, home, src, outside }) => { + const work = path.join(outside, 'work'); + fs.mkdirSync(work); + fs.mkdirSync(path.join(src, 'a', 'b'), { recursive: true }); + fs.symlinkSync(work, path.join(src, 'work-link')); + fs.symlinkSync(path.join(src, 'a', 'b'), path.join(outside, 'hop')); + let decision = ''; + withFreezeDir(src + '/', (stateDir) => { + const r = runHook(FREEZE_SCRIPT, freezeInput('../hop/../x.ts'), freezeEnv(stateDir, { HOME: home, PWD: path.join(src, 'work-link') }), work); + decision = r.output.hookSpecificOutput?.permissionDecision ?? (r.raw === '{}' ? 'allow' : r.raw); + }); + expect(decision).toBe('deny'); + }); + }); + + // Python used to write a lone UTF-16 surrogate as WTF-8 bytes while Node + // writes U+FFFD, so the hook looked up a different name than the host + // followed. + test.skipIf(process.platform === 'win32')('a lone surrogate names the same file the host writes', () => { + withTree(({ base, home, src, outside }) => { + fs.symlinkSync(outside, path.join(src, 'a\uFFFD')); + expect(decide(src, src + '/a\ud800/x.ts', home, base)).toBe('deny'); + }); + }); + + // The extractor tries python3, then node; with both on PATH a broken + // python branch hides behind node, and CI never takes the node branch + // that Windows hosts with a python3 Store stub run. Pin each one alone. + test.skipIf(process.platform === 'win32').each(['python3', 'node'])('with %s failing, the other extractor branch still keeps a path trailing newline', (broken) => { + withTree(({ base, home, src }) => { + const shim = path.join(base, 'shim'); + fs.mkdirSync(shim); + fs.writeFileSync(path.join(shim, broken), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + const env = { PATH: shim + path.delimiter + (process.env.PATH ?? '') }; + withFreezeDir(src + '/', (stateDir) => { + const trailing = runHook(FREEZE_SCRIPT, freezeInput(src + '\n'), freezeEnv(stateDir, { HOME: home, ...env }), base); + expect(trailing.output.hookSpecificOutput?.permissionDecision).toBe('deny'); + expect(trailing.output.hookSpecificOutput?.permissionDecisionReason).toContain('line break'); + const inside = runHook(FREEZE_SCRIPT, freezeInput(src + '/x.ts'), freezeEnv(stateDir, { HOME: home, ...env }), base); + expect(inside.raw).toBe('{}'); + }); + }); + }); + + // Linux paths are case-sensitive: a case variant is another directory. + // macOS volumes usually fold case, where the same denial is a safe false + // deny that this test deliberately does not pin. + test.skipIf(process.platform !== 'linux')('a case variant of the boundary is outside it on a case-sensitive filesystem', () => { + withTree(({ base, home, src }) => { + expect(decide(src, base + '/HOME/proj/src/x.ts', home, base)).toBe('deny'); + }); + }); + + // Windows CI runners lack Developer Mode, so symlinkSync throws EPERM there. + test.skipIf(process.platform === 'win32')('symlinks at any depth are followed, even when the rest of the path does not exist', () => { + withTree(({ base, home, src, outside }) => { + fs.symlinkSync(outside, path.join(src, 'out-link')); + fs.symlinkSync('../../../outside', path.join(src, 'up-link')); + fs.symlinkSync(path.join(outside, 'not-yet'), path.join(src, 'dangle')); + fs.mkdirSync(path.join(src, 'sub')); + fs.symlinkSync(path.join(src, 'sub'), path.join(outside, 'in-link')); + + expect(decide(src, src + '/out-link/new-dir/x.ts', home, base)).toBe('deny'); + expect(decide(src, src + '/out-link/', home, base)).toBe('deny'); + expect(decide(src, src + '/up-link/new-dir/x.ts', home, base)).toBe('deny'); + expect(decide(src, src + '/dangle/x.ts', home, base)).toBe('deny'); + // '..' after a symlink means the target's parent to the OS but the + // link's parent to a lexical normalizer; both readings must be inside. + expect(decide(src, src + '/out-link/../x.ts', home, base)).toBe('deny'); + expect(decide(src, outside + '/in-link/../x.ts', home, base)).toBe('deny'); + + expect(decide(src, outside + '/in-link/new-dir/x.ts', home, base)).toBe('allow'); + + // On POSIX a link target is POSIX text: 'C:\..' + src is one file + // name beside the link, not drive C's parent. + fs.symlinkSync('C:\\..' + src, path.join(outside, 'drive-text')); + expect(decide(src, outside + '/drive-text/x.ts', home, base)).toBe('deny'); + // A link whose target ends in a newline is a sibling, not the target. + fs.symlinkSync('../src\n', path.join(home, 'proj', 'nl-link')); + expect(decide(src, path.join(home, 'proj', 'nl-link', 'x.ts'), home, base)).toBe('deny'); + fs.symlinkSync('/', path.join(src, 'root-link')); + expect(decide(src, src + '/root-link' + outside + '/x.ts', home, base)).toBe('deny'); + }); + }); + + test.skipIf(process.platform === 'win32')('a symlink that cannot be resolved (loop, chain over 40 hops) denies, fail closed', () => { + withTree(({ base, home, src }) => { + fs.symlinkSync(path.join(src, 'loop-b'), path.join(src, 'loop-a')); + fs.symlinkSync(path.join(src, 'loop-a'), path.join(src, 'loop-b')); + let prev = path.join(base, 'outside', 'not-yet'); + for (let i = 1; i <= 45; i++) { + const link = path.join(src, `chain-${i}`); + fs.symlinkSync(prev, link); + prev = link; + } + expect(decide(src, src + '/loop-a/x.ts', home, base)).toBe('deny'); + expect(decide(src, src + '/chain-45/new-dir/x.ts', home, base)).toBe('deny'); + }); + }); + + test.skipIf(process.platform === 'win32')('a boundary saved through a symlinked parent (macOS /tmp) allows a new subdirectory under either spelling', () => { + withTree(({ base, home }) => { + fs.mkdirSync(path.join(base, 'real', 'bnd'), { recursive: true }); + fs.symlinkSync(path.join(base, 'real'), path.join(base, 'alias')); + const boundary = path.join(base, 'alias', 'bnd'); + expect(decide(boundary, boundary + '/new-dir/x.ts', home, base)).toBe('allow'); + expect(decide(boundary, path.join(base, 'real', 'bnd', 'new-dir', 'x.ts'), home, base)).toBe('allow'); + expect(decide(boundary, path.join(base, 'real', 'x.ts'), home, base)).toBe('deny'); + }); + }); + }); }); // ============================================================