Harden controller review and runtime gates

This commit is contained in:
2026-09-22 10:37:06 -04:00
parent 68e2f63e9a
commit 6d08fddac1
10 changed files with 199 additions and 90 deletions
-5
View File
@@ -8,11 +8,6 @@ permission:
task: deny task: deny
bash: bash:
"*": deny "*": deny
"git status*": allow
"git diff*": allow
"git log*": allow
"git show*": allow
"git rev-parse*": allow
"/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow "/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow
external_directory: external_directory:
"*": ask "*": ask
+4 -2
View File
@@ -30,8 +30,10 @@ The NESTQUEST_CONTROL_HOME environment variable names the installed launchers
(normally /home/overseer/.local/share/nestquest-controller). (normally /home/overseer/.local/share/nestquest-controller).
For implementation, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role For implementation, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role
developer, a task worktree, title, and task prompt file. For independent developer, a task worktree, title, and task prompt file. For independent
review, run "$NESTQUEST_CONTROL_HOME/scripts/nq-review" in that task worktree, review, run "$NESTQUEST_CONTROL_HOME/scripts/nq-review" with the worktree,
against the feature branch and the exact current head. The developer may merge feature base ref, exact head ref, review prompt file, PR number, and an output
file under /tmp/opencode. It publishes an exact-head Gitea approval only after
Claude approves. The developer may merge
a task PR into its feature branch only after independent approval of the exact a task PR into its feature branch only after independent approval of the exact
head and verification of every task gate. For feature-to-dev approval and head and verification of every task gate. For feature-to-dev approval and
merge, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role approver in merge, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role approver in
+15 -6
View File
@@ -12,11 +12,13 @@ open for the NestQuest checkout.
`scripts/nq-agent` launches developer and approver sessions with fixed agent `scripts/nq-agent` launches developer and approver sessions with fixed agent
definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the
global idle-notification plugin does not notify on every controller tick. global idle-notification plugin does not notify on every controller tick.
The approver's shell permission permits read-only Git commands and only the The approver's shell permission permits only the installed
installed `nq-approve-merge` broker for Gitea. That broker requires exact-head `nq-approve-merge` broker for Gitea. That broker requires exact-head
Claude approval evidence, the `dev` target, a mergeable PR, and passing Claude approval evidence, an exact-head Gitea approval by `review_agent`,
configured checks before it requests a squash merge. `scripts/nq-review` launches the independent the `dev` target, a mergeable PR, and passing configured checks before it
Claude review in the task worktree and checks the exact head before review. requests a squash merge. `scripts/nq-review` launches the independent
Claude review in the task worktree, checks the head again afterward, saves
the output under `/tmp/opencode`, and posts the Gitea approval.
The approver verifies the reviewed head again before a merge. The approver verifies the reviewed head again before a merge.
The production service uses a stable copy of this configuration and scripts The production service uses a stable copy of this configuration and scripts
@@ -25,13 +27,20 @@ which task branch is checked out in the primary NestQuest directory. Install
that copy, run `scripts/nq-controller --check`, and verify all three agents that copy, run `scripts/nq-controller --check`, and verify all three agents
with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the
installed `.opencode` directory. Install `deploy/nestquest-controller.service` installed `.opencode` directory. Install `deploy/nestquest-controller.service`
under `/etc/systemd/system/`, then enable it. The service waits for an open under `~/.config/systemd/user/`, then enable it with `systemctl --user`.
The host has lingering enabled for the `overseer` user. The service waits for an open
interactive NestQuest session to close before starting work. interactive NestQuest session to close before starting work.
The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt. The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt.
It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs
to a different local store and lacks this project. to a different local store and lacks this project.
All four agent credentials are currently readable by the same Unix account.
The Gitea review and merge gates enforce normal workflow sequencing, but
they are not a hard isolation boundary against a process running as that
account. Strong isolation requires separate OS identities and protected
credentials for the controller, reviewer, and approver.
A tick has an explicit six-hour timeout. On timeout or an OpenCode error, the A tick has an explicit six-hour timeout. On timeout or an OpenCode error, the
next tick must reconcile external state before acting. Three consecutive next tick must reconcile external state before acting. Three consecutive
failed ticks stop the service. A BLOCKED verdict also stops it. QUIESCENT failed ticks stop the service. A BLOCKED verdict also stops it. QUIESCENT
+1 -6
View File
@@ -1,13 +1,8 @@
[Unit] [Unit]
Description=NestQuest OpenCode controller Description=NestQuest OpenCode controller
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=600
StartLimitBurst=5
[Service] [Service]
Type=simple Type=simple
User=overseer
WorkingDirectory=/mnt/ai-storage/projects/NestQuest WorkingDirectory=/mnt/ai-storage/projects/NestQuest
ExecStartPre=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller --check ExecStartPre=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller --check
ExecStart=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller ExecStart=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller
@@ -19,4 +14,4 @@ KillMode=control-group
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
[Install] [Install]
WantedBy=multi-user.target WantedBy=default.target
@@ -2,6 +2,7 @@ from pathlib import Path
import runpy import runpy
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch
APPROVE = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-approve-merge")) APPROVE = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-approve-merge"))
@@ -22,6 +23,19 @@ class MergeGateTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "exact head"): with self.assertRaisesRegex(RuntimeError, "exact head"):
APPROVE["validate_review"](review, HEAD) APPROVE["validate_review"](review, HEAD)
def test_requires_distinct_gitea_reviewer_on_exact_commit(self):
records = [{"state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}}]
with patch.dict(APPROVE["has_platform_review"].__globals__, {"request": lambda *_: records}):
self.assertTrue(APPROVE["has_platform_review"](149, HEAD, "unused"))
self.assertFalse(APPROVE["has_platform_review"](149, "b" * 40, "unused"))
records[0]["user"]["login"] = "orchestrator-agent"
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
records[:] = [
{"id": 1, "state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}},
{"id": 2, "state": "REQUEST_CHANGES", "commit_id": HEAD, "user": {"login": "review_agent"}},
]
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -19,6 +19,8 @@ class ControllerProtocolTests(unittest.TestCase):
def test_error_event_does_not_publish_provider_message(self): def test_error_event_does_not_publish_provider_message(self):
event = {"type": "error", "error": {"name": "APIError", "data": {"message": "secret provider response"}}} event = {"type": "error", "error": {"name": "APIError", "data": {"message": "secret provider response"}}}
self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (None, "APIError")) self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (None, "APIError"))
self.assertEqual(CONTROL["parse_event"]("null"), (None, None))
self.assertEqual(CONTROL["parse_event"]("[]"), (None, None))
def test_tick_uses_final_text(self): def test_tick_uses_final_text(self):
event = json.dumps({"type": "text", "part": {"type": "text", "text": "ORCHESTRATOR_STATE: QUIESCENT\n"}}) event = json.dumps({"type": "text", "part": {"type": "text", "text": "ORCHESTRATOR_STATE: QUIESCENT\n"}})
@@ -53,6 +55,11 @@ class ControllerProtocolTests(unittest.TestCase):
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database)) self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0") (entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0")
self.assertFalse(CONTROL["interactive_nestquest_session"](project, proc, database)) self.assertFalse(CONTROL["interactive_nestquest_session"](project, proc, database))
with sqlite3.connect(database) as db:
db.execute("DROP TABLE session")
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
with self.assertRaisesRegex(RuntimeError, "lookup failed"):
CONTROL["interactive_nestquest_session"](project, proc, database)
if __name__ == "__main__": if __name__ == "__main__":
+9 -35
View File
@@ -1,43 +1,17 @@
from pathlib import Path from pathlib import Path
import os import runpy
import subprocess
import tempfile
import unittest import unittest
REVIEW = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-review"))
SCRIPT = Path(__file__).resolve().parents[1] / "scripts/nq-review" HEAD = "a" * 40
class ReviewLauncherTests(unittest.TestCase): class ReviewLauncherTests(unittest.TestCase):
def test_pins_head_and_model_in_worktree(self): def test_exact_head_verdict(self):
with tempfile.TemporaryDirectory() as directory: self.assertEqual(REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n", HEAD), "APPROVED")
root = Path(directory) with self.assertRaisesRegex(RuntimeError, "one verdict"):
repo = root / "repo" REVIEW["verdict"](f"REVIEWED_HEAD: {'b' * 40}\nREVIEW_VERDICT: APPROVED\n", HEAD)
repo.mkdir() with self.assertRaisesRegex(RuntimeError, "one verdict"):
subprocess.run(["git", "init", "-q", str(repo)], check=True) REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\nREVIEW_VERDICT: CHANGES_REQUIRED\n", HEAD)
subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.invalid"], check=True)
subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test"], check=True)
(repo / "README").write_text("initial\n")
subprocess.run(["git", "-C", str(repo), "add", "README"], check=True)
subprocess.run(["git", "-C", str(repo), "commit", "-qm", "initial"], check=True)
head = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
prompt = root / "review.txt"
prompt.write_text("Review this change.")
stub = root / "claude"
stub.write_text("#!/bin/sh\nprintf '%s\\n' \"$PWD\" \"$@\"\n")
stub.chmod(0o700)
env = os.environ.copy()
env["CLAUDE_BIN"] = str(stub)
result = subprocess.run([str(SCRIPT), str(repo), head, head, str(prompt)], env=env, text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(str(repo), result.stdout)
self.assertIn("--model\nclaude-sonnet-5", result.stdout)
self.assertIn("--effort\nhigh", result.stdout)
self.assertIn(head, result.stdout)
result = subprocess.run([str(SCRIPT), str(repo), head, "HEAD~1", str(prompt)], env=env, text=True, capture_output=True)
self.assertNotEqual(result.returncode, 0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+19 -1
View File
@@ -25,7 +25,7 @@ def token_from_file() -> str:
raise RuntimeError("GITEA_APPROVER_AGENT_TOKEN is unavailable") raise RuntimeError("GITEA_APPROVER_AGENT_TOKEN is unavailable")
def request(path: str, token: str, payload: dict | None = None) -> dict: def request(path: str, token: str, payload: dict | None = None) -> dict | list:
body = None if payload is None else json.dumps(payload).encode() body = None if payload is None else json.dumps(payload).encode()
headers = {"Authorization": "token " + token, "Accept": "application/json"} headers = {"Authorization": "token " + token, "Accept": "application/json"}
if body is not None: if body is not None:
@@ -68,6 +68,22 @@ def validate_review(path: Path, head: str) -> None:
raise RuntimeError("independent review does not approve this exact head") raise RuntimeError("independent review does not approve this exact head")
def has_platform_review(pr_number: int, head: str, token: str) -> bool:
reviews = request(f"/pulls/{pr_number}/reviews", token)
if not isinstance(reviews, list):
raise RuntimeError("Gitea returned invalid review records")
by_reviewer = [
review for review in reviews
if isinstance(review, dict)
and (review.get("user") or {}).get("login") == "review_agent"
and review.get("commit_id") == head
]
if not by_reviewer:
return False
latest = max(by_reviewer, key=lambda review: int(review.get("id") or 0))
return latest.get("state") == "APPROVED"
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("operation", choices=("inspect", "merge")) parser.add_argument("operation", choices=("inspect", "merge"))
@@ -87,6 +103,8 @@ def main() -> int:
if not FULL_SHA.fullmatch(args.reviewed_head): if not FULL_SHA.fullmatch(args.reviewed_head):
parser.error("reviewed head must be a full SHA") parser.error("reviewed head must be a full SHA")
validate_review(args.review_file, args.reviewed_head) validate_review(args.review_file, args.reviewed_head)
if not has_platform_review(args.pr, args.reviewed_head, token):
raise RuntimeError("Gitea lacks an exact-head approval by review_agent")
if before["base"] != "dev" or before["head"] != args.reviewed_head: if before["base"] != "dev" or before["head"] != args.reviewed_head:
raise RuntimeError("PR target or current head differs from the approved review") raise RuntimeError("PR target or current head differs from the approved review")
if before["state"] != "open" or before["merged"] or before["mergeable"] is not True: if before["state"] != "open" or before["merged"] or before["mergeable"] is not True:
+25 -6
View File
@@ -54,8 +54,8 @@ def interactive_nestquest_session(
"""Avoid racing a user-owned OpenCode TUI for the same repository.""" """Avoid racing a user-owned OpenCode TUI for the same repository."""
try: try:
db = sqlite3.connect(f"file:{database}?mode=ro", uri=True) db = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
except sqlite3.Error: except sqlite3.Error as error:
return False raise RuntimeError("OpenCode session database is unavailable") from error
try: try:
for entry in proc_root.iterdir(): for entry in proc_root.iterdir():
if not entry.name.isdecimal() or int(entry.name) == os.getpid(): if not entry.name.isdecimal() or int(entry.name) == os.getpid():
@@ -65,7 +65,7 @@ def interactive_nestquest_session(
args = [a.decode(errors="replace") for a in args if a] args = [a.decode(errors="replace") for a in args if a]
if not args or Path(args[0]).name != "opencode": if not args or Path(args[0]).name != "opencode":
continue continue
if any(a in ("run", "debug", "serve", "web") for a in args[1:]): if len(args) > 1 and args[1] in ("run", "debug", "serve", "web"):
continue continue
session = None session = None
for flag in ("-s", "--session"): for flag in ("-s", "--session"):
@@ -81,6 +81,10 @@ def interactive_nestquest_session(
return True return True
elif (entry / "cwd").resolve() == project.resolve(): elif (entry / "cwd").resolve() == project.resolve():
return True return True
except sqlite3.Error as error:
raise RuntimeError("OpenCode session lookup failed") from error
except TypeError as error:
raise RuntimeError("OpenCode session directory is invalid") from error
except (OSError, PermissionError): except (OSError, PermissionError):
continue continue
finally: finally:
@@ -93,6 +97,8 @@ def parse_event(raw: str) -> tuple[str | None, str | None]:
event = json.loads(raw) event = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
return None, None return None, None
if not isinstance(event, dict):
return None, None
kind = event.get("type") kind = event.get("type")
part = event.get("part") or {} part = event.get("part") or {}
if kind == "text" and isinstance(part, dict): if kind == "text" and isinstance(part, dict):
@@ -192,11 +198,16 @@ def main() -> int:
ctxd_version = check_ctxd() ctxd_version = check_ctxd()
except (OSError, KeyError, ValueError, RuntimeError) as error: except (OSError, KeyError, ValueError, RuntimeError) as error:
print(f"CTXD preflight failed: {type(error).__name__}", file=sys.stderr) print(f"CTXD preflight failed: {type(error).__name__}", file=sys.stderr)
return 77 return 1
print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}") print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}")
return 0 return 0
if args.check_interactive: if args.check_interactive:
print("interactive NestQuest session open" if interactive_nestquest_session(project) else "no interactive NestQuest session") try:
active = interactive_nestquest_session(project)
except RuntimeError:
print("OpenCode session state unavailable", file=sys.stderr)
return 77
print("interactive NestQuest session open" if active else "no interactive NestQuest session")
return 0 return 0
args.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True) args.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
@@ -213,7 +224,15 @@ def main() -> int:
env["NESTQUEST_CONTROL_HOME"] = str(control_home) env["NESTQUEST_CONTROL_HOME"] = str(control_home)
failures = 0 failures = 0
while True: while True:
if interactive_nestquest_session(project): try:
interactive = interactive_nestquest_session(project)
except RuntimeError:
print("OpenCode session state unavailable; waiting", file=sys.stderr, flush=True)
if args.once:
return 77
time.sleep(60)
continue
if interactive:
print("interactive NestQuest session still open; waiting", flush=True) print("interactive NestQuest session still open; waiting", flush=True)
if args.once: if args.once:
return 75 return 75
+105 -29
View File
@@ -1,34 +1,110 @@
#!/usr/bin/env bash #!/usr/bin/env python3
set -euo pipefail """Run Claude on an exact PR head and publish an independent Gitea approval."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import urllib.error
import urllib.request
if [[ $# -ne 4 ]]; then API = "https://code.cubecraftlabs.com/api/v1"
echo "usage: nq-review WORKTREE BASE_REF HEAD_REF REVIEW_PROMPT_FILE" >&2 REPO = "/repos/Maniacs_Mansion/NestQuest"
exit 64 CREDENTIALS = Path("/home/overseer/.llms/credentials.env")
fi CLAUDE = "/home/overseer/.local/bin/claude"
SHA = re.compile(r"^[0-9a-f]{40}$")
worktree=$1 def git(worktree: Path, *args: str) -> str:
base_ref=$2 return subprocess.check_output(["git", "-C", str(worktree), *args], text=True).strip()
head_ref=$3
prompt_file=$4
[[ -d "$worktree" && -f "$prompt_file" ]] || { echo "missing worktree or prompt" >&2; exit 66; }
cd "$worktree" def token() -> str:
base_commit=$(git rev-parse --verify "$base_ref^{commit}") for line in CREDENTIALS.read_text().splitlines():
head_commit=$(git rev-parse --verify "$head_ref^{commit}") if line.startswith("GITEA_REVIEW_AGENT_TOKEN="):
[[ "$(git rev-parse HEAD)" == "$head_commit" ]] || { echo "worktree HEAD differs from reviewed head" >&2; exit 65; } return line.split("=", 1)[1].strip().strip("\"'")
git merge-base --is-ancestor "$base_commit" "$head_commit" || { echo "base is not an ancestor of head" >&2; exit 65; } raise RuntimeError("review_agent credential is unavailable")
review_prompt=$(cat "$prompt_file") def api(path: str, credential: str, payload: dict | None = None) -> dict:
review_prompt+=$'\n\nReview only the delta from the specified base to this exact head. Base: ' body = None if payload is None else json.dumps(payload).encode()
review_prompt+="$base_commit" headers = {"Authorization": "token " + credential, "Accept": "application/json"}
review_prompt+=$'. Head: ' if body is not None:
review_prompt+="$head_commit" headers["Content-Type"] = "application/json"
review_prompt+=$'. Verify the current HEAD before issuing a verdict. Read only; do not modify files. End with two plain lines, REVIEWED_HEAD: <full 40-character SHA> and REVIEW_VERDICT: APPROVED or REVIEW_VERDICT: CHANGES_REQUIRED.' call = urllib.request.Request(API + path, data=body, headers=headers)
try:
with urllib.request.urlopen(call, timeout=20) as response:
return json.loads(response.read())
except urllib.error.HTTPError as error:
raise RuntimeError(f"Gitea returned HTTP {error.code}") from None
claude_bin=${CLAUDE_BIN:-/home/overseer/.local/bin/claude} def verdict(output: str, head: str) -> str:
exec "$claude_bin" -p "$review_prompt" \ reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", output)
--model claude-sonnet-5 \ decisions = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", output)
--effort high \ if reviewed != [head] or len(decisions) != 1:
--permission-mode plan \ raise RuntimeError("Claude did not return one verdict for the exact reviewed head")
--allowedTools Read Grep Glob \ return decisions[0]
'Bash(git diff:*)' 'Bash(git log:*)' 'Bash(git show:*)' 'Bash(git status:*)'
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("worktree", type=Path)
parser.add_argument("base_ref")
parser.add_argument("head_ref")
parser.add_argument("prompt_file", type=Path)
parser.add_argument("pr", type=int)
parser.add_argument("output_file", type=Path)
args = parser.parse_args()
if not args.worktree.is_dir() or not args.prompt_file.is_file() or args.pr < 1:
parser.error("valid worktree, prompt file, and PR number required")
if not args.output_file.resolve().is_relative_to(Path("/tmp/opencode").resolve()):
parser.error("review output must be under /tmp/opencode")
base = git(args.worktree, "rev-parse", "--verify", args.base_ref + "^{commit}")
head = git(args.worktree, "rev-parse", "--verify", args.head_ref + "^{commit}")
if not SHA.fullmatch(head) or git(args.worktree, "rev-parse", "HEAD") != head:
raise RuntimeError("worktree HEAD differs from requested head")
subprocess.run(["git", "-C", str(args.worktree), "merge-base", "--is-ancestor", base, head], check=True)
prompt = args.prompt_file.read_text() + (
f"\n\nReview only the delta from base {base} to exact head {head}. "
"Verify current HEAD before issuing a verdict. Read only; do not modify files. "
"End with two plain lines: REVIEWED_HEAD: <full 40-character SHA> "
"and REVIEW_VERDICT: APPROVED or REVIEW_VERDICT: CHANGES_REQUIRED."
)
result = subprocess.run(
[CLAUDE, "-p", prompt, "--model", "claude-sonnet-5", "--effort", "high",
"--permission-mode", "plan", "--allowedTools", "Read", "Grep", "Glob",
"Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)", "Bash(git status:*)"],
cwd=args.worktree, text=True, capture_output=True, timeout=1200,
)
args.output_file.parent.mkdir(parents=True, exist_ok=True)
args.output_file.write_text(result.stdout)
os.chmod(args.output_file, 0o600)
if result.returncode:
raise RuntimeError(f"Claude exited {result.returncode}: {result.stderr[-300:]}")
decision = verdict(result.stdout, head)
if git(args.worktree, "rev-parse", "HEAD") != head:
raise RuntimeError("worktree HEAD changed during review")
print(result.stdout, end="")
if decision != "APPROVED":
return 2
credential = token()
if api("/user", credential).get("login") != "review_agent":
raise RuntimeError("review credential belongs to wrong account")
pr = api(f"{REPO}/pulls/{args.pr}", credential)
if pr.get("state") != "open" or (pr.get("head") or {}).get("sha") != head:
raise RuntimeError("PR head changed before review publication")
published = api(
f"{REPO}/pulls/{args.pr}/reviews", credential,
{"body": "Independent Claude Sonnet 5 review completed for this exact commit.",
"commit_id": head, "event": "APPROVED"},
)
if published.get("state") != "APPROVED" or published.get("commit_id") != head:
raise RuntimeError("Gitea did not confirm exact-head approval")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError,
subprocess.TimeoutExpired) as error:
print(f"review gate failed: {error}", file=sys.stderr)
raise SystemExit(1)