modify documents
This commit is contained in:
+809
-148
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ if str(SCRIPTS_DIR) not in sys.path:
|
||||
|
||||
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 = (
|
||||
@@ -29,15 +30,27 @@ DANGEROUS_PATTERNS = (
|
||||
),
|
||||
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", {})
|
||||
return value if isinstance(value, dict) else {}
|
||||
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]:
|
||||
@@ -57,7 +70,7 @@ def _candidate_paths(payload: dict[str, Any], root: Path) -> list[Path]:
|
||||
if isinstance(value, str) and value.strip():
|
||||
raw_paths.append(value.strip())
|
||||
|
||||
for key in ("patch", "input"):
|
||||
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))
|
||||
@@ -71,20 +84,33 @@ def _candidate_paths(payload: dict[str, Any], root: Path) -> list[Path]:
|
||||
|
||||
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_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 isinstance(command, str) and any(
|
||||
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 {"apply_patch", "Edit", "MultiEdit", "Write"}:
|
||||
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:
|
||||
@@ -98,19 +124,33 @@ def main() -> int:
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
if not isinstance(payload, dict):
|
||||
raise TypeError("hook input must be a JSON object")
|
||||
raise HookInputError("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
|
||||
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
|
||||
|
||||
root = Path(payload.get("cwd") or Path.cwd()).resolve()
|
||||
reason = evaluate(payload, root)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from msvc_harness.discovery import DiscoveryError, discover_project
|
||||
from msvc_harness.models import ProjectKind
|
||||
from msvc_harness.process import ValidationFailure, execute_plan
|
||||
from msvc_harness.toolchain import ToolchainError, discover_toolchain
|
||||
from hooks.validation_scope import is_document_only_turn
|
||||
|
||||
|
||||
REENTRY_ENV = "CODEX_STOP_VALIDATION_ACTIVE"
|
||||
@@ -112,9 +113,8 @@ def _emit_stop_response(message: str) -> None:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"continue": False,
|
||||
"stopReason": message,
|
||||
"systemMessage": message,
|
||||
"decision": "block",
|
||||
"reason": message,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -125,16 +125,36 @@ def main(*, run=subprocess.run, clock=time.monotonic) -> int:
|
||||
if os.environ.get(REENTRY_ENV) == "1":
|
||||
return 0
|
||||
|
||||
try:
|
||||
raw_payload = sys.stdin.read()
|
||||
payload = json.loads(raw_payload) if raw_payload.strip() else {}
|
||||
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")
|
||||
stop_hook_active = payload.get("stop_hook_active", False)
|
||||
if not isinstance(stop_hook_active, bool):
|
||||
raise TypeError("stop_hook_active must be a boolean")
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
_emit_stop_response(f"validation hook input rejected: {exc}")
|
||||
return 0
|
||||
|
||||
if stop_hook_active:
|
||||
return 0
|
||||
|
||||
deadline = clock() + TOTAL_TIMEOUT_SECONDS
|
||||
try:
|
||||
root = _project_root(
|
||||
Path.cwd(),
|
||||
Path(cwd or Path.cwd()),
|
||||
deadline=deadline,
|
||||
run=run,
|
||||
clock=clock,
|
||||
)
|
||||
if is_document_only_turn(payload, root):
|
||||
return 0
|
||||
ok, message = run_validations(root, deadline=deadline, clock=clock)
|
||||
except (OSError, ValidationFailure) as exc:
|
||||
except Exception as exc:
|
||||
_emit_stop_response(f"validation hook failed: {exc}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Track whether a Codex turn edited Markdown files only."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
EDIT_TOOL_NAMES = frozenset({"apply_patch", "Edit", "MultiEdit", "Write"})
|
||||
STATE_VERSION = 1
|
||||
|
||||
|
||||
def _identifier(payload: dict[str, Any], name: str) -> str | None:
|
||||
value = payload.get(name)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _digest(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _state_path(root: Path, session_id: str) -> Path:
|
||||
return root / ".harness" / "hook-state" / f"{_digest(session_id)}.json"
|
||||
|
||||
|
||||
def _is_markdown_in_root(path: Path, root: Path) -> bool:
|
||||
resolved_root = root.resolve()
|
||||
resolved_path = path.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_root)
|
||||
except ValueError:
|
||||
return False
|
||||
return resolved_path.suffix.lower() == ".md"
|
||||
|
||||
|
||||
def record_edit_scope(
|
||||
payload: dict[str, Any],
|
||||
root: Path,
|
||||
paths: Iterable[Path],
|
||||
) -> bool:
|
||||
"""Record an allowed edit; return False when turn identity is unavailable."""
|
||||
session_id = _identifier(payload, "session_id")
|
||||
turn_id = _identifier(payload, "turn_id")
|
||||
if session_id is None or turn_id is None:
|
||||
return False
|
||||
|
||||
edit_paths = list(paths)
|
||||
if not edit_paths:
|
||||
raise ValueError("edit scope requires at least one path")
|
||||
|
||||
turn_digest = _digest(turn_id)
|
||||
state_path = _state_path(root, session_id)
|
||||
document_only = all(
|
||||
_is_markdown_in_root(path, root) for path in edit_paths
|
||||
)
|
||||
|
||||
if state_path.is_file():
|
||||
try:
|
||||
previous = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
valid_previous = (
|
||||
isinstance(previous, dict)
|
||||
and previous.get("version") == STATE_VERSION
|
||||
and isinstance(previous.get("turn"), str)
|
||||
and isinstance(previous.get("document_only"), bool)
|
||||
)
|
||||
if not valid_previous:
|
||||
document_only = False
|
||||
elif previous["turn"] == turn_digest:
|
||||
document_only = previous["document_only"] and document_only
|
||||
except (json.JSONDecodeError, OSError):
|
||||
document_only = False
|
||||
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path = state_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": STATE_VERSION,
|
||||
"turn": turn_digest,
|
||||
"document_only": document_only,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(state_path)
|
||||
return True
|
||||
|
||||
|
||||
def is_document_only_turn(payload: dict[str, Any], root: Path) -> bool:
|
||||
"""Return True only for a valid state proving Markdown-only edits."""
|
||||
session_id = _identifier(payload, "session_id")
|
||||
turn_id = _identifier(payload, "turn_id")
|
||||
if session_id is None or turn_id is None:
|
||||
return False
|
||||
|
||||
state_path = _state_path(root, session_id)
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return False
|
||||
|
||||
return (
|
||||
isinstance(state, dict)
|
||||
and state.get("version") == STATE_VERSION
|
||||
and state.get("turn") == _digest(turn_id)
|
||||
and state.get("document_only") is True
|
||||
)
|
||||
@@ -31,7 +31,7 @@ class CMakeAdapter:
|
||||
|
||||
if config.cmake.configure_preset is None:
|
||||
command_cwd = root
|
||||
binary = root / ".harness/build"
|
||||
binary = config.cmake.binary_dir or root / ".harness/build"
|
||||
configure = (
|
||||
str(tools.cmake),
|
||||
"-S",
|
||||
|
||||
@@ -13,12 +13,14 @@ from .base import AdapterError
|
||||
|
||||
|
||||
def _test_argv(root: Path, command: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if "/" not in command[0] and "\\" not in command[0]:
|
||||
return command
|
||||
first = Path(command[0].replace("\\", "/"))
|
||||
executable = first if first.is_absolute() else (root / first).resolve()
|
||||
if first.is_absolute():
|
||||
raise AdapterError(
|
||||
"msbuild.testCommand executable must be repository-relative"
|
||||
)
|
||||
executable = (root / first).resolve()
|
||||
try:
|
||||
executable.relative_to(root)
|
||||
executable.relative_to(root.resolve())
|
||||
except ValueError as exc:
|
||||
raise AdapterError(
|
||||
"msbuild.testCommand resolves outside the repository"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from string import Formatter
|
||||
from typing import Any
|
||||
|
||||
from .models import CMakeConfig, HarnessConfig, MSBuildConfig, TDDConfig
|
||||
@@ -43,15 +44,52 @@ def _mapping(value: Any, label: str) -> dict[str, Any]:
|
||||
def _nonempty_string(value: Any, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ConfigError(f"{label} must be a non-empty string")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise ConfigError(f"{label} must not contain control characters")
|
||||
return value
|
||||
|
||||
|
||||
def _string_array(value: Any, label: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
raise ConfigError(f"{label} must be an array")
|
||||
if not value or any(not isinstance(item, str) or not item for item in value):
|
||||
if not value:
|
||||
raise ConfigError(f"{label} must be a non-empty string array")
|
||||
return tuple(value)
|
||||
return tuple(
|
||||
_nonempty_string(item, f"{label}[{index}]")
|
||||
for index, item in enumerate(value)
|
||||
)
|
||||
|
||||
|
||||
def _validate_test_pattern(pattern: str) -> None:
|
||||
candidate = Path(pattern)
|
||||
if (
|
||||
candidate.is_absolute()
|
||||
or bool(candidate.drive)
|
||||
or "/" in pattern
|
||||
or "\\" in pattern
|
||||
or candidate.name != pattern
|
||||
):
|
||||
raise ConfigError(
|
||||
"every tdd.testPatterns entry must be a safe basename pattern"
|
||||
)
|
||||
try:
|
||||
fields = [
|
||||
(field_name, format_spec, conversion)
|
||||
for _, field_name, format_spec, conversion in Formatter().parse(pattern)
|
||||
if field_name is not None
|
||||
]
|
||||
except ValueError as exc:
|
||||
raise ConfigError(f"invalid tdd.testPatterns entry: {exc}") from exc
|
||||
if (
|
||||
not fields
|
||||
or any(
|
||||
field_name != "stem" or format_spec or conversion
|
||||
for field_name, format_spec, conversion in fields
|
||||
)
|
||||
):
|
||||
raise ConfigError(
|
||||
"every tdd.testPatterns entry may contain only the {stem} field"
|
||||
)
|
||||
|
||||
|
||||
def load_config(root: Path) -> HarnessConfig:
|
||||
@@ -92,8 +130,10 @@ def load_config(root: Path) -> HarnessConfig:
|
||||
binary_dir = _repo_path(
|
||||
root, _nonempty_string(cmake_data["binaryDir"], "cmake.binaryDir"), "cmake.binaryDir"
|
||||
)
|
||||
preset_keys = ("configurePreset", "buildPreset", "testPreset", "binaryDir")
|
||||
if any(key in cmake_data for key in preset_keys) and not all(key in cmake_data for key in preset_keys):
|
||||
preset_keys = ("configurePreset", "buildPreset", "testPreset")
|
||||
if any(key in cmake_data for key in preset_keys) and not (
|
||||
all(key in cmake_data for key in preset_keys) and binary_dir is not None
|
||||
):
|
||||
raise ConfigError("cmake configurePreset, buildPreset, testPreset, and binaryDir must be specified together")
|
||||
configure_preset = (
|
||||
_nonempty_string(cmake_data["configurePreset"], "cmake.configurePreset")
|
||||
@@ -131,8 +171,8 @@ def load_config(root: Path) -> HarnessConfig:
|
||||
raw_test_roots = _string_array(tdd_data["testRoots"], "tdd.testRoots") if "testRoots" in tdd_data else ("tests", "test")
|
||||
test_roots = tuple(_repo_path(root, item, "tdd.testRoots") for item in raw_test_roots)
|
||||
test_patterns = _string_array(tdd_data["testPatterns"], "tdd.testPatterns") if "testPatterns" in tdd_data else DEFAULT_TEST_PATTERNS
|
||||
if any("{stem}" not in pattern for pattern in test_patterns):
|
||||
raise ConfigError("every tdd.testPatterns entry must contain {stem}")
|
||||
for pattern in test_patterns:
|
||||
_validate_test_pattern(pattern)
|
||||
exclude = _string_array(tdd_data["exclude"], "tdd.exclude") if "exclude" in tdd_data else ()
|
||||
for item in exclude:
|
||||
_repo_path(root, item, "tdd.exclude")
|
||||
|
||||
@@ -48,7 +48,7 @@ def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult:
|
||||
project = _single_msbuild(root, config.msbuild.solution)
|
||||
return DiscoveryResult(ProjectSelection(ProjectKind.MSBUILD, project))
|
||||
|
||||
entry = _cmake_entry(root)
|
||||
entry = _cmake_entry(config.cmake.source_dir)
|
||||
if entry is not None:
|
||||
return DiscoveryResult(ProjectSelection(ProjectKind.CMAKE, entry))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user