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
101 lines
5.0 KiB
Python
101 lines
5.0 KiB
Python
"""Exercise the actual hook against disposable repositories; never write on real main."""
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
GUARD = Path(__file__).resolve().parents[1] / "scripts/zcode/write-guard.sh"
|
|
|
|
|
|
class WriteGuardTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.base = Path(self.temp.name)
|
|
self.repo = self.base / "repo"
|
|
self.repo.mkdir()
|
|
self.git("init", "-q", "-b", "main")
|
|
self.git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
|
|
"commit", "-q", "--allow-empty", "-m", "Initial")
|
|
|
|
def git(self, *args):
|
|
return subprocess.run(["git", "-C", str(self.repo), *args], check=True,
|
|
capture_output=True, text=True).stdout.strip()
|
|
|
|
def invoke(self, payload, cwd=None):
|
|
return subprocess.run(["bash", str(GUARD)], input=json.dumps(payload),
|
|
cwd=cwd or self.repo, capture_output=True, text=True)
|
|
|
|
def write(self, path, **kw):
|
|
return self.invoke({"tool_name": "Write", "tool_input": {"file_path": str(path)}, **kw})
|
|
|
|
def test_missing_parent_on_main_is_denied(self):
|
|
self.assertEqual(self.write(self.repo / "new/deep/file.txt").returncode, 2)
|
|
|
|
def test_relative_path_uses_hook_cwd_not_process_cwd(self):
|
|
self.assertEqual(self.invoke({"tool_name": "Write", "cwd": str(self.repo),
|
|
"tool_input": {"file_path": "new/file.txt"}},
|
|
cwd=self.base).returncode, 2)
|
|
|
|
def test_symlink_into_main_with_missing_parents_is_denied(self):
|
|
(self.base / "alias").symlink_to(self.repo, target_is_directory=True)
|
|
self.assertEqual(self.write(self.base / "alias/new/file.txt").returncode, 2)
|
|
|
|
def test_deleting_main_symlink_to_outside_is_denied(self):
|
|
(self.repo / "link").symlink_to(self.base / "outside")
|
|
patch = "*** Begin Patch\n*** Delete File: link\n*** End Patch"
|
|
result = self.invoke({"tool_name": "ApplyPatch", "tool_input": {"patch_text": patch}})
|
|
self.assertEqual(result.returncode, 2, result.stderr)
|
|
|
|
def test_feature_missing_parent_is_allowed(self):
|
|
self.git("switch", "-q", "-c", "feature/fixture")
|
|
self.assertEqual(self.write(self.repo / "new/deep/file.txt").returncode, 0)
|
|
|
|
def test_detached_and_bad_branches_are_denied(self):
|
|
for ref in ("--detach", "-c"):
|
|
self.git("switch", "-q", ref, "HEAD" if ref == "--detach" else "bad-branch")
|
|
self.assertEqual(self.write(self.repo / "new.txt").returncode, 2)
|
|
|
|
def test_non_git_destination_is_explicitly_outside_scope(self):
|
|
result = self.write(self.base / "notes/new.txt")
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("outside-git", result.stderr)
|
|
|
|
def test_every_patch_file_and_move_destination_is_checked(self):
|
|
self.git("switch", "-q", "-c", "feature/fixture")
|
|
other = self.base / "protected"
|
|
subprocess.run(["git", "init", "-q", "-b", "main", str(other)], check=True)
|
|
for operation in (f"*** Add File: {other}/new/deep.txt\n+x",
|
|
f"*** Update File: {self.repo}/old.txt\n*** Move to: {other}/new.txt\n@@\n-a\n+b"):
|
|
patch = f"*** Begin Patch\n*** Add File: {self.repo}/safe.txt\n+ok\n{operation}\n*** End Patch"
|
|
result = self.invoke({"tool_name": "ApplyPatch", "tool_input": {"patch_text": patch}})
|
|
self.assertEqual(result.returncode, 2, result.stderr)
|
|
|
|
def test_valid_multi_file_patch_on_feature_is_allowed(self):
|
|
self.git("switch", "-q", "-c", "fix/fixture")
|
|
patch = "*** Begin Patch\n*** Add File: new/a.txt\n+a\n*** Delete File: b.txt\n*** End Patch"
|
|
self.assertEqual(self.invoke({"tool_name": "ApplyPatch", "tool_input": {"patch_text": patch}}).returncode, 0)
|
|
|
|
def test_missing_unknown_and_malformed_write_input_are_denied(self):
|
|
for payload in ({"tool_name": "Write", "tool_input": {}},
|
|
{"tool_name": "ApplyPatch", "tool_input": {"patch_text": "garbage"}},
|
|
{"tool_name": "ApplyPatch", "tool_input": {"patch_text": "*** Begin Patch\n*** Add File: a\n+a\n*** Unexpected: b\n*** End Patch"}},
|
|
{"tool_name": "Write", "tool_input": {"file_path": 12}},
|
|
{"tool_name": "UnknownWriter", "tool_input": {"file_path": "a"}}):
|
|
with self.subTest(payload=payload):
|
|
self.assertEqual(self.invoke(payload).returncode, 2)
|
|
|
|
def test_invalid_json_is_denied(self):
|
|
result = subprocess.run(["bash", str(GUARD)], input="not-json", cwd=self.repo,
|
|
capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 2)
|
|
|
|
def test_nonwrite_tools_are_outside_scope(self):
|
|
self.assertEqual(self.invoke({"tool_name": "Read", "tool_input": {"file_path": "a"}}).returncode, 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|