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:
2026-10-02 09:21:51 +09:00
parent ea644a37f3
commit 0f89a9d678
3 changed files with 103 additions and 13 deletions

View File

@@ -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)