From d419ba18ec9e533d9484b06d36c6828aba4c273b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:34:36 +0000 Subject: [PATCH] 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: '/../outside/new/x', '/link-out/new/x', '/..', '~/.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> --- careful/bin/hook-extract.sh | 15 +- freeze/bin/check-freeze.sh | 241 +++++++++++++++++++++++++------- test/hook-scripts.test.ts | 271 ++++++++++++++++++++++++++++++++++++ 3 files changed, 469 insertions(+), 58 deletions(-) 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'); + }); + }); + }); }); // ============================================================