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,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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user