claude-dev-env 8.69.0 → 8.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/correction/SKILL.md +28 -6
- package/.agents/skills/correction/references/filing.md +33 -0
- package/.agents/skills/correction/scripts/correction_filing.py +327 -0
- package/.agents/skills/correction/scripts/correction_filing_constants/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_filing_constants/config/__init__.py +0 -0
- package/.agents/skills/correction/scripts/correction_filing_constants/config/constants.py +56 -0
- package/.agents/skills/correction/scripts/pytest.ini +4 -0
- package/.agents/skills/correction/scripts/test_correction_filing.py +155 -0
- package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
- package/.agents/skills/second-account-workers/SKILL.md +21 -3
- package/docs/rule-guides/features-start-with-an-eval.md +40 -0
- package/hooks/blocking/followup_pr_dedupe.py +55 -5
- package/hooks/blocking/image_read_size_gate.py +167 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
- package/hooks/blocking/pull_request_proof.py +86 -20
- package/hooks/blocking/test_artifact_look_gate.py +1 -0
- package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
- package/hooks/blocking/test_image_read_size_gate.py +79 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
- package/hooks/blocking/test_pull_request_proof.py +124 -1
- package/hooks/features/README.md +1 -0
- package/hooks/features/conduct-gates.md +1 -1
- package/hooks/features/read-gates.md +20 -0
- package/hooks/features/write-edit-blocking.md +3 -5
- package/hooks/hooks.json +11 -1
- package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
- package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
- package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
- package/hooks/test_transcript_skill_scan.py +75 -1
- package/hooks/transcript_skill_scan.py +87 -15
- package/package.json +1 -1
- package/rules/anti-corollary-tests.md +2 -2
- package/rules/archiving-agent-config.md +2 -2
- package/rules/asd-ste100-language.md +2 -2
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +1 -1
- package/rules/cleanup-temp-files.md +3 -3
- package/rules/code-standards.md +1 -1
- package/rules/correction-lens.md +3 -3
- package/rules/destructive-commands.md +3 -3
- package/rules/doc-inventory-integrity.md +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/explore-thoroughly.md +1 -1
- package/rules/failure-blast-radius.md +1 -1
- package/rules/falsify-before-green.md +2 -2
- package/rules/features-start-with-an-eval.md +9 -0
- package/rules/filesystem-search.md +3 -3
- package/rules/flag-non-breaking-findings.md +2 -2
- package/rules/long-horizon-autonomy.md +2 -2
- package/rules/memory-stores-durable-facts.md +2 -2
- package/rules/no-contrast-framing.md +1 -1
- package/rules/no-cross-skill-duplicate-helpers.md +1 -1
- package/rules/orphan-css-class.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/rules/plain-illustrative-docstrings.md +1 -1
- package/rules/prefer-existing-tools.md +2 -2
- package/rules/proof-before-pull-request.md +1 -1
- package/rules/request-scope.md +1 -1
- package/rules/research-mode.md +2 -2
- package/rules/retired-hook-prose.md +1 -1
- package/rules/session-title.md +1 -1
- package/rules/shell-invocation.md +2 -2
- package/rules/testing.md +1 -1
- package/rules/verify-before-asking.md +3 -3
- package/rules/verify-runtime-state.md +2 -2
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/account_broker.py +10 -3
- package/scripts/account_broker_support.py +7 -3
- package/scripts/agent_image_copy.py +155 -0
- package/scripts/claude_account_worker.py +24 -5
- package/scripts/claude_account_worker_process.py +5 -0
- package/scripts/claude_account_worker_report.py +16 -1
- package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +38 -0
- package/scripts/test_account_broker_support.py +24 -0
- package/scripts/test_agent_image_copy.py +76 -0
- package/scripts/test_claude_account_worker.py +47 -0
- package/scripts/test_claude_account_worker_process.py +20 -0
- package/scripts/test_claude_account_worker_report.py +15 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
- package/scripts/tests/test_rule_load_scopes.py +1 -0
- package/hooks/advisory/conftest.py +0 -10
- package/hooks/advisory/refactor_guard.py +0 -385
- package/hooks/advisory/refactor_guard_test_support.py +0 -46
- package/hooks/advisory/test_refactor_guard.py +0 -148
- package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
- package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
- package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
- package/hooks/hooks_constants/test_refactor_guard_constants.py +0 -21
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Deny a new pull request whose body lacks a 'Proof in practice' or an 'Existing work' section."""
|
|
1
|
+
"""Deny a new pull request whose body lacks a 'Proof in practice' or an 'Existing work' section, or a feature pull request whose body lacks an 'Eval' section."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -6,13 +6,14 @@ import re
|
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
8
|
from collections.abc import Mapping
|
|
9
|
+
from typing import NamedTuple
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
12
13
|
if hooks_directory not in sys.path:
|
|
13
14
|
sys.path.insert(0, hooks_directory)
|
|
14
15
|
|
|
15
|
-
from blocking.followup_pr_dedupe import body_from_arguments
|
|
16
|
+
from blocking.followup_pr_dedupe import body_from_arguments, create_tool_fields, title_from_arguments
|
|
16
17
|
from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
|
|
17
18
|
from hooks_constants.pull_request_proof_constants import (
|
|
18
19
|
ALL_GH_CREATE_WORDS,
|
|
@@ -20,16 +21,18 @@ from hooks_constants.pull_request_proof_constants import (
|
|
|
20
21
|
ALL_VISIBLE_FILE_SUFFIXES,
|
|
21
22
|
CHANGED_FILES_SEPARATOR,
|
|
22
23
|
COMMAND_MARKER,
|
|
23
|
-
CREATE_PULL_REQUEST_TOOL_SUFFIX,
|
|
24
24
|
ALL_DEFAULT_BRANCH_REFERENCES,
|
|
25
25
|
ALL_HEAD_FLAGS,
|
|
26
26
|
HEAD_FLAG_ASSIGNMENT_PREFIX,
|
|
27
27
|
REMOTE_BRANCH_PREFIX,
|
|
28
28
|
UNREADABLE_CHANGES_REASON,
|
|
29
|
+
EVAL_HEADING_PATTERN,
|
|
29
30
|
EXISTING_WORK_HEADING_PATTERN,
|
|
31
|
+
FEATURE_TITLE_PATTERN,
|
|
30
32
|
GIT_TIMEOUT_SECONDS,
|
|
31
33
|
LINE_SEPARATOR,
|
|
32
34
|
MISSING_BODY_REASON,
|
|
35
|
+
MISSING_EVAL_SECTION_REASON,
|
|
33
36
|
MISSING_EXISTING_WORK_REASON,
|
|
34
37
|
MISSING_LOOK_REASON,
|
|
35
38
|
MISSING_PROOF_REASON,
|
|
@@ -48,6 +51,13 @@ from hooks_constants.shell_command_wrappers import (
|
|
|
48
51
|
)
|
|
49
52
|
|
|
50
53
|
|
|
54
|
+
class CreateCall(NamedTuple):
|
|
55
|
+
"""The title and body one pull request create call passes; None marks a value this gate cannot read."""
|
|
56
|
+
|
|
57
|
+
title: str | None
|
|
58
|
+
body: str | None
|
|
59
|
+
|
|
60
|
+
|
|
51
61
|
def _heading_level(line: str, heading_pattern: str) -> int | None:
|
|
52
62
|
heading = re.match(heading_pattern, line.strip(), re.IGNORECASE)
|
|
53
63
|
return None if heading is None else len(heading.group(1))
|
|
@@ -120,9 +130,12 @@ def _create_arguments(all_segment_tokens: list[str]) -> list[str] | None:
|
|
|
120
130
|
return _script_create_arguments(program, all_arguments)
|
|
121
131
|
|
|
122
132
|
|
|
123
|
-
def
|
|
133
|
+
def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall]:
|
|
124
134
|
return [
|
|
125
|
-
|
|
135
|
+
CreateCall(
|
|
136
|
+
title_from_arguments(all_create_arguments),
|
|
137
|
+
body_from_arguments(all_create_arguments, working_directory),
|
|
138
|
+
)
|
|
126
139
|
for each_text in all_wrapped_command_texts(command)
|
|
127
140
|
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
128
141
|
if (all_create_arguments := _create_arguments(each_segment)) is not None
|
|
@@ -139,13 +152,12 @@ def _head_flag_value(all_create_arguments: list[str]) -> str | None:
|
|
|
139
152
|
|
|
140
153
|
|
|
141
154
|
def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
return None
|
|
145
|
-
head_branch = tool_input.get("head")
|
|
155
|
+
create_fields = create_tool_fields(all_payload_fields)
|
|
156
|
+
head_branch = None if create_fields is None else create_fields.get("head")
|
|
146
157
|
if isinstance(head_branch, str):
|
|
147
158
|
return head_branch
|
|
148
|
-
|
|
159
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
160
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
149
161
|
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
150
162
|
return None
|
|
151
163
|
all_head_flag_values = [
|
|
@@ -157,23 +169,77 @@ def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
|
157
169
|
return next((each_value for each_value in all_head_flag_values if each_value), None)
|
|
158
170
|
|
|
159
171
|
|
|
160
|
-
def
|
|
161
|
-
|
|
172
|
+
def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
|
|
173
|
+
create_fields = create_tool_fields(all_payload_fields)
|
|
174
|
+
if create_fields is not None:
|
|
175
|
+
title = create_fields.get("title")
|
|
176
|
+
body = create_fields.get("body")
|
|
177
|
+
return [
|
|
178
|
+
CreateCall(
|
|
179
|
+
title if isinstance(title, str) else None,
|
|
180
|
+
body if isinstance(body, str) else "",
|
|
181
|
+
)
|
|
182
|
+
]
|
|
162
183
|
tool_input = all_payload_fields.get("tool_input")
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if tool_name.endswith(CREATE_PULL_REQUEST_TOOL_SUFFIX):
|
|
166
|
-
body = tool_input.get("body")
|
|
167
|
-
return [body if isinstance(body, str) else ""]
|
|
168
|
-
command = tool_input.get("command")
|
|
169
|
-
if tool_name not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
184
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
185
|
+
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
170
186
|
return []
|
|
171
187
|
working_directory = all_payload_fields.get("cwd")
|
|
172
|
-
return
|
|
188
|
+
return _shell_create_calls(
|
|
173
189
|
command, working_directory if isinstance(working_directory, str) else ""
|
|
174
190
|
)
|
|
175
191
|
|
|
176
192
|
|
|
193
|
+
def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
|
|
194
|
+
return [each_call.body for each_call in _create_calls(all_payload_fields)]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_feature_title(title: str | None) -> bool:
|
|
198
|
+
return title is not None and re.match(FEATURE_TITLE_PATTERN, title, re.IGNORECASE) is not None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def is_feature_pull_request(all_payload_fields: Mapping[str, object]) -> bool:
|
|
202
|
+
"""Return whether any pull request create call in the payload carries a feature title.
|
|
203
|
+
|
|
204
|
+
::
|
|
205
|
+
|
|
206
|
+
mcp__github__create_pull_request, title "feat(hooks)!: x" -> True
|
|
207
|
+
gh pr create --title "fix: y" --body-file b.md -> False
|
|
208
|
+
gh pr create --fill -> False
|
|
209
|
+
gh pr edit, git push, any other tool -> False
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
213
|
+
"""
|
|
214
|
+
return any(_is_feature_title(each_call.title) for each_call in _create_calls(all_payload_fields))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def missing_eval_section_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
218
|
+
"""Return a deny reason when a feature pull request's body has no eval section, else None.
|
|
219
|
+
|
|
220
|
+
::
|
|
221
|
+
|
|
222
|
+
title "feat: x", body has the section -> None
|
|
223
|
+
title "feat: x", body without it -> MISSING_EVAL_SECTION_REASON
|
|
224
|
+
title "fix: y", body without it -> None
|
|
225
|
+
title "feat: x", gh pr create --fill -> None
|
|
226
|
+
|
|
227
|
+
The section is an 'Eval' heading whose text names at least one command in
|
|
228
|
+
backticks. A create command whose body this gate cannot read passes here,
|
|
229
|
+
because missing_proof_reason denies it first.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
233
|
+
"""
|
|
234
|
+
for each_call in _create_calls(all_payload_fields):
|
|
235
|
+
if each_call.body is None or not _is_feature_title(each_call.title):
|
|
236
|
+
continue
|
|
237
|
+
section = _section_text(each_call.body, EVAL_HEADING_PATTERN)
|
|
238
|
+
if not section or COMMAND_MARKER not in section:
|
|
239
|
+
return MISSING_EVAL_SECTION_REASON
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
177
243
|
def missing_proof_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
178
244
|
"""Return a deny reason when a new pull request's body has no proof, else None.
|
|
179
245
|
|
|
@@ -101,6 +101,7 @@ def test_should_refuse_a_page_published_without_a_look(tmp_path: Path) -> None:
|
|
|
101
101
|
specific_output = hook_output["hookSpecificOutput"]
|
|
102
102
|
assert specific_output["permissionDecision"] == "deny"
|
|
103
103
|
assert "--screenshot" in specific_output["permissionDecisionReason"]
|
|
104
|
+
assert "Attach" not in specific_output["permissionDecisionReason"]
|
|
104
105
|
|
|
105
106
|
|
|
106
107
|
def test_should_allow_a_page_whose_newer_screenshot_was_read(tmp_path: Path) -> None:
|
|
@@ -92,3 +92,36 @@ def test_body_from_arguments_reads_inline_text_and_body_files(tmp_path: Path) ->
|
|
|
92
92
|
assert dedupe.body_from_arguments(["--body-file", "body.md"], str(tmp_path)) == "From the file."
|
|
93
93
|
assert dedupe.body_from_arguments(["--body-file", "-"], str(tmp_path)) is None
|
|
94
94
|
assert dedupe.body_from_arguments(["--fill"], str(tmp_path)) is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_title_from_arguments_should_read_each_title_flag_shape() -> None:
|
|
98
|
+
assert dedupe.title_from_arguments(["--title", "feat: x", "--fill"]) == "feat: x"
|
|
99
|
+
assert dedupe.title_from_arguments(["-t", "fix: y"]) == "fix: y"
|
|
100
|
+
assert dedupe.title_from_arguments(["--title=feat(x): z"]) == "feat(x): z"
|
|
101
|
+
assert dedupe.title_from_arguments(["--title-file", "t.txt"]) is None
|
|
102
|
+
assert dedupe.title_from_arguments(["--fill"]) is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _dispatch_payload(all_inputs: dict[str, object]) -> dict[str, object]:
|
|
106
|
+
return {
|
|
107
|
+
"tool_name": "mcp__github__actions_run_trigger",
|
|
108
|
+
"tool_input": {"method": "run_workflow", "owner": "jl-cmd", "repo": "claude-dev-env", "inputs": all_inputs},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_create_tool_fields_should_read_a_pull_request_dispatch_as_a_create() -> None:
|
|
113
|
+
payload = _dispatch_payload({"head": "b", "title": "fix: y", "body": "Follow-up to #1731"})
|
|
114
|
+
assert dedupe.create_tool_fields(payload) == {
|
|
115
|
+
"head": "b",
|
|
116
|
+
"title": "fix: y",
|
|
117
|
+
"body": "Follow-up to #1731",
|
|
118
|
+
"owner": "jl-cmd",
|
|
119
|
+
"repo": "claude-dev-env",
|
|
120
|
+
}
|
|
121
|
+
reason = dedupe.duplicate_followup_reason(payload, _reader([OPEN_FOLLOWUP]))
|
|
122
|
+
assert reason is not None and "#1769" in reason
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_create_tool_fields_should_skip_a_call_that_opens_no_pull_request() -> None:
|
|
126
|
+
assert dedupe.create_tool_fields(_dispatch_payload({"work_order": "wo-1"})) is None
|
|
127
|
+
assert dedupe.create_tool_fields({"tool_name": "Bash", "tool_input": {"command": "gh pr list"}}) is None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Behavior tests for the image read size gate."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
12
|
+
_HOOKS_ROOT = _BLOCKING_DIR.parent
|
|
13
|
+
for each_sys_path_entry in (str(_BLOCKING_DIR), str(_HOOKS_ROOT)):
|
|
14
|
+
if each_sys_path_entry not in sys.path:
|
|
15
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
16
|
+
|
|
17
|
+
import image_read_size_gate
|
|
18
|
+
|
|
19
|
+
HOOK_SCRIPT = _BLOCKING_DIR / "image_read_size_gate.py"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _image(file_path: Path, width: int, height: int) -> Path:
|
|
23
|
+
Image.new("RGB", (width, height), "white").save(file_path)
|
|
24
|
+
return file_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _run_hook(file_path: Path) -> str:
|
|
28
|
+
completed = subprocess.run(
|
|
29
|
+
[sys.executable, str(HOOK_SCRIPT)],
|
|
30
|
+
input=json.dumps(
|
|
31
|
+
{
|
|
32
|
+
"hook_event_name": "PreToolUse",
|
|
33
|
+
"tool_name": "Read",
|
|
34
|
+
"tool_input": {"file_path": str(file_path)},
|
|
35
|
+
}
|
|
36
|
+
),
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
check=True,
|
|
40
|
+
)
|
|
41
|
+
return completed.stdout
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_should_refuse_a_full_size_preview_read(tmp_path: Path) -> None:
|
|
45
|
+
hook_output = json.loads(_run_hook(_image(tmp_path / "preview.png", 1440, 2560)))
|
|
46
|
+
specific_output = hook_output["hookSpecificOutput"]
|
|
47
|
+
assert specific_output["permissionDecision"] == "deny"
|
|
48
|
+
assert "1440x2560" in specific_output["permissionDecisionReason"]
|
|
49
|
+
assert "agent_image_copy.py" in specific_output["permissionDecisionReason"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_should_allow_an_image_at_the_cap(tmp_path: Path) -> None:
|
|
53
|
+
assert _run_hook(_image(tmp_path / "preview.agent.png", 288, 512)) == ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_should_allow_a_text_file(tmp_path: Path) -> None:
|
|
57
|
+
notes_path = tmp_path / "notes.md"
|
|
58
|
+
notes_path.write_text("x" * 4000, encoding="utf-8")
|
|
59
|
+
assert _run_hook(notes_path) == ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_should_allow_a_missing_file(tmp_path: Path) -> None:
|
|
63
|
+
assert _run_hook(tmp_path / "gone.png") == ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("suffix", [".png", ".jpg", ".gif"])
|
|
67
|
+
def test_image_size_reads_each_format(tmp_path: Path, suffix: str) -> None:
|
|
68
|
+
image_path = _image(tmp_path / f"wide{suffix}", 900, 300)
|
|
69
|
+
assert image_read_size_gate.image_size(image_path.read_bytes()[:65536]) == (900, 300)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_oversized_image_reason_passes_other_tools(tmp_path: Path) -> None:
|
|
73
|
+
image_path = _image(tmp_path / "big.png", 2000, 2000)
|
|
74
|
+
assert (
|
|
75
|
+
image_read_size_gate.oversized_image_reason(
|
|
76
|
+
{"tool_name": "Write", "tool_input": {"file_path": str(image_path)}}
|
|
77
|
+
)
|
|
78
|
+
is None
|
|
79
|
+
)
|
|
@@ -14,7 +14,7 @@ if str(HOOKS_DIRECTORY) not in sys.path:
|
|
|
14
14
|
from blocking import followup_pr_dedupe as gate_dedupe
|
|
15
15
|
from blocking import pr_lifecycle_skill_gate as gate
|
|
16
16
|
from hooks_constants.pr_lifecycle_skill_gate_constants import DENY_REASON
|
|
17
|
-
from hooks_constants.pull_request_proof_constants import MISSING_PROOF_REASON
|
|
17
|
+
from hooks_constants.pull_request_proof_constants import MISSING_BUILD_EVAL_REASON, MISSING_PROOF_REASON
|
|
18
18
|
|
|
19
19
|
PROVEN_FOLLOWUP_BODY = (
|
|
20
20
|
"Follow-up to #1731\n\n## Existing work\nNothing found in open or merged pull requests.\n\n"
|
|
@@ -225,11 +225,11 @@ def test_hook_has_its_own_pre_tool_use_registration() -> None:
|
|
|
225
225
|
each_group
|
|
226
226
|
for each_group in all_groups
|
|
227
227
|
if each_group["matcher"]
|
|
228
|
-
== "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)"
|
|
228
|
+
== "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)"
|
|
229
229
|
]
|
|
230
230
|
assert matching_groups == [
|
|
231
231
|
{
|
|
232
|
-
"matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)",
|
|
232
|
+
"matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)",
|
|
233
233
|
"hooks": [
|
|
234
234
|
{
|
|
235
235
|
"type": "command",
|
|
@@ -283,3 +283,117 @@ def test_loaded_skill_still_denies_a_new_pull_request_without_proof(tmp_path: Pa
|
|
|
283
283
|
exit_code, output = _run_main(_payload("gh pr create --body 'Adds a gate.'", _transcript(tmp_path, "pr-lifecycle")))
|
|
284
284
|
assert exit_code == 0
|
|
285
285
|
assert json.loads(output)["hookSpecificOutput"]["permissionDecisionReason"] == MISSING_PROOF_REASON
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
EVALUATED_BODY = (
|
|
289
|
+
"## Existing work\nNothing found in open or merged pull requests.\n\n"
|
|
290
|
+
"## Proof in practice\nRan `python probe.py`.\n\n"
|
|
291
|
+
"## Eval\nTen labeled cases, exact-match grader. Ran `python eval.py`: 10/10.\n"
|
|
292
|
+
)
|
|
293
|
+
SKILL_LOAD_LINE = json.dumps(
|
|
294
|
+
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": "pr-lifecycle"}}]}}
|
|
295
|
+
)
|
|
296
|
+
BUILD_EVAL_LINE = json.dumps(
|
|
297
|
+
{
|
|
298
|
+
"type": "assistant",
|
|
299
|
+
"message": {
|
|
300
|
+
"content": [{"type": "tool_use", "name": "Skill", "input": {"skill": "claude-api", "args": "build-eval"}}]
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _transcript_with_lines(tmp_path: Path, file_name: str, all_lines: list[str]) -> Path:
|
|
307
|
+
transcript_path = tmp_path / file_name
|
|
308
|
+
transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
|
|
309
|
+
return transcript_path
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _create_payload(tmp_path: Path, title: str, transcript_path: Path) -> dict[str, object]:
|
|
313
|
+
return {
|
|
314
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
315
|
+
"tool_input": {"owner": "o", "repo": "r", "title": title, "body": EVALUATED_BODY},
|
|
316
|
+
"transcript_path": str(transcript_path),
|
|
317
|
+
"cwd": _checkout_without_visible_changes(tmp_path),
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _assert_denied_with(payload: dict[str, object], reason: str) -> None:
|
|
322
|
+
decision = gate.decision_for(payload)
|
|
323
|
+
assert decision is not None
|
|
324
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
325
|
+
assert decision["hookSpecificOutput"]["permissionDecisionReason"] == reason
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_feature_pull_request_without_build_eval_invocation_should_be_denied(tmp_path: Path) -> None:
|
|
329
|
+
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
|
|
330
|
+
_assert_denied_with(_create_payload(tmp_path, "feat: add a gate", transcript_path), MISSING_BUILD_EVAL_REASON)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_feature_pull_request_with_build_eval_invocation_should_pass(tmp_path: Path) -> None:
|
|
334
|
+
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE, SKILL_LOAD_LINE])
|
|
335
|
+
assert gate.decision_for(_create_payload(tmp_path, "feat: add a gate", transcript_path)) is None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_non_feature_pull_request_without_build_eval_invocation_should_pass(tmp_path: Path) -> None:
|
|
339
|
+
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
|
|
340
|
+
assert gate.decision_for(_create_payload(tmp_path, "fix: repair a gate", transcript_path)) is None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_feature_pull_request_with_no_readable_transcript_should_pass(tmp_path: Path) -> None:
|
|
344
|
+
payload = _create_payload(tmp_path, "feat: add a gate", tmp_path / "missing.jsonl")
|
|
345
|
+
assert gate.decision_for(payload) is None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_build_eval_check_should_ignore_an_unreadable_transcript_beside_a_readable_one(tmp_path: Path) -> None:
|
|
349
|
+
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
|
|
350
|
+
payload = _create_payload(tmp_path, "feat: add a gate", transcript_path)
|
|
351
|
+
payload["agent_transcript_path"] = str(tmp_path / "missing-agent.jsonl")
|
|
352
|
+
_assert_denied_with(payload, MISSING_BUILD_EVAL_REASON)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_build_eval_invocation_in_the_agent_transcript_should_pass(tmp_path: Path) -> None:
|
|
356
|
+
session_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
|
|
357
|
+
agent_path = _transcript_with_lines(tmp_path, "agent.jsonl", [BUILD_EVAL_LINE])
|
|
358
|
+
payload = _create_payload(tmp_path, "feat: add a gate", session_path)
|
|
359
|
+
payload["agent_transcript_path"] = str(agent_path)
|
|
360
|
+
assert gate.decision_for(payload) is None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _dispatch_payload(tmp_path: Path, all_inputs: dict[str, object], transcript_path: Path) -> dict[str, object]:
|
|
364
|
+
return {
|
|
365
|
+
"tool_name": "mcp__github__actions_run_trigger",
|
|
366
|
+
"tool_input": {
|
|
367
|
+
"method": "run_workflow",
|
|
368
|
+
"owner": "o",
|
|
369
|
+
"repo": "r",
|
|
370
|
+
"workflow_id": "open.yml",
|
|
371
|
+
"ref": "main",
|
|
372
|
+
"inputs": all_inputs,
|
|
373
|
+
},
|
|
374
|
+
"transcript_path": str(transcript_path),
|
|
375
|
+
"cwd": _checkout_without_visible_changes(tmp_path),
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_feature_dispatch_without_build_eval_invocation_should_be_denied(tmp_path: Path) -> None:
|
|
380
|
+
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [SKILL_LOAD_LINE])
|
|
381
|
+
all_inputs = {"head": "main", "title": "feat: add a gate", "body": EVALUATED_BODY}
|
|
382
|
+
_assert_denied_with(_dispatch_payload(tmp_path, all_inputs, transcript_path), MISSING_BUILD_EVAL_REASON)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_feature_dispatch_with_build_eval_invocation_should_pass(tmp_path: Path) -> None:
|
|
386
|
+
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE, SKILL_LOAD_LINE])
|
|
387
|
+
all_inputs = {"head": "main", "title": "feat: add a gate", "body": EVALUATED_BODY}
|
|
388
|
+
assert gate.decision_for(_dispatch_payload(tmp_path, all_inputs, transcript_path)) is None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_pull_request_dispatch_without_the_skill_loaded_should_be_denied(tmp_path: Path) -> None:
|
|
392
|
+
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE])
|
|
393
|
+
all_inputs = {"head": "main", "title": "fix: repair a gate", "body": EVALUATED_BODY}
|
|
394
|
+
_assert_denied_with(_dispatch_payload(tmp_path, all_inputs, transcript_path), DENY_REASON)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_dispatch_that_opens_no_pull_request_should_pass_without_the_skill(tmp_path: Path) -> None:
|
|
398
|
+
transcript_path = _transcript_with_lines(tmp_path, "session.jsonl", [BUILD_EVAL_LINE])
|
|
399
|
+
assert gate.decision_for(_dispatch_payload(tmp_path, {"work_order": "wo-1"}, transcript_path)) is None
|
|
@@ -724,10 +724,7 @@ def test_folded_write_edit_hooks_have_no_standalone_hooks_json_entry() -> None:
|
|
|
724
724
|
for each_group in hooks_configuration["hooks"]["PreToolUse"]
|
|
725
725
|
for each_hook in each_group["hooks"]
|
|
726
726
|
]
|
|
727
|
-
folded_script_relative_paths = (
|
|
728
|
-
"advisory/refactor_guard.py",
|
|
729
|
-
"advisory/migration_safety_advisor.py",
|
|
730
|
-
)
|
|
727
|
+
folded_script_relative_paths = ("advisory/migration_safety_advisor.py",)
|
|
731
728
|
for each_script_path in folded_script_relative_paths:
|
|
732
729
|
matching_commands = [
|
|
733
730
|
each_command
|
|
@@ -808,7 +805,6 @@ def test_dispatcher_allows_clean_apply_patch_add(tmp_path: Path) -> None:
|
|
|
808
805
|
|
|
809
806
|
|
|
810
807
|
ALL_RETAINED_PATHS = {
|
|
811
|
-
"advisory/refactor_guard.py",
|
|
812
808
|
"advisory/migration_safety_advisor.py",
|
|
813
809
|
}
|
|
814
810
|
ALL_RETIRED_PATHS = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Behavior tests for the proof-in-practice
|
|
1
|
+
"""Behavior tests for the proof-in-practice, existing-work and eval checks on a new pull request."""
|
|
2
2
|
|
|
3
3
|
import subprocess
|
|
4
4
|
import sys
|
|
@@ -11,6 +11,7 @@ if str(HOOKS_DIRECTORY) not in sys.path:
|
|
|
11
11
|
from blocking import pull_request_proof as gate
|
|
12
12
|
from hooks_constants.pull_request_proof_constants import (
|
|
13
13
|
MISSING_BODY_REASON,
|
|
14
|
+
MISSING_EVAL_SECTION_REASON,
|
|
14
15
|
MISSING_EXISTING_WORK_REASON,
|
|
15
16
|
MISSING_PROOF_REASON,
|
|
16
17
|
UNREADABLE_CHANGES_REASON,
|
|
@@ -210,3 +211,125 @@ def test_should_read_the_head_flag_of_a_shell_create(tmp_path: Path) -> None:
|
|
|
210
211
|
reason = gate.missing_look_reason(payload)
|
|
211
212
|
assert reason is not None
|
|
212
213
|
assert "page.html" in reason
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
EVALUATED_BODY = SEARCHED_BODY + "\n## Eval\nTen labeled cases, exact-match grader. Ran `python eval.py`: 10/10.\n"
|
|
217
|
+
EVAL_WITHOUT_COMMAND_BODY = SEARCHED_BODY + "\n## Eval\nTen labeled cases, all pass.\n"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _titled_mcp_create(title: object, body: object) -> dict[str, object]:
|
|
221
|
+
payload = _mcp_create(body)
|
|
222
|
+
payload["tool_input"]["title"] = title
|
|
223
|
+
return payload
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_feature_mcp_create_without_an_eval_section_should_be_denied() -> None:
|
|
227
|
+
payload = _titled_mcp_create("feat: add a gate", SEARCHED_BODY)
|
|
228
|
+
assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_feature_mcp_create_with_an_eval_section_naming_a_command_should_pass() -> None:
|
|
232
|
+
assert gate.missing_eval_section_reason(_titled_mcp_create("feat: add a gate", EVALUATED_BODY)) is None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_feature_mcp_create_with_an_eval_heading_and_no_backtick_should_be_denied() -> None:
|
|
236
|
+
payload = _titled_mcp_create("feat: add a gate", EVAL_WITHOUT_COMMAND_BODY)
|
|
237
|
+
assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_eval_section_should_stop_at_a_heading_of_the_same_level() -> None:
|
|
241
|
+
body = SEARCHED_BODY + "\n## Evals\nCases listed.\n\n## Notes\nRan `python eval.py`.\n"
|
|
242
|
+
payload = _titled_mcp_create("feat: add a gate", body)
|
|
243
|
+
assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_fix_and_docs_titles_without_an_eval_section_should_pass() -> None:
|
|
247
|
+
for each_title in ("fix: repair a gate", "docs: describe a gate"):
|
|
248
|
+
payload = _titled_mcp_create(each_title, SEARCHED_BODY)
|
|
249
|
+
assert gate.missing_eval_section_reason(payload) is None, each_title
|
|
250
|
+
assert not gate.is_feature_pull_request(payload), each_title
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_feature_titles_should_count_as_features() -> None:
|
|
254
|
+
for each_title in ("feat: x", "feat(hooks)!: x", "FEAT: x", "FEAT(x)!: y", " feat!: x"):
|
|
255
|
+
assert gate.is_feature_pull_request(_titled_mcp_create(each_title, SEARCHED_BODY)), each_title
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_non_feature_titles_should_not_count_as_features() -> None:
|
|
259
|
+
for each_title in ("featured: x", "feature: x", "chore: feat: x", None, 7):
|
|
260
|
+
assert not gate.is_feature_pull_request(_titled_mcp_create(each_title, SEARCHED_BODY)), each_title
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_gh_create_title_flag_should_be_read(tmp_path: Path) -> None:
|
|
264
|
+
(tmp_path / "b.md").write_text(SEARCHED_BODY, encoding="utf-8")
|
|
265
|
+
payload = _shell('gh pr create --title "feat: x" --body-file b.md', tmp_path)
|
|
266
|
+
assert gate.is_feature_pull_request(payload)
|
|
267
|
+
assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_gh_create_title_assignment_should_be_read(tmp_path: Path) -> None:
|
|
271
|
+
(tmp_path / "b.md").write_text(EVALUATED_BODY, encoding="utf-8")
|
|
272
|
+
payload = _shell("gh pr create --title=feat:x --body-file b.md", tmp_path)
|
|
273
|
+
assert gate.is_feature_pull_request(payload)
|
|
274
|
+
assert gate.missing_eval_section_reason(payload) is None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_pull_request_script_create_title_should_be_read(tmp_path: Path) -> None:
|
|
278
|
+
(tmp_path / "b.md").write_text(SEARCHED_BODY, encoding="utf-8")
|
|
279
|
+
payload = _shell(
|
|
280
|
+
'python pull_request.py create --title "feat(x): y" --body-file b.md --repo o/r', tmp_path
|
|
281
|
+
)
|
|
282
|
+
assert gate.is_feature_pull_request(payload)
|
|
283
|
+
assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_gh_create_without_a_title_should_not_count_as_a_feature(tmp_path: Path) -> None:
|
|
287
|
+
(tmp_path / "b.md").write_text(SEARCHED_BODY, encoding="utf-8")
|
|
288
|
+
payload = _shell("gh pr create --body-file b.md", tmp_path)
|
|
289
|
+
assert not gate.is_feature_pull_request(payload)
|
|
290
|
+
assert gate.missing_eval_section_reason(payload) is None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_feature_create_with_an_unreadable_body_should_leave_the_deny_to_the_proof_check() -> None:
|
|
294
|
+
payload = _shell('gh pr create --title "feat: x" --fill')
|
|
295
|
+
assert gate.missing_eval_section_reason(payload) is None
|
|
296
|
+
assert gate.missing_proof_reason(payload) == MISSING_BODY_REASON
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _dispatch(all_inputs: dict[str, object], method: str = "run_workflow") -> dict[str, object]:
|
|
300
|
+
return {
|
|
301
|
+
"tool_name": "mcp__github__actions_run_trigger",
|
|
302
|
+
"tool_input": {
|
|
303
|
+
"method": method,
|
|
304
|
+
"owner": "o",
|
|
305
|
+
"repo": "r",
|
|
306
|
+
"workflow_id": "open.yml",
|
|
307
|
+
"ref": "main",
|
|
308
|
+
"inputs": all_inputs,
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_pull_request_dispatch_without_proof_should_be_denied() -> None:
|
|
314
|
+
payload = _dispatch({"head": "feature", "title": "fix: y", "body": UNPROVEN_BODY})
|
|
315
|
+
assert gate.missing_proof_reason(payload) == MISSING_PROOF_REASON
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_pull_request_dispatch_with_no_body_input_should_be_denied() -> None:
|
|
319
|
+
assert gate.missing_proof_reason(_dispatch({"head": "feature", "title": "fix: y"})) == MISSING_PROOF_REASON
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_feature_dispatch_without_an_eval_section_should_be_denied() -> None:
|
|
323
|
+
payload = _dispatch({"head": "feature", "title": "feat: x", "body": SEARCHED_BODY})
|
|
324
|
+
assert gate.is_feature_pull_request(payload)
|
|
325
|
+
assert gate.missing_eval_section_reason(payload) == MISSING_EVAL_SECTION_REASON
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_dispatch_that_names_no_head_or_runs_no_workflow_should_not_count_as_a_create() -> None:
|
|
329
|
+
all_payloads = [
|
|
330
|
+
_dispatch({"title": "feat: x", "body": UNPROVEN_BODY}),
|
|
331
|
+
_dispatch({"head": "feature", "title": "feat: x", "body": UNPROVEN_BODY}, "rerun_workflow_run"),
|
|
332
|
+
]
|
|
333
|
+
for each_payload in all_payloads:
|
|
334
|
+
assert gate.missing_proof_reason(each_payload) is None
|
|
335
|
+
assert not gate.is_feature_pull_request(each_payload)
|
package/hooks/features/README.md
CHANGED
|
@@ -29,4 +29,5 @@ Each family page starts with a title and an agent-facing behavior summary. Its f
|
|
|
29
29
|
- [Session context](./session-context.md) covers skill reminders, startup guidance, and the auto mode denial quick fix.
|
|
30
30
|
- [Spawn routing](./spawn-routing.md) covers spawn readiness, pacing, and model selection.
|
|
31
31
|
- [Observability](./observability.md) covers instruction loads, edited files, and investigation resets.
|
|
32
|
+
- [Read gates](./read-gates.md) covers the image size cap on Read.
|
|
32
33
|
- [Lifecycle cleanup](./lifecycle-cleanup.md) covers nested checkouts, worktree setup, and session cleanup.
|
|
@@ -24,7 +24,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
24
24
|
- `blocking/session_title_stop_gate.py` runs on `Stop`, no matcher, timeout `10` seconds in `hooks.json`.
|
|
25
25
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
26
26
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
27
|
-
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
|
|
27
|
+
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
28
28
|
- `blocking/artifact_look_gate.py` runs on `PreToolUse`, matcher `Artifact|Skill`, timeout `10` seconds in `hooks.json`. It speaks only on an `Artifact` publish of a `.html` or `.htm` file.
|
|
29
29
|
- `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
|
|
30
30
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Read gates
|
|
2
|
+
|
|
3
|
+
This family stops a Read before it puts too much into the context. An agent that opens a full-size screenshot spends up to 4,784 visual tokens on one image, so the gate sends it to a capped copy first.
|
|
4
|
+
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
- `blocking/image_read_size_gate.py` denies a Read of a PNG, JPEG or GIF file whose long edge is over 512 pixels and names `scripts/agent_image_copy.py`, which writes a `<name>.agent.png` copy at 512 pixels or less with the aspect ratio kept.
|
|
8
|
+
|
|
9
|
+
## When it fires
|
|
10
|
+
|
|
11
|
+
- `blocking/image_read_size_gate.py` runs on `PreToolUse`, matcher `Read`, timeout `10` seconds in `hooks.json`. It reads only the file header, so a text file, a missing file, a WebP file and an unknown format pass.
|
|
12
|
+
|
|
13
|
+
## Proving it
|
|
14
|
+
|
|
15
|
+
- **Full-size screenshot.** Input is a Read of a 1440x2560 PNG. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_image_read_size_gate.py -q`. The adjacent test observes a denial that names `agent_image_copy.py`; a Read of a 288x512 copy passes.
|
|
16
|
+
|
|
17
|
+
## Gotchas
|
|
18
|
+
|
|
19
|
+
- The copy is written after the source, so a gate that wants a screenshot read after a page changed still counts the capped copy.
|
|
20
|
+
- A person gets the full-size file. The cap applies to what an agent reads.
|