|
1 | | -"""The tarot review-artifact gate — a `PreToolUse` hook on `apply_operation` (advance). |
2 | | -
|
3 | | -For a repo that opts in (`Repo.capabilities["tarot_review"]`, see |
4 | | -:mod:`panopticon.workflows.github_forge`), every other ITERATING responsibility is agent |
5 | | -self-attested, but this one is real-verified: this hook intercepts the `advance` operation while |
6 | | -the task is in ITERATING and runs `tarot strands check` / `tarot tour check` in `/workspace`, |
7 | | -denying the tool call (with the checks' output as the reason, so it lands in the agent's context |
8 | | -like a failed test would) unless they pass. A trivial diff (below a changed-line threshold) skips |
9 | | -the checks and auto-resolves the responsibility instead — no tour to write for a one-line fix. |
10 | | -
|
11 | | -Registered unconditionally in `container/hooks.py` (like the turn-flip hooks), regardless of |
12 | | -workflow — irrelevant calls (a different operation, a non-ITERATING state, a non-opted-in repo) |
13 | | -resolve in the first couple of checks below and allow immediately. Deterministic and LLM-free: |
14 | | -only subprocess + REST calls, so it's unit-tested with a fake command runner and a fake client |
15 | | -(no real `tarot` binary needed), the same shape as :mod:`panopticon.container.hook`. |
| 1 | +"""Compatibility shim: the tarot review-artifact gate no longer runs in the container. |
| 2 | +
|
| 3 | +Enforcement moved **host-side** (:mod:`panopticon.taskservice.tarot_gate`), where the operator's |
| 4 | +`tarot` is installed and the task's clone already lives — the same directory the container sees at |
| 5 | +``/workspace``. Nothing in a task image needs tarot any more, and the agent reaches tarot through |
| 6 | +the `tarot_strand_seed` / `tarot_check` / `tarot_tour_scaffold` MCP tools instead. |
| 7 | +
|
| 8 | +This module survives only for **already-provisioned tasks**: `.claude/settings.json` lives in a |
| 9 | +task's persisted config volume and :func:`panopticon.container.config.update_json_config` merges |
| 10 | +rather than prunes, so a task respawned after this change still has a `PreToolUse` entry pointing |
| 11 | +at ``python -m panopticon.container.tarot_gate``. Deleting the module would make every |
| 12 | +`apply_operation` call fail with a hook error in those containers; allowing unconditionally here |
| 13 | +makes the stale entry a harmless no-op. Newly rendered settings don't wire it at all |
| 14 | +(:mod:`panopticon.container.hooks`), so this can be deleted once no such container remains. |
16 | 15 | """ |
17 | 16 |
|
18 | 17 | from __future__ import annotations |
19 | 18 |
|
20 | | -import json |
21 | | -import os |
22 | | -import subprocess |
| 19 | +import contextlib |
23 | 20 | import sys |
24 | | -from collections.abc import Sequence |
25 | | -from dataclasses import dataclass |
26 | | -from typing import Any, Protocol, TextIO |
27 | | - |
28 | | -import httpx |
29 | | - |
30 | | -from panopticon.client import JsonObj, TaskServiceClient |
31 | | -from panopticon.core.models import Status |
32 | | - |
33 | | -#: The `Repo.capabilities` key that opts a repo into this gate (mirrors `docker_in_docker`). |
34 | | -TAROT_REVIEW_CAPABILITY = "tarot_review" |
35 | | -#: Optional per-repo override (an int) of the trivial-diff line threshold, under `capabilities`. |
36 | | -TAROT_REVIEW_THRESHOLD_CAPABILITY = "tarot_review_threshold" |
37 | | -#: The ITERATING responsibility this gate verifies (see `GithubForgeWorkflow.TAROT_REVIEW_ARTIFACTS`). |
38 | | -RESPONSIBILITY_KEY = "tarot-review-artifacts" |
39 | | -#: Below this many total changed lines (git `diff --numstat`, added + removed), the diff is |
40 | | -#: considered trivial and the tarot checks are skipped entirely. |
41 | | -DEFAULT_TRIVIAL_THRESHOLD = 20 |
42 | | -WORKSPACE = "/workspace" |
43 | | - |
44 | | - |
45 | | -@dataclass(frozen=True) |
46 | | -class CommandResult: |
47 | | - """The outcome of running one external command — never raises; the caller inspects it.""" |
48 | | - |
49 | | - returncode: int |
50 | | - output: str # combined stdout+stderr |
51 | | - found: bool = True # False when the executable itself wasn't found on PATH |
52 | | - |
53 | | - |
54 | | -class CommandRunner(Protocol): |
55 | | - def __call__(self, args: Sequence[str], *, cwd: str | None = None) -> CommandResult: ... |
56 | | - |
57 | | - |
58 | | -def _subprocess_run(args: Sequence[str], *, cwd: str | None = None) -> CommandResult: |
59 | | - try: |
60 | | - proc = subprocess.run(list(args), cwd=cwd, capture_output=True, text=True, check=False) |
61 | | - except FileNotFoundError: |
62 | | - return CommandResult(returncode=127, output=f"{args[0]}: command not found", found=False) |
63 | | - return CommandResult(returncode=proc.returncode, output=proc.stdout + proc.stderr) |
64 | | - |
65 | | - |
66 | | -def _read_payload(stdin: TextIO) -> dict[str, Any]: |
67 | | - """Tolerantly parse the hook's stdin JSON; empty/invalid input yields an empty payload.""" |
68 | | - try: |
69 | | - raw = stdin.read() |
70 | | - except (OSError, ValueError): |
71 | | - return {} |
72 | | - if not raw or not raw.strip(): |
73 | | - return {} |
74 | | - try: |
75 | | - data = json.loads(raw) |
76 | | - except json.JSONDecodeError: |
77 | | - return {} |
78 | | - return data if isinstance(data, dict) else {} |
79 | | - |
80 | | - |
81 | | -def _allow() -> int: |
82 | | - """No stdout, exit 0 — Claude Code lets the tool call through unmodified.""" |
83 | | - return 0 |
| 21 | +from typing import TextIO |
84 | 22 |
|
85 | 23 |
|
86 | | -def _deny(reason: str) -> int: |
87 | | - """Structured `PreToolUse` denial: the reason string lands in the agent's context as the |
88 | | - tool call's failure, the same seam a failed test's output would use.""" |
89 | | - print( |
90 | | - json.dumps( |
91 | | - { |
92 | | - "hookSpecificOutput": { |
93 | | - "hookEventName": "PreToolUse", |
94 | | - "permissionDecision": "deny", |
95 | | - "permissionDecisionReason": reason, |
96 | | - } |
97 | | - } |
98 | | - ) |
99 | | - ) |
| 24 | +def main(*, stdin: TextIO | None = None) -> int: |
| 25 | + """Drain the hook payload and allow the tool call. No output, exit 0.""" |
| 26 | + # A closed/absent stdin is not a reason to fail a tool call. |
| 27 | + with contextlib.suppress(OSError, ValueError): |
| 28 | + (stdin or sys.stdin).read() |
100 | 29 | return 0 |
101 | 30 |
|
102 | 31 |
|
103 | | -def _opted_in(repo: JsonObj) -> bool: |
104 | | - return bool((repo.get("capabilities") or {}).get(TAROT_REVIEW_CAPABILITY)) |
105 | | - |
106 | | - |
107 | | -def _threshold(repo: JsonObj) -> int: |
108 | | - value = (repo.get("capabilities") or {}).get(TAROT_REVIEW_THRESHOLD_CAPABILITY) |
109 | | - return value if isinstance(value, int) else DEFAULT_TRIVIAL_THRESHOLD |
110 | | - |
111 | | - |
112 | | -def _changed_line_count(run: CommandRunner, *, base_ref: str) -> int: |
113 | | - """Total added+removed lines between ``base_ref`` and ``HEAD`` (a `diff --numstat` sum) — the |
114 | | - trivial-diff heuristic. Binary files report ``-`` counts, which don't parse as digits and are |
115 | | - skipped rather than counted.""" |
116 | | - result = run(["git", "-C", WORKSPACE, "diff", "--numstat", f"{base_ref}...HEAD"]) |
117 | | - total = 0 |
118 | | - for line in result.output.splitlines(): |
119 | | - added, _, rest = line.partition("\t") |
120 | | - removed, _, _path = rest.partition("\t") |
121 | | - for count in (added, removed): |
122 | | - if count.isdigit(): |
123 | | - total += int(count) |
124 | | - return total |
125 | | - |
126 | | - |
127 | | -def _run_tarot_checks(run: CommandRunner) -> CommandResult | None: |
128 | | - """Run `tarot strands check` then `tarot tour check`; stop at the first failure (nothing to |
129 | | - gain running both once one has already failed). ``None`` means both passed.""" |
130 | | - for args in (["tarot", "strands", "check"], ["tarot", "tour", "check"]): |
131 | | - result = run(args, cwd=WORKSPACE) |
132 | | - if result.returncode != 0: |
133 | | - return result |
134 | | - return None |
135 | | - |
136 | | - |
137 | | -def main( |
138 | | - *, |
139 | | - client: TaskServiceClient | None = None, |
140 | | - stdin: TextIO | None = None, |
141 | | - run: CommandRunner = _subprocess_run, |
142 | | -) -> int: |
143 | | - payload = _read_payload(stdin or sys.stdin) |
144 | | - tool_input = payload.get("tool_input") |
145 | | - if not isinstance(tool_input, dict) or tool_input.get("operation") != "advance": |
146 | | - return _allow() |
147 | | - |
148 | | - env = os.environ |
149 | | - task_id = env["PANOPTICON_TASK_ID"] |
150 | | - client = client or TaskServiceClient(httpx.Client(base_url=env["PANOPTICON_SERVICE_URL"])) |
151 | | - |
152 | | - task = client.get_task(task_id) |
153 | | - if task.get("state") != "ITERATING": |
154 | | - return _allow() |
155 | | - |
156 | | - repo = client.get_repo(task["repo_id"]) |
157 | | - if not _opted_in(repo): |
158 | | - return _allow() |
159 | | - |
160 | | - base_ref = f"origin/{repo.get('default_base', 'main')}" |
161 | | - if _changed_line_count(run, base_ref=base_ref) < _threshold(repo): |
162 | | - client.resolve_responsibility( |
163 | | - task_id, |
164 | | - RESPONSIBILITY_KEY, |
165 | | - Status.MET, |
166 | | - comment="trivial diff — tarot review skipped", |
167 | | - ) |
168 | | - return _allow() |
169 | | - |
170 | | - failure = _run_tarot_checks(run) |
171 | | - if failure is None: |
172 | | - client.resolve_responsibility( |
173 | | - task_id, |
174 | | - RESPONSIBILITY_KEY, |
175 | | - Status.MET, |
176 | | - comment="verified by tarot strands check / tarot tour check", |
177 | | - ) |
178 | | - return _allow() |
179 | | - if not failure.found: |
180 | | - return _deny( |
181 | | - "`tarot` is not installed in this container. An opted-in repo must install it via " |
182 | | - "its `image_layer_file` (see docs/repos.md)." |
183 | | - ) |
184 | | - return _deny(failure.output) |
185 | | - |
186 | | - |
187 | 32 | if __name__ == "__main__": # pragma: no cover |
188 | 33 | raise SystemExit(main()) |
0 commit comments