fix(harness): 루프 검증으로 찾은 bash_guard 미탐 9건 수정 + 커밋 게이트 사유 정리
1회차(31건 중 미탐 8, 오탐 0) → 2회차(새 31건 중 미탐 1, 오탐 0) → 3회차 0/0 - 변수 확장($C compose up), eval, bash -lc 등 묶인 -c 플래그, python -c 안의 uvicorn.run 재귀 검사 - compose up|start|restart|run|exec 를 실행 파일명과 무관하게 deny - make dev|deploy|restart|install, scripts/(dev|deploy|restart|start*).sh deny - main 삭제 푸시(:main, --delete) deny, find <repo> -delete / -exec rm deny - 커밋 게이트 deny 사유는 FAILED/ERROR·요약 줄만(경고 잡음 제거) - 훅 테스트 106 passed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,12 +350,19 @@ 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):
|
||||
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 # 원격 실행은 로컬 가드 대상 아님
|
||||
parsed.append((argv, depth))
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user