diff --git a/templates/default/.ax/hooks/pre-bash/block-destructive.sh b/templates/default/.ax/hooks/pre-bash/block-destructive.sh index 6b0d9e3..0da8a0f 100755 --- a/templates/default/.ax/hooks/pre-bash/block-destructive.sh +++ b/templates/default/.ax/hooks/pre-bash/block-destructive.sh @@ -57,6 +57,20 @@ fi # 정규화된 명령에 대해 확장 정규식 매칭. matches() { printf '%s' "$NORM" | grep -qE "$1"; } +# 단순 명령(세그먼트) 단위 — `;` `&&` `||` `|` `&` `(` `)` 백틱 줄바꿈에서 잘라요. +# rm·재귀·대상 경로 세 조건은 **같은 세그먼트 안에서만** 봐요. 명령 전체에 걸어 두면 서로 다른 명령의 +# 조각이 합쳐져요 — 실측(one-tenth): `cd <스크래치패드> && rm -rf Build ...; df -h / | tail -1` 이 +# rm 과 무관한 `df -h /` 의 ` /` 때문에 CATASTROPHIC 으로 막혀 20GB 빌드 캐시를 못 지웠고 디스크가 +# 120MB 까지 찼어요. heredoc 으로 파일을 쓰는 명령도 본문 낱말(`rm -r`·` / `)로 같은 일이 났어요. +# 세그먼트마다 앞에 공백 하나를 붙여 둬서 아래 ` …` 로 시작하는 패턴이 그대로 맞아요. +# 줄 이어쓰기(`\` + 줄바꿈)는 셸이 한 명령으로 읽어요 — 먼저 붙여야 `rm -rf \⏎ /` 가 두 세그먼트로 갈라지지 않아요. +CMD_JOINED=${CMD//$'\\\n'/ } +SEGS=$(printf '%s\n' "$CMD_JOINED" | tr -d '\047\042' | tr '\t' ' ' \ + | awk '{ gsub(/&&|\|\||[;|&()`]/, "\n"); print }' \ + | sed -E 's/ +/ /g; s/^ */ /; s/ +$//' | grep -v '^ *$' || true) +SEG="" +seg_matches() { printf '%s' "$SEG" | grep -qE "$1"; } + # `rm` 이 **명령어 위치**에 있는지 (confirm·npm 같은 부분 일치 배제). # 앞이 줄머리이거나 셸 구분자([;&|(]) 또는 공백. `git rm`, `sudo rm` 도 포함돼요. RM_INVOKED='(^| |[;&|(])rm ' @@ -78,23 +92,43 @@ HOME_TARGET=' (~|~/\*|\$HOME|\$HOME/\*|\$\{HOME\}|\$\{HOME\}/\*)( |$)' # ─── 1) CATASTROPHIC — 복구 불가, mode 무관 항상 차단 ──────────────── CATASTROPHIC_HIT="" -# 1-a. 재귀 rm + 루트/시스템 경로 (두 조건 동시 충족 시에만 — false positive 최소화) -if matches "$RM_INVOKED" && matches "$RM_RECURSIVE" && matches "$ROOT_TARGET"; then - CATASTROPHIC_HIT="재귀 rm + 루트/시스템 경로" -fi +# 상대 경로 "현재 디렉토리 전부" — `cd /` 뒤의 `rm -rf *` 는 루트를 지워요. +REL_ALL=' (\*|\.|\./|\./\*|\.\*)( |$)' +CD_DANGER="" # 바로 앞 cd 가 루트·시스템·홈이었으면 그 목적지 + +while IFS= read -r SEG; do + [ -n "$SEG" ] || continue + # cd·pushd 는 다음 세그먼트의 상대 경로가 어디를 가리키는지 정해요. 다른 곳으로 옮기면 풀려요. + if seg_matches '^ (cd|pushd)( |$)'; then + DEST=$(printf '%s' "$SEG" | sed -E 's/^ (cd|pushd)//') + if [ -z "$DEST" ] || printf '%s' "$DEST" | grep -qE "^(${ROOT_TARGET}|${HOME_TARGET})"; then + CD_DANGER="${DEST# }"; CD_DANGER="${CD_DANGER:-~}" + else + CD_DANGER="" + fi + continue + fi -# 1-b. 재귀 rm + 홈 디렉토리 전체 -if [ -z "$CATASTROPHIC_HIT" ] && matches "$RM_INVOKED" && matches "$RM_RECURSIVE" && matches "$HOME_TARGET"; then - CATASTROPHIC_HIT="재귀 rm + 홈 디렉토리 전체" -fi + if seg_matches "$RM_INVOKED" && seg_matches "$RM_RECURSIVE"; then + # 1-a. 재귀 rm + 루트/시스템 경로 (같은 세그먼트에서 셋 다 — false positive 최소화) + if seg_matches "$ROOT_TARGET"; then + CATASTROPHIC_HIT="재귀 rm + 루트/시스템 경로"; break + fi + # 1-b. 재귀 rm + 홈 디렉토리 전체 + if seg_matches "$HOME_TARGET"; then + CATASTROPHIC_HIT="재귀 rm + 홈 디렉토리 전체"; break + fi + # 1-b'. 루트·시스템·홈으로 cd 한 뒤 상대 경로 전부 + if [ -n "$CD_DANGER" ] && seg_matches "$REL_ALL"; then + CATASTROPHIC_HIT="cd ${CD_DANGER} 뒤 재귀 rm (현재 디렉토리 전부)"; break + fi + fi -# 1-c. rm 이 아닌 경로로 대량 삭제 — find -delete / find -exec rm -if [ -z "$CATASTROPHIC_HIT" ] \ - && matches '(^| |[;&|(])find ' \ - && matches "$ROOT_TARGET" \ - && matches ' (-delete( |$)|-exec +rm )'; then - CATASTROPHIC_HIT="find 를 통한 루트/시스템 경로 대량 삭제" -fi + # 1-c. rm 이 아닌 경로로 대량 삭제 — find -delete / find -exec rm + if seg_matches '(^| )find ' && seg_matches "$ROOT_TARGET" && seg_matches ' (-delete( |$)|-exec +rm )'; then + CATASTROPHIC_HIT="find 를 통한 루트/시스템 경로 대량 삭제"; break + fi +done <<< "$SEGS" # 1-d. 단독으로 복구 불가한 명령들 if [ -z "$CATASTROPHIC_HIT" ]; then @@ -130,9 +164,13 @@ RECOVERABLE_HIT="" # 더 위험한 `rm -rf ../..`·`../../etc`·`../../../` 는 그냥 통과시켰어요 (위험도 역전). # 이제 `..` 로 시작하는 경로 전체를 봐요: `..`, `../*`, `../..`, `../../etc`, `../../../`, `../foo`. PARENT_TARGET=' (\.\.(/\.\.)*(/[^ ]*)?|\.\./\*)( |$)' -if matches "$RM_INVOKED" && matches "$RM_RECURSIVE" && matches "$PARENT_TARGET"; then - RECOVERABLE_HIT="재귀 rm + 상위 디렉토리(..)" -fi +# 1단계와 같이 세그먼트 안에서만 봐요 (`rm -rf build && cd ..` 는 상위 삭제가 아니에요). +while IFS= read -r SEG; do + [ -n "$SEG" ] || continue + if seg_matches "$RM_INVOKED" && seg_matches "$RM_RECURSIVE" && seg_matches "$PARENT_TARGET"; then + RECOVERABLE_HIT="재귀 rm + 상위 디렉토리(..)"; break + fi +done <<< "$SEGS" # 2-b. 히스토리/원격 되돌리기 계열 if [ -z "$RECOVERABLE_HIT" ]; then diff --git a/tests/smoke.sh b/tests/smoke.sh index 74b801c..29f3b73 100644 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -1044,8 +1044,8 @@ rm -rf "$PM_FX" # ─────────────────────────────────────────────────────────── section "15. PR #1085 review fixes — security/correctness/escape" # ─────────────────────────────────────────────────────────── -# §15/§41 분담: 여기는 git/원격/sudo 계열 경고 7종 + CATASTROPHIC 16종 + 무해 9종(무음). -# 상위 경로(..) 계열 경고 5종 · 일상 rm 3종(무음) · jq 부재는 §41 담당이에요 — 섞지 마세요. +# §15/§41 분담: 여기는 git/원격/sudo 계열 경고 7종 + CATASTROPHIC 24종 + 무해 16종(무음 · 세그먼트 범위 회귀 13종 포함). +# 상위 경로(..) 계열 경고 5종 · 일상 rm 4종(무음) · jq 부재는 §41 담당이에요 — 섞지 마세요. # 15.1 block-destructive — rm variants + git push variants # capture-mistake.sh 폐기 후 — block-destructive 가 더 이상 호출 안 함. @@ -1131,6 +1131,35 @@ assert_passed 'git rm -r --cached .intro' 'git rm -r --cached' assert_passed 'find . -name x -delete' 'find . -delete (루트 아님)' assert_passed 'grep -r / etc/hosts' 'grep -r (rm 아님)' +# 세그먼트 범위 — 세 조건(rm · 재귀 · 루트 경로)을 명령 전체가 아니라 **rm 이 든 단순 명령 하나** 안에서 봐요. +# 실측(one-tenth): 디스크가 120MB 남은 상태에서 세션 스크래치패드의 20GB 빌드 캐시를 지우려던 명령이 +# 뒤에 붙은 `df -h /` 의 ` /` 때문에 CATASTROPHIC 으로 막혔어요. heredoc 본문의 낱말에도 같은 일이 났어요. +SP=/private/tmp/claude-502/-Users-me-proj/0f1e2d3c-sess/scratchpad +assert_passed "cd $SP/ios-dd && rm -rf Build/Intermediates.noindex Index.noindex ModuleCache.noindex Logs; du -sh . ; df -h / | tail -1" \ + '실측 — 스크래치패드 빌드 캐시 삭제 + 뒤의 df -h /' +assert_passed "rm -rf $SP/ios-dd $SP/ios-dd2" '세션 스크래치패드 절대 경로' +assert_passed "rm -rf /tmp/claude-501/-p/s/scratchpad/x && ls /" '/tmp/claude-* 스크래치패드 + 뒤의 ls /' +assert_passed 'D=/private/tmp/claude-502/p/s/scratchpad/ios-dd; rm -rf $D/Build; df -h /' '변수 경로 + df -h /' +assert_passed 'cat > a.md </dev/null 2>&1 + bd_rc=$? + if [ "$bd_rc" -eq 2 ]; then pass "block-destructive 차단: 줄 이어쓰기로 나눈 $(printf '%s' "$bd_cont" | tr '\n' ' ')" + else fail "block-destructive 미차단 (exit=$bd_rc): 줄 이어쓰기로 나눈 $(printf '%s' "$bd_cont" | tr '\n' ' ')"; fi +done + rm -rf "$BD_FX" # 15.1b check-protected-paths — 경로 정규화 + 경계 매칭 + YAML 파싱 @@ -3114,7 +3143,7 @@ fi # ─────────────────────────────────────────────────────────── section "41. 설치기 · 훅 안전망 — 심링크 · 시크릿 · 상위 경로 · jq 부재 · 인계 기한 · 이벤트 키" # ─────────────────────────────────────────────────────────── -# §15/§41 분담: 여기는 상위 경로(..) 계열 경고 5종 + 일상 rm 3종(무음) + jq 부재 담당이에요. +# §15/§41 분담: 여기는 상위 경로(..) 계열 경고 5종 + 일상 rm 4종(무음) + jq 부재 담당이에요. # git/원격/sudo 계열 경고 7종 + CATASTROPHIC 16종 + 무해 9종(무음)은 §15 담당 — 섞지 마세요. # 안전망이 *조용히* 꺼진 상태가 제일 나빠요 — 아무도 모르니까요. 여기 6개는 전부 # "무경고로 통과했다" 가 회귀 내용이에요 (프로젝트 밖 쓰기 · 시크릿 9종 미탐 · @@ -3147,10 +3176,10 @@ else done [ "$parent_missed" -eq 0 ] && pass "block-destructive — 상위 경로 rm 5종 전부 경고 (../.. 포함)" || true parent_fp=0 - for pc in 'rm -rf ./dist' 'rm -rf node_modules' 'rm -rf /tmp/build-cache'; do + for pc in 'rm -rf ./dist' 'rm -rf node_modules' 'rm -rf /tmp/build-cache' 'rm -rf build && cd ..'; do [ -z "$(bd_err "$pc")" ] || { fail "block-destructive — 일상 명령 '$pc' 오탐"; parent_fp=$((parent_fp+1)); } done - [ "$parent_fp" -eq 0 ] && pass "block-destructive — 일상 rm 3종은 그대로 통과 (오탐 0)" || true + [ "$parent_fp" -eq 0 ] && pass "block-destructive — 일상 rm 4종은 그대로 통과 (오탐 0 · 뒤 세그먼트의 cd .. 포함)" || true # PATH 에 `cat` 만 남겨요 — 훅은 stdin 을 읽어야 "jq 가 없다" 갈래에 도달해요. # bash·훅은 절대경로로 부르니 PATH 가 비어도 돌아요. @@ -4304,6 +4333,11 @@ if command -v jq >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then [ "$(df_rc "$c" fp)" = 0 ] || { fail "destructive-facts 오탐: $c"; DF_FP=1; } done [ "$DF_FP" -eq 0 ] && pass "destructive-facts — 재생성 디렉토리 · 프로젝트 밖 · 변수 경로 · 비재귀 rm · 안전한 git 은 통과 (오탐 0)" + # 세션 스크래치패드는 프로젝트 밖이라 묻지 않아요 — 프로젝트 루트 자체는 여전히 사실을 요구해요 + [ "$(df_rc 'cd /private/tmp/claude-502/-p/s/scratchpad/ios-dd && rm -rf Build Index.noindex; df -h /' sp1)" = 0 ] \ + && [ "$(df_rc "rm -rf $DF" sp2)" = 2 ] \ + && pass "destructive-facts — 스크래치패드(프로젝트 밖)는 통과 · 프로젝트 루트 자체는 차단" \ + || fail "destructive-facts — 스크래치패드/프로젝트 루트 판정이 틀려요" [ "$(df_rc 'git reset --hard' r1)" = 2 ] && [ "$(df_rc 'git reset --hard' r1)" = 0 ] \ && pass "destructive-facts — 같은 명령 재시도는 통과 (사실을 적고 다시 온 것)" \ || fail "destructive-facts — 재시도가 통과하지 않아요"