159 lines
5.2 KiB
Python
159 lines
5.2 KiB
Python
#!/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<path>.+?)\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())
|