modify documents

This commit is contained in:
KOKO\Mimi
2026-09-05 15:14:21 +09:00
parent 31dac85192
commit 97e4d7a971
79 changed files with 4570 additions and 2787 deletions
+54 -14
View File
@@ -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
+25 -5
View File
@@ -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
+109
View File
@@ -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
)