diff --git a/.claude/hooks/bash_guard.py b/.claude/hooks/bash_guard.py index 9b01182..8e27609 100755 --- a/.claude/hooks/bash_guard.py +++ b/.claude/hooks/bash_guard.py @@ -18,6 +18,12 @@ from _lib import git, kst_now, market_state, project_dir, read_stdin_json # noq WRAPPERS = {"sudo", "env", "time", "nohup", "command", "exec", "nice", "builtin"} SHELLS = {"bash", "sh", "zsh", "dash"} +COMPOSE_BAD = {"up", "start", "restart", "run", "exec"} +# Makefile(레포 루트) 기준: dev/deploy/restart/rebuild/install 이 scripts/*.sh 또는 docker-compose up 을 호출. +# `up` 은 Makefile 에 없지만 관용적 별칭이라 함께 막는다. help/logs*/status/stop/backup 은 허용. +MAKE_BAD_TARGETS = {"up", "start", "dev", "deploy", "restart", "rebuild", "install"} +SCRIPT_BAD_RE = re.compile(r"(^|/)scripts/(dev|deploy|restart|start[^/]*)\.sh$") +PY_CODE_BAD_RE = re.compile(r"uvicorn\.(run|Server)|create_app\s*\(|gunicorn|app\.main[\s\S]*(uvicorn|\.run\()|(uvicorn|\.run\()[\s\S]*app\.main") PYTEST_CMD_DEFAULT = ( 'cd backend && DATABASE_URL="postgresql+asyncpg://x:x@localhost/autostock_test" ' "../.venv/bin/python -m pytest tests/ --ignore=tests/integration -q -x -p no:cacheprovider" @@ -131,13 +137,26 @@ def check_local_stack(argv: list[str]) -> dict | None: ) if b0 == "fastapi" and any(t in ("run", "dev") for t in argv[1:3]): return deny("로컬 fastapi 기동 금지(KIS 토큰 충돌).") - if b0 == "docker-compose" or (b0 == "docker" and "compose" in argv): - start = argv.index("compose") if "compose" in argv else 0 - if any(t in ("up", "start", "restart") for t in argv[start + 1:]): + # 실행 파일명과 무관: docker/podman/미해석 $VAR 가 compose 서브커맨드를 실행하는 경우. + # (grep/echo 등 비실행 명령의 인자는 제외해 오탐을 막는다) + if b0 in ("docker", "docker-compose", "podman", "podman-compose") or b0.startswith("$"): + start = argv.index("compose") if "compose" in argv else (0 if b0.endswith("-compose") else -1) + if start >= 0 and any(t in COMPOSE_BAD for t in argv[start + 1:]): return deny( - "로컬 docker compose up/start/restart 금지: KIS 앱키 공유로 NAS 토큰이 무효화된다. " - "배포는 /deploy 스킬(Jenkins), 검증은 격리 pytest." + "로컬 docker compose up/start/restart/run/exec 금지: 컨테이너·의존 서비스가 기동되어 KIS 앱키 공유로 " + "NAS 토큰이 무효화된다. 배포는 /deploy 스킬(Jenkins), 검증은 격리 pytest." ) + if b0 == "make" and any(t in MAKE_BAD_TARGETS for t in argv[1:]): + return deny( + "make up/dev/deploy/restart/rebuild/install 금지: Makefile 이 scripts/*.sh 로 로컬 스택을 기동한다(KIS 토큰 충돌)." + ) + for t in argv[:2]: + if SCRIPT_BAD_RE.search(t) and ".claude/" not in t: + return deny("scripts/dev|deploy|restart|start*.sh 는 로컬 스택을 기동한다(KIS 토큰 충돌). 배포는 /deploy 스킬.") + if re.match(r"^python[\d.]*$", b0) or b0 == "python": + for i, t in enumerate(argv[:-1]): + if t == "-c" and PY_CODE_BAD_RE.search(argv[i + 1]): + return deny("python -c 로 uvicorn/앱 기동 금지(KIS 토큰 충돌).") return None @@ -189,6 +208,8 @@ def check_git_push(argv: list[str], cwd: str) -> dict | None: to_main = True if r.startswith("+") and dst in ("main", "refs/heads/main"): force = True + if r.startswith(":") and dst in ("main", "refs/heads/main"): + return deny("main 삭제 푸시(`:main`) 금지.") if "--delete" in args or "-d" in args: if any(r in ("main", "refs/heads/main") for r in refspecs): return deny("main 브랜치 삭제 푸시 금지.") @@ -225,6 +246,29 @@ def _resolve(path: str, cwd: str) -> str: return os.path.realpath(p if os.path.isabs(p) else os.path.join(cwd, p)) +def check_find_delete(argv: list[str], cwd: str, root: str) -> dict | None: + if base(argv[0]) != "find": + return None + destructive = "-delete" in argv or any( + t in ("-exec", "-execdir", "-ok") and i + 1 < len(argv) and base(argv[i + 1]) == "rm" + for i, t in enumerate(argv) + ) + if not destructive: + return None + starts = [] + for t in argv[1:]: + if t.startswith(("-", "!", "(")): + break + starts.append(t) + protected = {os.path.realpath(root), os.path.realpath(os.path.join(root, "backend")), + os.path.realpath(os.path.join(root, ".git"))} + for t in starts or ["."]: + target = _resolve(t, cwd) + if target in protected or os.path.realpath(root).startswith(target.rstrip("/") + "/") or target == "/": + return deny(f"find '{t}' -delete/-exec rm 은 레포 루트/backend/.git(또는 상위)를 대량 삭제한다. 하위 캐시 경로로 한정하라.") + return None + + def check_rm(argv: list[str], cwd: str, root: str) -> dict | None: if base(argv[0]) != "rm": return None @@ -285,7 +329,10 @@ def commit_gate(cmd_segments: list[tuple[list[str], int]], cwd: str) -> dict | N sys.stderr.write(f"[bash_guard] 게이트 실행 오류 {e!r} — 통과\n") return None if p.returncode != 0: - tail = "\n".join((p.stdout + "\n" + p.stderr).strip().splitlines()[-25:]) + all_lines = (p.stdout + "\n" + p.stderr).strip().splitlines() + key = [l for l in all_lines if re.match(r"^(FAILED|ERROR)\b", l.strip()) + or re.search(r"\d+ (failed|passed|error)", l)] + tail = "\n".join((key or all_lines)[-15:]) return deny(f"커밋 게이트: backend 변경이 스테이징돼 있는데 유닛 스위트 실패(rc={p.returncode}). 고친 뒤 다시 커밋.\n--- tail ---\n{tail}") return None @@ -303,11 +350,18 @@ def evaluate(cmd: str, cwd: str, root: str) -> dict | None: env_acc.update(env) # 'A=b' 단독 또는 export A=b return eff = {**env_acc, **env} - if base(argv[0]) in SHELLS and "-c" in argv[1:] and depth < 3: - i = argv.index("-c") - if i + 1 < len(argv): - for s in split_segments(argv[i + 1]): - handle(tokenize(s), depth + 1) + m = re.match(r"^\$(\w+)$|^\$\{(\w+)\}$", argv[0]) + if m and eff.get(m.group(1) or m.group(2)): + argv = tokenize(eff[m.group(1) or m.group(2)]) + argv[1:] + if base(argv[0]) in SHELLS and depth < 3: + for i, t in enumerate(argv[1:], 1): + if re.match(r"^-[a-zA-Z]*c[a-zA-Z]*$", t) and i + 1 < len(argv): + for s in split_segments(argv[i + 1]): + handle(tokenize(s), depth + 1) + return + if base(argv[0]) == "eval" and depth < 3: + for s in split_segments(" ".join(argv[1:])): + handle(tokenize(s), depth + 1) return if base(argv[0]) == "ssh": return # 원격 실행은 로컬 가드 대상 아님 @@ -317,7 +371,7 @@ def evaluate(cmd: str, cwd: str, root: str) -> dict | None: if r: results.append(r) for r in (check_alembic(argv, eff), check_pytest(argv, eff), check_git_push(argv, cwd), - check_rm(argv, cwd, root), check_deploy_script(argv)): + check_rm(argv, cwd, root), check_find_delete(argv, cwd, root), check_deploy_script(argv)): if r: results.append(r) diff --git a/.claude/hooks/tests/test_hooks.py b/.claude/hooks/tests/test_hooks.py index 79db43d..da28eec 100644 --- a/.claude/hooks/tests/test_hooks.py +++ b/.claude/hooks/tests/test_hooks.py @@ -53,6 +53,25 @@ TEST_URL = "postgresql+asyncpg://x:x@localhost/autostock_test" "rm -rf ./*", "sudo rm -fr /Users/commylife/myproject/java/workspace/auto-stock", "bash -c 'docker compose up'", + "C=docker; $C compose up -d", + "C=docker; ${C} compose restart", + "python -c \"import uvicorn; uvicorn.run('app.main:app')\"", + "cd backend && ../.venv/bin/python -c 'from app.main import app; import uvicorn; uvicorn.run(app)'", + "eval 'docker compose up -d'", + 'bash -lc "cd backend && uvicorn app.main:app --port 8000"', + "zsh -ic 'docker-compose up'", + "sh -ec 'docker compose start'", + "docker compose run --rm backend alembic upgrade head", + "docker compose exec backend bash", + "make up", "make dev", "make deploy", "make restart", + "./scripts/dev.sh", "bash scripts/restart.sh", "./scripts/start_all.sh", "./scripts/deploy.sh", + "git push origin :main", + "find . -delete", + "find backend -name '*.py' -delete", + "find . -name x -exec rm -rf {} +", + "find .git -delete", + "git push origin --delete main", + "git push --delete origin main", ]) def test_deny(cmd): d, reason = bash(cmd) @@ -76,6 +95,14 @@ def test_deny(cmd): "ssh -p 2082 nas 'docker compose up -d'", "cat <<'EOF'\ndocker compose up\nEOF", "alembic current", + "docker compose ps", + "find backend/__pycache__ -delete", + "find node_modules -name '*.tmp' -delete", + "find . -name '*.py'", + "make help", "make logs", "make logs-backend", "make status", + "bash .claude/scripts/deploy.sh --watch-only", + "python -c 'print(1)'", + "ssh nas 'docker compose run --rm backend alembic upgrade head'", ]) def test_allow(cmd): d, _ = bash(cmd) @@ -132,6 +159,15 @@ def test_commit_gate_fail_denies_with_tail(repo): assert out["permissionDecision"] == "deny" and "boom" in out["permissionDecisionReason"] +def test_commit_gate_reason_filters_noise(repo): + d, g = repo + (d / "backend/a.py").write_text("x=5\n"); g("add", "backend/a.py") + cmd = "for i in $(seq 1 40); do echo 'DeprecationWarning noise'; done; echo 'FAILED t.py::t - boom'; echo '1 failed, 3 passed in 0.1s'; exit 1" + out = commit_hook(d, {"HARNESS_PYTEST_CMD": cmd}) + r = out["permissionDecisionReason"] + assert "FAILED t.py::t" in r and "1 failed, 3 passed" in r and "DeprecationWarning" not in r + + def test_commit_gate_pass(repo): d, g = repo (d / "backend/a.py").write_text("x=2\n"); g("add", "backend/a.py") diff --git a/CLAUDE.md b/CLAUDE.md index 8649f64..febffd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -381,7 +381,7 @@ DX(0~100)에 합산형 평활을 적용한 버그. 측정: `up 1361 vs 97 / flat - **배포**: `/deploy` 스킬 → `.claude/scripts/deploy.sh`(worktree 테스트 → push dev → 임시 worktree 에서 `--no-ff` 병합·push main → 재기동 감시 → 스모크). `--watch-only`, `--dry-run`, `--no-test`(경고). 스모크 단독: `.claude/scripts/prod_smoke.sh`. - **우회**: 훅이 막은 것을 에이전트가 환경변수·명령 변형으로 피하지 말 것. 정당한 예외는 **사용자가 직접** 훅을 끄거나 명령을 실행한다(`ask` 는 사용자 승인 프롬프트). 규칙 자체가 틀렸으면 훅과 이 절을 함께 고친다. - **훅 테스트**: `.venv/bin/python -m pytest .claude/hooks/tests -q` (backend 테스트와 분리, DB 불필요). -- 한계: 공휴일은 장 상태 판정에 반영되지 않는다(`market_schedule` 과 동일). +- 한계: 커밋 게이트는 **현재 워킹트리**를 테스트하므로 스테이징 내용과 워킹트리가 다르면(`git add -p` 등) 실제 커밋 내용과 어긋날 수 있다. 공휴일은 장 상태 판정에 반영되지 않는다(`market_schedule` 과 동일). *마지막 업데이트: 2026-09-27 — 죽은 코드 2,695줄 제거 / 지표·사이징 단일화(수치 무변경 증명) / 파라미터 3중 소스 정합 + alembic 0012 / 섹터 매핑 44→747종목 / 백테스트를 실전 로직에 정합(ADX 14배 발산·RSI SMA↔Wilder·1봉 지연) / 미국 상수 13개 config 편입 / 테스트 실 KIS 호출 차단(conftest 신설) / 유닛 스위트 559 passed·0 failed*