From 83288b53c25925193ae182dde9636a6b727fe3d1 Mon Sep 17 00:00:00 2001 From: Redlex Gilgamesh Date: Tue, 14 Jul 2026 14:06:08 +0200 Subject: [PATCH] governance: add protected-edit block approvals --- .ai/bin/protected_edit_approval.py | 459 ++++++++++++++++++++++ .ai/bin/validate-ai-sdlc.py | 24 ++ .ai/matrix.json | 15 + .ai/rules/protected-edit-approvals.md | 50 +++ .ai/tests/test_protected_edit_approval.py | 358 +++++++++++++++++ AGENTS.md | 7 + 6 files changed, 913 insertions(+) create mode 100755 .ai/bin/protected_edit_approval.py create mode 100644 .ai/rules/protected-edit-approvals.md create mode 100755 .ai/tests/test_protected_edit_approval.py diff --git a/.ai/bin/protected_edit_approval.py b/.ai/bin/protected_edit_approval.py new file mode 100755 index 0000000..9ee9940 --- /dev/null +++ b/.ai/bin/protected_edit_approval.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Prepare and verify exact manifest-bound protected-edit block approvals.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import os +import re +import stat +import subprocess +import sys +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TextIO + +INDIVIDUAL_PREFIX = "confirm-protected-edit " +BLOCK_PREFIX = "confirm-protected-edit-block " +CATEGORY_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*$") +TICKET_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*-[0-9]+$") +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +AUDIT_LOG_NAME = "protected-edit-approvals.jsonl" + + +class ApprovalError(ValueError): + """Raised when an approval or manifest is invalid.""" + + +@dataclass(frozen=True) +class Manifest: + lines: tuple[str, ...] + content: bytes + sha256: str + + @property + def count(self) -> int: + return len(self.lines) + + +def parse_individual_command(line: str) -> tuple[str, str]: + if not line.startswith(INDIVIDUAL_PREFIX): + raise ApprovalError(f"invalid individual approval command: {line!r}") + remainder = line[len(INDIVIDUAL_PREFIX) :] + category, separator, path = remainder.partition(" ") + if not separator or not CATEGORY_PATTERN.fullmatch(category): + raise ApprovalError(f"invalid protected-edit category: {category!r}") + components = path.split("/") + if ( + not path + or path != path.strip() + or path.startswith("/") + or path.endswith("/") + or "\\" in path + or "\x00" in path + or "\r" in path + or "\n" in path + or any( + component in {"", ".", ".."} or component != component.strip() + for component in components + ) + or re.match(r"^[A-Za-z]:", path) + ): + raise ApprovalError(f"invalid repository-relative path: {path!r}") + return category, path + + +def canonical_manifest(lines: Iterable[str]) -> Manifest: + commands: set[str] = set() + for raw_line in lines: + line = raw_line.rstrip("\r\n") + if not line: + continue + parse_individual_command(line) + commands.add(line) + if not commands: + raise ApprovalError("manifest is empty") + canonical_lines = tuple(sorted(commands, key=lambda value: value.encode("utf-8"))) + content = ("\n".join(canonical_lines) + "\n").encode("utf-8") + return Manifest( + lines=canonical_lines, + content=content, + sha256=hashlib.sha256(content).hexdigest(), + ) + + +def contains_agent_config(manifest: Manifest) -> bool: + for line in manifest.lines: + category, path = parse_individual_command(line) + components = tuple(component.casefold() for component in path.split("/")) + if ( + category == "agent-config" + or ".agents" in components + or components[-1] == "agents.md" + ): + return True + return False + + +def validate_ticket(ticket: str) -> None: + if not TICKET_PATTERN.fullmatch(ticket): + raise ApprovalError(f"invalid ticket: {ticket!r}") + + +def validate_head(head_sha: str) -> None: + if not re.fullmatch(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})", head_sha): + raise ApprovalError(f"invalid Git HEAD SHA: {head_sha!r}") + + +def block_phrase(ticket: str, head_sha: str, manifest: Manifest) -> str: + validate_ticket(ticket) + validate_head(head_sha) + if contains_agent_config(manifest): + raise ApprovalError("block approval is forbidden for agent-config") + return f"{BLOCK_PREFIX}{ticket} {head_sha} {manifest.count} {manifest.sha256}" + + +def verify_block_phrase( + phrase: str, + *, + ticket: str, + head_sha: str, + manifest: Manifest, +) -> None: + expected = block_phrase(ticket, head_sha, manifest) + if phrase != expected: + raise ApprovalError(f"block approval mismatch; expected: {expected}") + + +def git_directory(repository: Path) -> Path: + """Return the repository's trusted, worktree-specific Git metadata directory.""" + metadata = repository.resolve() / ".git" + if metadata.is_symlink(): + raise ApprovalError(f"Git metadata must not be a symlink: {metadata}") + if metadata.is_file(): + declaration = metadata.read_text(encoding="utf-8").strip() + prefix = "gitdir: " + if not declaration.startswith(prefix): + raise ApprovalError(f"invalid Git metadata pointer: {metadata}") + git_dir = Path(declaration[len(prefix) :]) + if not git_dir.is_absolute(): + git_dir = metadata.parent / git_dir + git_dir = git_dir.resolve() + elif metadata.is_dir(): + git_dir = metadata.resolve() + else: + raise ApprovalError(f"Git metadata not found: {metadata}") + if not git_dir.is_dir(): + raise ApprovalError(f"Git metadata directory not found: {git_dir}") + return git_dir + + +def audit_log_path(repository: Path) -> Path: + """Return the fixed audit destination inside worktree-specific Git metadata.""" + return git_directory(repository) / AUDIT_LOG_NAME + + +def append_audit_records( + repository: Path, + *, + phrase: str, + ticket: str, + head_sha: str, + manifest: Manifest, + timestamp: str | None = None, +) -> None: + """Append one durable JSONL record for every approved manifest entry.""" + audit_log = audit_log_path(repository) + if audit_log.is_symlink(): + raise ApprovalError(f"approval audit must not be a symlink: {audit_log}") + recorded_at = timestamp or dt.datetime.now(dt.UTC).isoformat().replace( + "+00:00", "Z" + ) + phrase_sha256 = hashlib.sha256(phrase.encode("utf-8")).hexdigest() + records = [] + for line in manifest.lines: + category, path = parse_individual_command(line) + records.append( + { + "approval_mode": "block", + "approver_phrase_sha256": phrase_sha256, + "category": category, + "head_sha": head_sha, + "manifest_count": manifest.count, + "manifest_sha256": manifest.sha256, + "path": path, + "recorded_at": recorded_at, + "ticket": ticket, + } + ) + payload = "".join( + json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n" + for record in records + ).encode("utf-8") + no_follow = getattr(os, "O_NOFOLLOW", None) + non_block = getattr(os, "O_NONBLOCK", None) + if no_follow is None or non_block is None: + raise ApprovalError("safe nonblocking no-follow audit writes are unavailable") + try: + descriptor = os.open( + audit_log, + os.O_APPEND | os.O_CREAT | os.O_WRONLY | no_follow | non_block, + 0o600, + ) + except OSError as exc: + raise ApprovalError(f"unable to open approval audit: {audit_log}") from exc + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ApprovalError( + f"approval audit must be one regular, unlinked path: {audit_log}" + ) + view = memoryview(payload) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise ApprovalError(f"unable to append approval audit: {audit_log}") + view = view[written:] + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def verify_current_and_audit( + *, + repository: Path, + phrase: str, + ticket: str, + manifest: Manifest, +) -> str: + """Verify against the current HEAD, recheck it, then append audit records.""" + validate_manifest_paths(repository, manifest) + head_sha = current_head(repository) + verify_block_phrase( + phrase, + ticket=ticket, + head_sha=head_sha, + manifest=manifest, + ) + if current_head(repository) != head_sha: + raise ApprovalError("Git HEAD changed during protected-edit verification") + append_audit_records( + repository, + phrase=phrase, + ticket=ticket, + head_sha=head_sha, + manifest=manifest, + ) + return head_sha + + +def current_head(repository: Path) -> str: + git_dir = git_directory(repository) + head = (git_dir / "HEAD").read_text(encoding="utf-8").strip() + if not head.startswith("ref: "): + validate_head(head) + return head + + reference = head[len("ref: ") :] + common_dir = git_dir + common_dir_pointer = git_dir / "commondir" + if common_dir_pointer.is_file(): + common_dir = ( + git_dir / common_dir_pointer.read_text(encoding="utf-8").strip() + ).resolve() + for candidate in (git_dir / reference, common_dir / reference): + if candidate.is_file(): + resolved = candidate.read_text(encoding="utf-8").strip() + validate_head(resolved) + return resolved + packed_refs = common_dir / "packed-refs" + if packed_refs.is_file(): + suffix = f" {reference}" + for line in packed_refs.read_text(encoding="utf-8").splitlines(): + if line.endswith(suffix): + resolved = line.split(" ", 1)[0] + validate_head(resolved) + return resolved + raise ApprovalError(f"unable to resolve Git HEAD reference: {reference}") + + +def read_manifest(source: TextIO) -> Manifest: + return canonical_manifest(source) + + +def validate_manifest_paths(repository: Path, manifest: Manifest) -> None: + root = repository.resolve() + metadata_marker = root / ".git" + metadata_marker_resolved = metadata_marker.resolve() + git_dir = git_directory(repository) + for line in manifest.lines: + _, path = parse_individual_command(line) + if path.split("/", 1)[0].casefold() == ".git": + raise ApprovalError(f"protected-edit path targets Git metadata: {path!r}") + candidate = root + for component in path.split("/"): + candidate /= component + if candidate.is_symlink(): + raise ApprovalError(f"protected-edit path contains a symlink: {path!r}") + if not candidate.exists(): + break + target = (root / path).resolve() + if target == metadata_marker_resolved or ( + target.exists() and target.samefile(metadata_marker) + ): + raise ApprovalError(f"protected-edit path aliases Git metadata: {path!r}") + if target.is_file() and target.stat().st_nlink != 1: + raise ApprovalError( + f"protected-edit path has multiple hard links: {path!r}" + ) + try: + target.relative_to(root) + except ValueError as exc: + raise ApprovalError( + f"protected-edit path escapes repository: {path!r}" + ) from exc + try: + target.relative_to(git_dir) + except ValueError: + continue + raise ApprovalError(f"protected-edit path targets Git metadata: {path!r}") + + +def check_repository(repository: Path) -> None: + policy_path = repository / ".ai/rules/protected-edit-approvals.md" + agents_path = repository / "AGENTS.md" + if not policy_path.is_file(): + raise ApprovalError(f"missing policy: {policy_path}") + if not agents_path.is_file(): + raise ApprovalError(f"missing agent guidance: {agents_path}") + policy = policy_path.read_text(encoding="utf-8") + agents = agents_path.read_text(encoding="utf-8") + required_policy_text = ( + "confirm-protected-edit ", + "confirm-protected-edit-block ", + "sorted by the UTF-8 bytes", + "one final LF", + "lowercase SHA-256", + "agent-config", + "one append-only audit record per manifest", + "one-by-one flow", + "recommendation", + ) + for expected in required_policy_text: + if expected not in policy: + raise ApprovalError(f"policy is missing required text: {expected}") + if ".ai/rules/protected-edit-approvals.md" not in agents: + raise ApprovalError( + "AGENTS.md does not link the protected-edit approval policy" + ) + + +def run_regression_tests(repository: Path) -> None: + test_file = repository / ".ai/tests/test_protected_edit_approval.py" + completed = subprocess.run( + [sys.executable, str(test_file)], + cwd=repository, + check=False, + ) + if completed.returncode != 0: + raise ApprovalError( + f"protected-edit regression tests failed with exit code {completed.returncode}" + ) + + +def self_test() -> None: + head_sha = "a" * 40 + manifest = canonical_manifest( + [ + "confirm-protected-edit package-versions z.csproj", + "confirm-protected-edit auth a.cs", + "confirm-protected-edit auth a.cs", + ] + ) + if manifest.count != 2 or manifest.content[-1:] != b"\n": + raise ApprovalError("canonical manifest self-test failed") + phrase = block_phrase("DEV-1", head_sha, manifest) + verify_block_phrase( + phrase, + ticket="DEV-1", + head_sha=head_sha, + manifest=manifest, + ) + agent_config = canonical_manifest( + ["confirm-protected-edit agent-config .agents/agents/coder.md"] + ) + try: + block_phrase("DEV-1", head_sha, agent_config) + except ApprovalError: + return + raise ApprovalError("agent-config block rejection self-test failed") + + +def open_input(path: str) -> TextIO: + if path == "-": + return sys.stdin + return Path(path).open("r", encoding="utf-8", newline="") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + prepare = subparsers.add_parser( + "prepare", help="print a canonical manifest and block phrase" + ) + prepare.add_argument("--ticket", required=True) + prepare.add_argument("--repository", type=Path, default=Path.cwd()) + prepare.add_argument("--input", default="-") + + verify = subparsers.add_parser("verify", help="verify an exact block phrase") + verify.add_argument("--ticket", required=True) + verify.add_argument("--repository", type=Path, default=Path.cwd()) + verify.add_argument("--input", default="-") + verify.add_argument("--phrase", required=True) + + check = subparsers.add_parser( + "check-repository", help="validate repository policy wiring" + ) + check.add_argument("--repository", type=Path, default=Path.cwd()) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "check-repository": + check_repository(args.repository.resolve()) + print("Protected-edit approval policy validation passed") + return 0 + + with open_input(args.input) as source: + manifest = read_manifest(source) + if args.command == "prepare": + validate_manifest_paths(args.repository, manifest) + head_sha = current_head(args.repository) + phrase = block_phrase(args.ticket, head_sha, manifest) + sys.stdout.buffer.write(manifest.content) + print(f"COUNT: {manifest.count}") + print(f"MANIFEST_SHA256: {manifest.sha256}") + print(f"BLOCK_PHRASE: {phrase}") + return 0 + verify_current_and_audit( + repository=args.repository, + phrase=args.phrase, + ticket=args.ticket, + manifest=manifest, + ) + print("Protected-edit block approval verified") + return 0 + except (ApprovalError, OSError, UnicodeError) as exc: + print(f"FAIL: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.ai/bin/validate-ai-sdlc.py b/.ai/bin/validate-ai-sdlc.py index 3c38161..3f747e5 100755 --- a/.ai/bin/validate-ai-sdlc.py +++ b/.ai/bin/validate-ai-sdlc.py @@ -5,7 +5,21 @@ import sys from pathlib import Path +from protected_edit_approval import ( + ApprovalError as ProtectedEditApprovalError, +) +from protected_edit_approval import ( + check_repository as check_protected_edit_repository, +) +from protected_edit_approval import ( + run_regression_tests as run_protected_edit_regression_tests, +) +from protected_edit_approval import ( + self_test as protected_edit_self_test, +) + ROOT = Path(__file__).resolve().parents[2] + REQUIRED = [ ".ai/matrix.json", ".ai/init/repo-profile.json", @@ -39,6 +53,9 @@ "CLAUDE.md", "GEMINI.md", "CONTRIBUTING.md", + ".ai/rules/protected-edit-approvals.md", + ".ai/bin/protected_edit_approval.py", + ".ai/tests/test_protected_edit_approval.py", ] @@ -108,4 +125,11 @@ def fail(message: str) -> None: if word not in review: fail(f"missing review keyword {word}") +try: + check_protected_edit_repository(ROOT) + protected_edit_self_test() + run_protected_edit_regression_tests(ROOT) +except ProtectedEditApprovalError as exc: + fail(f"Protected-edit approval harness failed: {exc}") + print("AI-SDLC validation passed") diff --git a/.ai/matrix.json b/.ai/matrix.json index 254a3c5..8ea795d 100644 --- a/.ai/matrix.json +++ b/.ai/matrix.json @@ -15,6 +15,21 @@ "mode": "physical-copy", "path": ".ai/workflows/repo-workflow.md", "source": "." + }, + { + "mode": "physical-copy", + "path": ".ai/rules/protected-edit-approvals.md", + "source": "r3dlex/rib-workspace" + }, + { + "mode": "physical-copy", + "path": ".ai/bin/protected_edit_approval.py", + "source": "r3dlex/rib-workspace" + }, + { + "mode": "physical-copy", + "path": ".ai/tests/test_protected_edit_approval.py", + "source": "r3dlex/rib-workspace" } ], "max_allowed_depth": 0, diff --git a/.ai/rules/protected-edit-approvals.md b/.ai/rules/protected-edit-approvals.md new file mode 100644 index 0000000..c70571c --- /dev/null +++ b/.ai/rules/protected-edit-approvals.md @@ -0,0 +1,50 @@ +# Protected Edit Approvals + +Protected edits require explicit approval before the first edit. The recommended +mode is one exact command per matched `(category, path)` pair: + +```text +confirm-protected-edit +``` + +## Optional block approval + +A large protected set may use one block command after the complete canonical +manifest is shown: + +```text +confirm-protected-edit-block +``` + +The block command approves only the displayed manifest for the exact ticket and +Git HEAD. It is not a wildcard, prefix, category-wide grant, or an interpretation +of "approve all". + +The canonical manifest uses these rules: + +- UTF-8 without a byte-order mark. +- One `confirm-protected-edit ` line per matched pair. +- Exact duplicates removed. +- Lines sorted by the UTF-8 bytes of the complete line. +- LF line endings and one final LF. +- A lowercase SHA-256 over those exact bytes. + +The approval consumer supplies the complete currently unapproved set to +`verify` immediately before acceptance. The verifier canonicalizes that set +again, resolves the repository HEAD itself, and rejects any ticket, HEAD, +count, hash, entry-set, or mid-verification HEAD mismatch. A manifest containing +`agent-config` must never use block approval. Agent configuration always +requires individual approval. + +Each accepted block expands into one append-only audit record per manifest +entry before editing begins. Each record keeps the normal category, path, HEAD, +timestamp, and approver-phrase hash. It also records block mode, ticket, +manifest count, and manifest SHA-256. The audit destination is fixed inside the +worktree-specific Git metadata. A caller cannot select or redirect it. Symlink, +hard-link, non-regular-file, and Git-metadata manifest targets fail closed. + +Use `.ai/bin/protected_edit_approval.py prepare` to produce a canonical manifest +and exact block phrase. Use `verify` to validate a supplied block phrase against +the current unapproved manifest and current HEAD, then append its per-entry audit +records to the fixed Git-metadata log before returning success. The one-by-one flow +remains the recommendation because it has the smallest approval scope. diff --git a/.ai/tests/test_protected_edit_approval.py b/.ai/tests/test_protected_edit_approval.py new file mode 100755 index 0000000..20f8ea3 --- /dev/null +++ b/.ai/tests/test_protected_edit_approval.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / ".ai/bin")) +import protected_edit_approval as policy # noqa: E402 + + +class CanonicalManifestTests(unittest.TestCase): + def test_sorts_by_utf8_bytes_removes_duplicates_and_adds_final_lf(self) -> None: + manifest = policy.canonical_manifest( + [ + "confirm-protected-edit package-versions z.csproj\n", + "confirm-protected-edit auth a.cs\r\n", + "confirm-protected-edit auth a.cs\n", + ] + ) + self.assertEqual( + manifest.lines, + ( + "confirm-protected-edit auth a.cs", + "confirm-protected-edit package-versions z.csproj", + ), + ) + self.assertEqual(manifest.content[-1:], b"\n") + self.assertEqual(len(manifest.sha256), 64) + + def test_rejects_noncanonical_or_escaping_paths(self) -> None: + invalid_paths = ( + "/absolute.cs", + "../outside.cs", + "backend/../outside.cs", + "./backend/a.cs", + "backend\\a.cs", + "C:\\Windows\\system32\\drivers\\etc\\hosts", + " leading-space.cs", + "trailing-space.cs ", + "backend/ leading-space.cs", + "backend/trailing-space.cs ", + "backend/ /a.cs", + "backend//a.cs", + "backend/", + ) + for path in invalid_paths: + with self.subTest(path=path), self.assertRaises(policy.ApprovalError): + policy.canonical_manifest([f"confirm-protected-edit auth {path}"]) + + def test_rejects_empty_manifest(self) -> None: + with self.assertRaises(policy.ApprovalError): + policy.canonical_manifest([]) + + +class BlockApprovalTests(unittest.TestCase): + def setUp(self) -> None: + self.manifest = policy.canonical_manifest( + ["confirm-protected-edit auth backend/PermissionLogic.cs"] + ) + self.head = "a" * 40 + + def test_verifies_exact_ticket_head_count_hash_and_entries(self) -> None: + phrase = policy.block_phrase("DEV-71765", self.head, self.manifest) + policy.verify_block_phrase( + phrase, + ticket="DEV-71765", + head_sha=self.head, + manifest=self.manifest, + ) + + def test_rejects_stale_head(self) -> None: + phrase = policy.block_phrase("DEV-71765", self.head, self.manifest) + with self.assertRaises(policy.ApprovalError): + policy.verify_block_phrase( + phrase, + ticket="DEV-71765", + head_sha="b" * 40, + manifest=self.manifest, + ) + + def test_rejects_changed_entry_set(self) -> None: + phrase = policy.block_phrase("DEV-71765", self.head, self.manifest) + changed = policy.canonical_manifest( + [ + "confirm-protected-edit auth backend/PermissionLogic.cs", + "confirm-protected-edit package-versions backend/IdentityServer.csproj", + ] + ) + with self.assertRaises(policy.ApprovalError): + policy.verify_block_phrase( + phrase, + ticket="DEV-71765", + head_sha=self.head, + manifest=changed, + ) + + def test_forbids_agent_config_in_block_mode(self) -> None: + commands = ( + "confirm-protected-edit agent-config config/coder.md", + "confirm-protected-edit auth .agents/agents/coder.md", + "confirm-protected-edit auth .AGENTS/agents/coder.md", + "confirm-protected-edit auth AGENTS.md", + "confirm-protected-edit auth llm/AGENTS.md", + "confirm-protected-edit auth subproject/.agents/agents/coder.md", + "confirm-protected-edit auth subproject/.AGENTS/agents/coder.md", + ) + for command in commands: + manifest = policy.canonical_manifest([command]) + with self.subTest(command=command), self.assertRaises(policy.ApprovalError): + policy.block_phrase("DEV-71765", self.head, manifest) + + def test_verify_current_head_appends_one_audit_record_per_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + git_dir = repository / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text(self.head + "\n", encoding="utf-8") + manifest = policy.canonical_manifest( + [ + "confirm-protected-edit auth backend/PermissionLogic.cs", + "confirm-protected-edit package-versions backend/IdentityServer.csproj", + ] + ) + phrase = policy.block_phrase("DEV-71765", self.head, manifest) + resolved = policy.verify_current_and_audit( + repository=repository, + phrase=phrase, + ticket="DEV-71765", + manifest=manifest, + ) + + self.assertEqual(resolved, self.head) + audit_log = policy.audit_log_path(repository) + self.assertEqual( + audit_log, + repository.resolve() / ".git/protected-edit-approvals.jsonl", + ) + records = [ + json.loads(line) + for line in audit_log.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual(len(records), 2) + self.assertEqual( + {record["path"] for record in records}, + { + "backend/PermissionLogic.cs", + "backend/IdentityServer.csproj", + }, + ) + self.assertTrue(all(record["head_sha"] == self.head for record in records)) + + def test_cli_rejects_head_and_audit_log_overrides(self) -> None: + parser = policy.build_parser() + rejected_options = ( + ("--head", "a" * 40), + ("--audit-log", "AGENTS.md"), + ("--audit-log", ".agents/state/audit.jsonl"), + ("--audit-log", "backend/PermissionLogic.cs"), + ("--audit-log", "/tmp/audit.jsonl"), + ("--audit-log", "../audit.jsonl"), + ("--audit-log", "linked/audit.jsonl"), + ) + for option, value in rejected_options: + with ( + self.subTest(option=option, value=value), + contextlib.redirect_stderr(io.StringIO()), + self.assertRaises(SystemExit), + ): + parser.parse_args( + [ + "verify", + "--ticket", + "DEV-71765", + "--phrase", + "unused", + option, + value, + ] + ) + + def test_rejects_symlink_at_fixed_audit_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + git_dir = repository / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text(self.head + "\n", encoding="utf-8") + agents = repository / "AGENTS.md" + agents.write_text("protected\n", encoding="utf-8") + policy.audit_log_path(repository).symlink_to(agents) + phrase = policy.block_phrase("DEV-71765", self.head, self.manifest) + + with self.assertRaises(policy.ApprovalError): + policy.verify_current_and_audit( + repository=repository, + phrase=phrase, + ticket="DEV-71765", + manifest=self.manifest, + ) + + self.assertEqual(agents.read_text(encoding="utf-8"), "protected\n") + + def test_rejects_fifo_at_fixed_audit_path_without_blocking(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + git_dir = repository / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text(self.head + "\n", encoding="utf-8") + os.mkfifo(policy.audit_log_path(repository)) + phrase = policy.block_phrase("DEV-71765", self.head, self.manifest) + + completed = subprocess.run( + [ + sys.executable, + str(ROOT / ".ai/bin/protected_edit_approval.py"), + "verify", + "--ticket", + "DEV-71765", + "--repository", + str(repository), + "--phrase", + phrase, + ], + input=self.manifest.content, + capture_output=True, + timeout=1, + ) + + self.assertEqual(completed.returncode, 1) + self.assertIn(b"FAIL: unable to open approval audit", completed.stderr) + + def test_rejects_git_metadata_manifest_targets(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + git_dir = repository / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text(self.head + "\n", encoding="utf-8") + for path in ( + ".git", + ".git/protected-edit-approvals.jsonl", + ".GIT", + ".GIT/HEAD", + ): + manifest = policy.canonical_manifest( + [f"confirm-protected-edit auth {path}"] + ) + with self.subTest(path=path), self.assertRaises(policy.ApprovalError): + policy.validate_manifest_paths(repository, manifest) + + def test_rejects_git_metadata_manifest_targets_in_linked_worktree(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + repository = root / "worktree" + repository.mkdir() + git_dir = root / "common/worktrees/feature" + git_dir.mkdir(parents=True) + (repository / ".git").write_text( + f"gitdir: {git_dir}\n", + encoding="utf-8", + ) + (git_dir / "HEAD").write_text(self.head + "\n", encoding="utf-8") + for path in ( + ".git", + ".git/protected-edit-approvals.jsonl", + ".GIT", + ".GIT/HEAD", + ): + manifest = policy.canonical_manifest( + [f"confirm-protected-edit auth {path}"] + ) + with self.subTest(path=path), self.assertRaises(policy.ApprovalError): + policy.validate_manifest_paths(repository, manifest) + + def test_rejects_linked_worktree_git_metadata_aliases(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + repository = root / "worktree" + repository.mkdir() + git_dir = root / "common/worktrees/feature" + git_dir.mkdir(parents=True) + marker = repository / ".git" + marker.write_text(f"gitdir: {git_dir}\n", encoding="utf-8") + head = git_dir / "HEAD" + head.write_text(self.head + "\n", encoding="utf-8") + (repository / "metadata-link").symlink_to(".git") + os.link(marker, repository / "metadata-hardlink") + os.link(head, repository / "head-hardlink") + (repository / "inside-target").write_text("target\n", encoding="utf-8") + (repository / "inside-link").symlink_to("inside-target") + + for path in ( + "metadata-link", + "metadata-hardlink", + "head-hardlink", + "inside-link", + ): + manifest = policy.canonical_manifest( + [f"confirm-protected-edit auth {path}"] + ) + with self.subTest(path=path), self.assertRaises(policy.ApprovalError): + policy.validate_manifest_paths(repository, manifest) + + def test_rejects_symlink_escape_from_repository(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + repository = root / "repository" + outside = root / "outside" + repository.mkdir() + outside.mkdir() + (repository / "linked").symlink_to(outside, target_is_directory=True) + manifest = policy.canonical_manifest( + ["confirm-protected-edit auth linked/outside.cs"] + ) + + with self.assertRaises(policy.ApprovalError): + policy.validate_manifest_paths(repository, manifest) + + +class GitHeadTests(unittest.TestCase): + def test_reads_direct_head(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + git_dir = repository / ".git" + git_dir.mkdir() + expected = "a" * 40 + (git_dir / "HEAD").write_text(expected + "\n", encoding="utf-8") + self.assertEqual(policy.current_head(repository), expected) + + def test_reads_worktree_head_from_common_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + repository = root / "worktree" + repository.mkdir() + common = root / "common" + git_dir = common / "worktrees/feature" + git_dir.mkdir(parents=True) + (repository / ".git").write_text( + f"gitdir: {git_dir}\n", + encoding="utf-8", + ) + (git_dir / "HEAD").write_text("ref: refs/heads/feature\n", encoding="utf-8") + (git_dir / "commondir").write_text("../..\n", encoding="utf-8") + reference = common / "refs/heads/feature" + reference.parent.mkdir(parents=True) + expected = "b" * 40 + reference.write_text(expected + "\n", encoding="utf-8") + self.assertEqual(policy.current_head(repository), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/AGENTS.md b/AGENTS.md index 95098ec..3732d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,13 @@ uv run mypy src/ --ignore-missing-imports # 0 errors This repo follows the v3 AI-SDLC layout. See `.ai/matrix.json`, `.memory/human-override/`, and `docs/architecture/adr/`. Modules at `r3dlex/skills/ai-sdlc-init/modules/`. +## Protected edit approvals + +The shared contract is `.ai/rules/protected-edit-approvals.md`. Individual +approval remains recommended. Block approval is optional only for an exact, +manifest-bound large set at the current Git HEAD. It never applies to +`agent-config`. + ## AI-SDLC v3 governance