From 3ad63ec6371e00746caf67f05617f2a21f34e36d Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 19:59:05 +0000 Subject: [PATCH] fix(freeze): deny paths over PATH_MAX before the quadratic walk (#3623) A PreToolUse hook that outlives the host's timeout does not block the edit, and the component walk is quadratic in path length. Deny a file_path over 4096 characters, and links that grow the walk past 8192, before walking. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- freeze/bin/check-freeze.sh | 10 +++++++++- test/hook-scripts.test.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/freeze/bin/check-freeze.sh b/freeze/bin/check-freeze.sh index c0d2d821e..1fb6cf6fc 100755 --- a/freeze/bin/check-freeze.sh +++ b/freeze/bin/check-freeze.sh @@ -168,6 +168,12 @@ _FREEZE_CR=$'\r' case "$FILE_PATH" in *"$_FREEZE_NL"*|*"$_FREEZE_CR"*) _freeze_unresolved "$TOOL_LABEL $FILE_PATH" "a line break in the path" ;; esac +# The walk below costs time quadratic in the path length, and a hook that +# outlives the host's timeout lets the edit through. No real path is longer +# than PATH_MAX (4096 on Linux), so bound the work before it starts. +if [ "${#FILE_PATH}" -gt 4096 ]; then + _freeze_unresolved "$TOOL_LABEL path of ${#FILE_PATH} characters" "longer than 4096 characters" +fi # A leading ~ is the home directory to the host's file tools, never a # directory named '~' under cwd: joined onto cwd, '~/.zshrc' passed whenever @@ -227,7 +233,8 @@ esac # 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. +# line-broken link, links that grow the path past 8192 characters, or an +# unplaceable Windows name; the caller fails closed. _resolve_path() { local _rem="$1" _win="${3:-0}" _lead="" _cur="" _n=0 _min=0 _reroot=1 local _part _tgt _p _tail="" _hops=0 _probe=1 @@ -283,6 +290,7 @@ _resolve_path() { fi case "$_tgt" in /*) _reroot=1 ;; esac _rem="${_tgt%/}${_rem:+/$_rem}" + [ "${#_rem}" -le 8192 ] || return 1 done _p="$_lead$_cur" if [ "${2:-}" != lexical ] && [ "$_probe" = 1 ]; then diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index feb66dcc5..c732cea68 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -1546,6 +1546,17 @@ describe('check-freeze.sh', () => { }); }); + // The walk is quadratic in path length, and a PreToolUse hook that times + // out does not block the edit; an over-long path must deny fast. + test('a path longer than PATH_MAX denies quickly instead of stalling the hook', () => { + withTree(({ base, home, src }) => { + const started = Date.now(); + expect(decide(src, src + '/a/..'.repeat(20000) + '/../outside/x.ts', home, base)).toBe('deny'); + expect(Date.now() - started).toBeLessThan(4000); + expect(decide(src, src + '/a/..'.repeat(500) + '/x.ts', home, base)).toBe('allow'); + }); + }); + 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');