Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 56 additions & 18 deletions templates/default/.ax/hooks/pre-bash/block-destructive.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 39 additions & 5 deletions tests/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 가 더 이상 호출 안 함.
Expand Down Expand Up @@ -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 <<EOF\nrm -rf 는 쓰지 마세요\n/ 루트도요\nEOF' 'heredoc 본문의 낱말'
# 같은 단순 명령 안이면 여전히 막아요
assert_blocked 'rm -rf / ; echo done' 'rm -rf / 뒤에 다른 명령'
assert_blocked 'ls && rm -rf /*' '앞에 다른 명령 + rm -rf /*'
assert_blocked 'df -h / && sudo rm -rf /usr' 'sudo rm -rf /usr (앞에 df -h /)'
assert_blocked '(rm -rf ~)' 'subshell 안 rm -rf ~'
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"

# 15.1b check-protected-paths — 경로 정규화 + 경계 매칭 + YAML 파싱
Expand Down Expand Up @@ -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종 미탐 ·
Expand Down Expand Up @@ -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 가 비어도 돌아요.
Expand Down Expand Up @@ -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 — 재시도가 통과하지 않아요"
Expand Down
Loading