From c27e3b649a5621464c962eca3a4b99952062c5f8 Mon Sep 17 00:00:00 2001 From: bluecheat Date: Sat, 3 Oct 2026 19:49:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(hooks):=20block-destructive=20=EB=8A=94?= =?UTF-8?q?=20rm=20=EC=9D=B4=20=EB=93=A0=20=EB=8B=A8=EC=88=9C=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=20=EC=95=88=EC=97=90=EC=84=9C=EB=A7=8C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20=EB=B4=90=EC=9A=94=20=E2=80=94=20=EB=92=A4?= =?UTF-8?q?=EC=9D=98=20`df=20-h=20/`=20=EB=A1=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=9E=98=EC=B9=98=ED=8C=A8=EB=93=9C=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EA=B0=80=20=EB=A7=89=ED=9E=88=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CATASTROPHIC 의 세 조건(rm · 재귀 플래그 · 루트/시스템 경로)을 명령 문자열 전체에 따로 걸어서, 서로 다른 명령의 조각이 합쳐졌어요. 실측(one-tenth): 세션 스크래치패드의 20GB 빌드 캐시를 지우는 `cd /ios-dd && rm -rf Build/... Logs; du -sh . ; df -h / | tail -1` 이 `df -h /` 의 ` /` 때문에 "재귀 rm + 루트/시스템 경로" 로 막혔고 디스크가 120MB 까지 찼어요. heredoc 으로 파일을 쓰는 명령도 본문 낱말로 같은 이유로 여러 번 막혔어요. - `;` `&&` `||` `|` `&` `(` `)` 백틱 줄바꿈에서 세그먼트로 자르고, 세 조건을 같은 세그먼트에서만 봐요 - 세그먼트로 가르며 생길 구멍 하나를 막아요: `cd /`·`cd ~`·`cd /usr` 뒤 `rm -rf *`/`.`/`./*` 는 CATASTROPHIC (예전엔 `cd /` 의 ` /` 에 우연히 걸렸어요). `(rm -rf ~)` 처럼 괄호에 붙은 홈 삭제도 이제 잡아요 - 상위 경로(..) 경고도 같은 범위로 — `rm -rf build && cd ..` 는 경고하지 않아요 - 스크래치패드 경로 허용 목록은 두지 않았어요. 원인이 경로가 아니라 범위였고, CATASTROPHIC 앞에 허용 목록을 두면 `.../scratchpad/../../..` 같은 표기가 새 우회로가 돼요 smoke §15 에 회귀 13종(스크래치패드 실측 명령 · 절대 경로 · /tmp/claude-* · 변수 경로 · heredoc 본문 통과, 같은 세그먼트 rm -rf / · /* · sudo /usr · (~) · cd / 뒤 * · cd ~ 뒤 . · cd /usr 뒤 ./* 차단, cd 로 벗어나면 통과), §41 에 `rm -rf build && cd ..`, §51 에 스크래치패드 통과 · 프로젝트 루트 자체 차단. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C8rZ6V3FW3nwYA9T7iygPd --- .../.ax/hooks/pre-bash/block-destructive.sh | 71 ++++++++++++++----- tests/smoke.sh | 35 +++++++-- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/templates/default/.ax/hooks/pre-bash/block-destructive.sh b/templates/default/.ax/hooks/pre-bash/block-destructive.sh index 6b0d9e3..058b039 100755 --- a/templates/default/.ax/hooks/pre-bash/block-destructive.sh +++ b/templates/default/.ax/hooks/pre-bash/block-destructive.sh @@ -57,6 +57,18 @@ 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`·` / `)로 같은 일이 났어요. +# 세그먼트마다 앞에 공백 하나를 붙여 둬서 아래 ` …` 로 시작하는 패턴이 그대로 맞아요. +SEGS=$(printf '%s\n' "$CMD" | 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 +90,42 @@ 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 는 다음 세그먼트의 상대 경로가 어디를 가리키는지 정해요. 다른 곳으로 cd 하면 풀려요. + if seg_matches '^ cd( |$)'; then + if seg_matches '^ cd$' || seg_matches "^ cd${ROOT_TARGET}" || seg_matches "^ cd${HOME_TARGET}"; then + CD_DANGER="${SEG# cd}"; CD_DANGER="${CD_DANGER# }"; 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 +161,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..c77264f 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,26 @@ 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 && 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 — 재시도가 통과하지 않아요" From 26340737d2b3b0f199cd76d7c93639f89acf46ef Mon Sep 17 00:00:00 2001 From: bluecheat Date: Sat, 3 Oct 2026 23:14:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(hooks):=20=EC=A4=84=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=93=B0=EA=B8=B0=C2=B7pushd=20=EB=A1=9C=20CATASTROPH?= =?UTF-8?q?IC=20=EC=9D=B4=20=EB=9A=AB=EB=A6=AC=EB=8D=98=20=EA=B2=83=20?= =?UTF-8?q?=E2=80=94=20=EC=84=B8=EA=B7=B8=EB=A8=BC=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=90=EB=A5=B4=EA=B8=B0=20=EC=A0=84=EC=97=90=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=A7=84=20=EC=A4=84=EC=9D=84=20=EB=B6=99=EC=97=AC?= =?UTF-8?q?=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세그먼트를 줄바꿈에서 자르면서 `rm -rf \⏎ /` 가 두 세그먼트로 갈라져 루트 삭제가 통과했어요 (main 은 NORM 에서 줄바꿈을 공백으로 바꿔 잡고 있었어요). `pushd / && rm -rf *` 도 cd 만 보던 탓에 통과했어요. - 자르기 전에 `\` + 줄바꿈을 공백으로 붙여요 - cd 와 같이 pushd 도 다음 세그먼트의 상대 경로 기준으로 봐요 - smoke §15: pushd 1건 · 줄 이어쓰기 3건 (jq 로 JSON 을 만들어 줄바꿈을 그대로 넘겨요) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MW9uw5kSzPQhuMePpnsJYt --- .../default/.ax/hooks/pre-bash/block-destructive.sh | 13 ++++++++----- tests/smoke.sh | 9 +++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/templates/default/.ax/hooks/pre-bash/block-destructive.sh b/templates/default/.ax/hooks/pre-bash/block-destructive.sh index 058b039..0da8a0f 100755 --- a/templates/default/.ax/hooks/pre-bash/block-destructive.sh +++ b/templates/default/.ax/hooks/pre-bash/block-destructive.sh @@ -63,7 +63,9 @@ matches() { printf '%s' "$NORM" | grep -qE "$1"; } # rm 과 무관한 `df -h /` 의 ` /` 때문에 CATASTROPHIC 으로 막혀 20GB 빌드 캐시를 못 지웠고 디스크가 # 120MB 까지 찼어요. heredoc 으로 파일을 쓰는 명령도 본문 낱말(`rm -r`·` / `)로 같은 일이 났어요. # 세그먼트마다 앞에 공백 하나를 붙여 둬서 아래 ` …` 로 시작하는 패턴이 그대로 맞아요. -SEGS=$(printf '%s\n' "$CMD" | tr -d '\047\042' | tr '\t' ' ' \ +# 줄 이어쓰기(`\` + 줄바꿈)는 셸이 한 명령으로 읽어요 — 먼저 붙여야 `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="" @@ -96,10 +98,11 @@ CD_DANGER="" # 바로 앞 cd 가 루트·시스템·홈이었으면 그 목적 while IFS= read -r SEG; do [ -n "$SEG" ] || continue - # cd 는 다음 세그먼트의 상대 경로가 어디를 가리키는지 정해요. 다른 곳으로 cd 하면 풀려요. - if seg_matches '^ cd( |$)'; then - if seg_matches '^ cd$' || seg_matches "^ cd${ROOT_TARGET}" || seg_matches "^ cd${HOME_TARGET}"; then - CD_DANGER="${SEG# cd}"; CD_DANGER="${CD_DANGER# }"; CD_DANGER="${CD_DANGER:-~}" + # 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 diff --git a/tests/smoke.sh b/tests/smoke.sh index c77264f..29f3b73 100644 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -1150,6 +1150,15 @@ assert_blocked 'cd / && rm -rf *' 'cd / 뒤 상대 글롭 — 실제 assert_blocked 'cd ~; rm -rf .' 'cd ~ 뒤 rm -rf .' assert_blocked 'cd /usr && rm -rf ./*' 'cd /usr 뒤 rm -rf ./*' assert_passed 'cd / && ls; cd /tmp/x && rm -rf *' '루트에 갔다가 다른 곳으로 cd 한 뒤 rm -rf *' +assert_blocked 'pushd / && rm -rf *' 'pushd / 뒤 상대 글롭 — cd 와 같아요' +# 줄 이어쓰기(`\` + 줄바꿈)는 한 명령이에요 — 세그먼트로 자르기 전에 붙여요 (줄바꿈이 든 명령은 jq 로 JSON 을 만들어요) +for bd_cont in $'rm -rf \\\n /' $'sudo rm -rf \\\n ~' $'rm -rf \\\n /usr'; do + jq -nc --arg c "$bd_cont" '{tool_input:{command:$c}}' \ + | CLAUDE_PROJECT_DIR=$BD_FX bash "$BD_FX/.ax/hooks/pre-bash/block-destructive.sh" >/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"