#!/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 from hooks.validation_scope import EDIT_TOOL_NAMES, record_edit_scope 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), ) EXECUTOR_OWNED_GIT = re.compile( r"\bgit(?:\.exe)?\b[^\r\n;&|]*\b(?:commit|push|switch|checkout|branch|tag|" r"merge|rebase|reset|cherry-pick|revert|stash|clean|update-ref|symbolic-ref|" r"worktree|fetch|pull)\b", re.IGNORECASE, ) PATCH_PATH = re.compile( r"^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:) (?P.+?)\s*$", re.MULTILINE, ) class HookInputError(ValueError): pass def _tool_input(payload: dict[str, Any]) -> dict[str, Any]: value = payload.get("tool_input") if not isinstance(value, dict): raise HookInputError("hook tool_input must be an object") return value 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 ("command", "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 = payload.get("tool_name") if not isinstance(tool_name, str) or not tool_name: raise HookInputError("hook tool_name must be a non-empty string") tool_input = _tool_input(payload) if tool_name in {"Bash", "shell_command", "PowerShell"}: command = tool_input.get("command") if not isinstance(command, str): raise HookInputError("shell tool_input.command must be a string") if EXECUTOR_OWNED_GIT.search(command): return ( "Harness-owned Git operation detected. The implementation session " "must leave branch, commit, push, and history operations to the Executor." ) if any( pattern.search(command) for pattern in DANGEROUS_PATTERNS ): return "위험한 명령어가 감지되어 실행을 차단했습니다." if tool_name not in EDIT_TOOL_NAMES: return None paths = _candidate_paths(payload, root) if not paths: raise HookInputError( f"{tool_name} input did not contain a path that can be checked" ) 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 HookInputError("hook input must be a JSON object") cwd = payload.get("cwd") if cwd is not None and not isinstance(cwd, str): raise HookInputError("hook cwd must be a string") root = Path(cwd or Path.cwd()).resolve() reason = evaluate(payload, root) except ( json.JSONDecodeError, HookInputError, ConfigError, KeyError, ValueError, OSError, ) as exc: print(f"TDD GUARD: hook input rejected: {exc}", file=sys.stderr) return 2 if reason: print(reason, file=sys.stderr) return 2 if payload["tool_name"] in EDIT_TOOL_NAMES: try: record_edit_scope(payload, root, _candidate_paths(payload, root)) except (ValueError, OSError) as exc: print(f"TDD GUARD: edit scope could not be recorded: {exc}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main())