#!/usr/bin/env python3 """Codex PreToolUse 정책: 위험 명령과 테스트 없는 구현 파일 수정을 차단한다.""" import json import re import sys from pathlib import Path from typing import Any SCRIPTS_DIR = Path(__file__).resolve().parents[1] if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) from msvc_harness.config import ConfigError, load_config from msvc_harness.tdd_policy import CPP_SUFFIXES, evaluate_paths DANGEROUS_PATTERNS = ( re.compile(r"\bgit\s+reset\s+--hard\b", re.IGNORECASE), re.compile(r"\bgit\s+push\b[^\n]*--force(?:-with-lease)?\b", re.IGNORECASE), re.compile(r"\brm\s+-rf\b", re.IGNORECASE), re.compile( r"\bRemove-Item\b(?=[^\n]*-Recurse\b)(?=[^\n]*-Force\b)", re.IGNORECASE, ), re.compile( r"\b(?:rmdir|rd)\b(?=[^\n]*/s\b)(?=[^\n]*/q\b)", re.IGNORECASE, ), re.compile(r"\bDROP\s+TABLE\b", re.IGNORECASE), ) PATCH_PATH = re.compile( r"^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:) (?P.+?)\s*$", re.MULTILINE, ) def _tool_input(payload: dict[str, Any]) -> dict[str, Any]: value = payload.get("tool_input", {}) return value if isinstance(value, dict) else {} def _candidate_paths(payload: dict[str, Any], root: Path) -> list[Path]: tool_input = _tool_input(payload) raw_paths: list[str] = [] for key in ("path", "file_path"): value = tool_input.get(key) if isinstance(value, str) and value.strip(): raw_paths.append(value.strip()) edits = tool_input.get("edits") if isinstance(edits, list): for edit in edits: if not isinstance(edit, dict): continue value = edit.get("path") if isinstance(value, str) and value.strip(): raw_paths.append(value.strip()) for key in ("patch", "input"): value = tool_input.get(key) if isinstance(value, str): raw_paths.extend(match.group("path") for match in PATCH_PATH.finditer(value)) paths: list[Path] = [] for raw_path in raw_paths: path = Path(raw_path) paths.append(path.resolve() if path.is_absolute() else (root / path).resolve()) return list(dict.fromkeys(paths)) def evaluate(payload: dict[str, Any], root: Path) -> str | None: """Return a blocking reason, or None when the tool call is allowed.""" tool_name = str(payload.get("tool_name", "")) tool_input = _tool_input(payload) if tool_name in {"Bash", "shell_command", "PowerShell"}: command = tool_input.get("command", "") if isinstance(command, str) and any( pattern.search(command) for pattern in DANGEROUS_PATTERNS ): return "위험한 명령어가 감지되어 실행을 차단했습니다." if tool_name not in {"apply_patch", "Edit", "MultiEdit", "Write"}: return None paths = _candidate_paths(payload, root) try: config = load_config(root) except ConfigError as exc: if any(path.suffix.lower() in CPP_SUFFIXES for path in paths): return f"TDD GUARD: .harness/config.json must be repaired: {exc}" return None return evaluate_paths(paths, root, config.tdd) def main() -> int: try: payload = json.load(sys.stdin) if not isinstance(payload, dict): raise TypeError("hook input must be a JSON object") cwd = payload.get("cwd") if cwd is not None and not isinstance(cwd, str): raise TypeError("hook cwd must be a string") except (json.JSONDecodeError, TypeError) as exc: print(f"TDD GUARD: hook 입력을 해석하지 못해 검사를 건너뜁니다: {exc}", file=sys.stderr) return 0 root = Path(payload.get("cwd") or Path.cwd()).resolve() reason = evaluate(payload, root) if reason: print(reason, file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main())