Files
weichen 8af945a1d5 Make development gates preserve work and verifiable evidence
Why: cleanup, component synchronization, receipt recording and credential handling could contradict the documented development boundaries.

Evidence: SELF 75 isolated governance tests and source checks are captured in .task/feature-governance-reliability.md; combination and CI verification follow.

Impact: hardens local task and CI tooling, aligns policy and component documentation, and adds continuous regression tests without changing product behavior.

Refs: .task/feature-governance-reliability.md; policy/governance.md; policy/platform-admission.md
2026-10-05 12:36:02 +00:00

127 lines
5.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Check every write target; a local branch guard, not an OS sandbox."""
import json
import os
from pathlib import Path
import re
import subprocess
import sys
def deny(reason):
raise ValueError(reason)
def path_value(value):
if not isinstance(value, str) or not value.strip() or any(c in value for c in "\x00\r\n"):
deny("写入路径缺失或无法解析")
return value
def patch_paths(patch):
if not isinstance(patch, str):
deny("ApplyPatch 缺少 patch_text")
lines = patch.strip().splitlines()
if len(lines) < 3 or lines[0] != "*** Begin Patch" or lines[-1] != "*** End Patch":
deny("无法解析 ApplyPatch 边界")
paths = []
operation = None
moved = False
for line in lines[1:-1]:
match = re.fullmatch(r"\*\*\* (Add File|Update File|Delete File): (.+)", line)
if match:
operation = match[1]
moved = False
paths.append(path_value(match[2]))
elif line.startswith("*** Move to: ") and operation == "Update File" and not moved:
paths.append(path_value(line[len("*** Move to: "):]))
moved = True
elif line == "*** End of File" and operation == "Update File":
continue
elif line.startswith("***") or operation is None:
deny("ApplyPatch 包含未知路径指令")
elif not (line.startswith(("+", "-", " ", "@@")) or line == ""):
deny("ApplyPatch 包含无法解析的内容")
if not paths:
deny("ApplyPatch 没有可检查的路径")
return paths
def check_path(raw, cwd):
target = Path(raw)
if not target.is_absolute():
target = cwd / target
# A tool can follow a symlink OR replace/delete its directory entry. Check
# both locations, including a dangling final symlink on a protected branch.
location = target.parent.resolve() / target.name
check_location(location, entry_only=location.is_symlink())
resolved = target.resolve()
if resolved != location:
check_location(resolved)
def check_location(target, entry_only=False):
ancestor = target if target.is_dir() and not entry_only else target.parent
while not ancestor.exists():
ancestor = ancestor.parent
if not ancestor.is_dir():
deny(f"目标祖先不是目录:{ancestor}")
# GIT_DIR/GIT_WORK_TREE must not redirect inspection to another checkout.
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
env["LC_ALL"] = "C"
result = subprocess.run(["git", "-C", str(ancestor), "rev-parse", "--show-toplevel"],
env=env, capture_output=True, text=True)
if result.returncode:
if any((p / ".git").exists() or (p / ".git").is_symlink()
for p in (ancestor, *ancestor.parents)):
deny(f"无法读取目标仓库状态:{target}")
if "not a git repository" not in result.stderr:
deny(f"无法确认目标仓库:{target}")
print(f"write-guard: outside-git(未受 Git 分支规则覆盖):{target}", file=sys.stderr)
return
repo = result.stdout.strip()
branch_result = subprocess.run(["git", "-C", repo, "symbolic-ref", "--quiet", "--short", "HEAD"],
env=env, capture_output=True, text=True)
if branch_result.returncode:
deny(f"仓库处于 detached HEAD 或状态不可读:{repo};先建立任务分支(A1)")
branch = branch_result.stdout.strip()
if not re.fullmatch(r"(?:feature|fix|hotfix|release)/[A-Za-z0-9._-]+", branch):
deny(f"分支 {branch} 不允许写入(目标 {target},仓库 {repo});先 make task-start NAME=feature/<slug>(A1/A3)")
def main():
data = json.load(sys.stdin)
if not isinstance(data, dict):
deny("钩子输入必须为对象")
tool = data.get("tool_name")
if tool in {"Read", "Glob", "Grep", "WebFetch", "WebSearch"}:
return
if tool not in {None, "Edit", "Write", "ApplyPatch", "apply_patch"}:
deny("未知写入工具,必须先补充适配")
payload = data.get("tool_input", data)
if isinstance(payload, str) and tool in {"ApplyPatch", "apply_patch"}:
paths = patch_paths(payload)
elif isinstance(payload, dict):
# Installed zcode uses patch_text; aliases use the same structured format.
patches = [payload[k] for k in ("patch_text", "patch", "input") if k in payload]
paths = [path_value(payload[k]) for k in ("file_path", "path", "filename") if k in payload]
for patch in patches:
paths.extend(patch_paths(patch))
else:
deny("无法解析写入工具参数")
if not paths:
deny("写入请求未提供可检查路径")
cwd = Path(path_value(data.get("cwd", os.getcwd()))).resolve()
if not cwd.is_dir():
deny("钩子 cwd 不存在")
for path in paths:
check_path(path, cwd)
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, RuntimeError) as error:
print(f"write-guard: 拒绝——{error}", file=sys.stderr)
sys.exit(2)