claude-dev-env 8.69.1 → 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/hooks/blocking/image_read_size_gate.py +167 -0
- package/hooks/blocking/test_image_read_size_gate.py +79 -0
- package/hooks/features/README.md +1 -0
- package/hooks/features/read-gates.md +20 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
- package/package.json +1 -1
- package/scripts/agent_image_copy.py +155 -0
- package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_agent_image_copy.py +76 -0
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: correction
|
|
3
|
-
description:
|
|
3
|
+
description: File a correction the user made to an agent as one labeled GitHub issue, list the open corrections, write a handoff brief, or land the fix in this session with the fix playbook. Use when the user types /correction, /correction fix, says "file this as a correction", or asks to show the open corrections.
|
|
4
4
|
argument-hint: "[fix] <what the agent got wrong, in the user's words>"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# /correction
|
|
8
8
|
|
|
9
|
-
The user corrected an agent and wants the fix to stick.
|
|
9
|
+
The user corrected an agent and wants the fix to stick. A labeled issue is the shared store a corrections project watches. When `$ARGUMENTS` starts with the word `fix`, this session lands the fix itself: write the brief, then follow the fix playbook.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## File it
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Use this for `/correction <text>` and "file this as a correction: <text>". Run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" file <<'CORRECTION'
|
|
17
|
+
<the correction, word for word>
|
|
18
|
+
CORRECTION
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Pass the user's words exactly. The quoted heredoc keeps the shell from expanding `$`, backticks, and quotes in them. Print the one line the command prints, `Filed: <url>` or `Already filed: <url>`, and nothing else.
|
|
22
|
+
|
|
23
|
+
When it prints `No correction filing config`, write the brief below instead and tell the user the config file it names.
|
|
24
|
+
|
|
25
|
+
## List them
|
|
26
|
+
|
|
27
|
+
For "show the open corrections", run `python "${CLAUDE_SKILL_DIR}/scripts/correction_filing.py" list` and print its lines.
|
|
28
|
+
|
|
29
|
+
## Brief
|
|
30
|
+
|
|
31
|
+
Print one fenced `text` block. Fill the fields from this session: the transcript, the files and pull requests it touched, and the messages it sent. Write `unknown` for a field the session does not settle.
|
|
14
32
|
|
|
15
33
|
```text
|
|
16
34
|
Correction: <$ARGUMENTS word for word, minus a leading fix>
|
|
@@ -28,12 +46,16 @@ Keep each field to one or two lines. Leave out the layer and the fix design; the
|
|
|
28
46
|
|
|
29
47
|
| Mode | What follows the brief |
|
|
30
48
|
|---|---|
|
|
31
|
-
|
|
|
49
|
+
| File, the default | The filed line, or the brief when no config exists. |
|
|
32
50
|
| `fix` | Read [`playbooks/fix.md`](playbooks/fix.md) right after the brief, and run its steps from step 2. |
|
|
33
51
|
|
|
34
52
|
## Layout
|
|
35
53
|
|
|
36
54
|
| File | What it holds |
|
|
37
55
|
|---|---|
|
|
38
|
-
| `SKILL.md` |
|
|
56
|
+
| `SKILL.md` | Filing, the brief, and the playbook index |
|
|
39
57
|
| `playbooks/fix.md` | The steps that land one correction as a control and a pull request in this session |
|
|
58
|
+
| `scripts/correction_filing.py` | Files one correction as a labeled issue, deduplicated, or lists the open ones |
|
|
59
|
+
| `scripts/correction_filing_constants/config/constants.py` | Config keys, issue shape, and redaction patterns |
|
|
60
|
+
| `scripts/test_correction_filing.py` | Behavior tests with a stand-in `gh` |
|
|
61
|
+
| `references/filing.md` | Config file, issue shape, dedupe, and commands |
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Correction filing
|
|
2
|
+
|
|
3
|
+
`scripts/correction_filing.py` is the one filing path. The skill calls it.
|
|
4
|
+
|
|
5
|
+
## Config
|
|
6
|
+
|
|
7
|
+
The script reads `~/.claude/correction-capture.json`, or the file `CLAUDE_CORRECTION_CAPTURE_PATH` names:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{"repository": "owner/name", "label": "correction"}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Keep this file private. Without it the script files nothing and exits 1.
|
|
14
|
+
|
|
15
|
+
## Issue shape
|
|
16
|
+
|
|
17
|
+
- Title: `Correction: ` and the first 72 characters of the text.
|
|
18
|
+
- Body: the source (`typed`, `flag`, `reaction`, or `hook`), the text as a quote, and a hidden dedupe marker.
|
|
19
|
+
- The script masks GitHub, OpenAI, Slack, and AWS tokens and email addresses before filing.
|
|
20
|
+
- The script applies the label itself.
|
|
21
|
+
|
|
22
|
+
## Dedupe
|
|
23
|
+
|
|
24
|
+
The key is a hash of `--dedupe-key` when given, else of the whitespace-collapsed text. A caller with a message id passes it there. Before filing, the script checks `correction-capture-filed.json` beside the config file for this repository and key, then every labeled issue, open and closed, and prints `Already filed: <url>` on a key match. The local file covers the minute after filing, when the labeled-issue listing can miss a new issue.
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
Without `--text`, `file` reads the correction from standard input.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python correction_filing.py file [--text "<text>"] [--source flag] [--dedupe-key <id>]
|
|
32
|
+
python correction_filing.py list
|
|
33
|
+
```
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""File a user's correction as one labeled GitHub issue, or list the open ones.
|
|
2
|
+
|
|
3
|
+
The repository and label come from a private config file, so nothing
|
|
4
|
+
identifying lives in this package::
|
|
5
|
+
|
|
6
|
+
~/.claude/correction-capture.json
|
|
7
|
+
{"repository": "owner/name", "label": "correction"}
|
|
8
|
+
|
|
9
|
+
python correction_filing.py file --text "use shorter replies"
|
|
10
|
+
Filed: https://github.com/owner/name/issues/12
|
|
11
|
+
python correction_filing.py file --text "use shorter replies"
|
|
12
|
+
Already filed: https://github.com/owner/name/issues/12
|
|
13
|
+
python correction_filing.py file <<'CORRECTION'
|
|
14
|
+
use shorter replies
|
|
15
|
+
CORRECTION
|
|
16
|
+
Already filed: https://github.com/owner/name/issues/12
|
|
17
|
+
python correction_filing.py list
|
|
18
|
+
#12 Correction: use shorter replies https://github.com/...
|
|
19
|
+
|
|
20
|
+
The issue quotes only the correction text, with tokens and email addresses
|
|
21
|
+
masked. A hidden dedupe marker in the body, and a local ledger of filed keys
|
|
22
|
+
beside the config file, keep a replayed correction from filing twice. CLAUDE_CORRECTION_CAPTURE_PATH names another config file.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from urllib.parse import quote
|
|
37
|
+
|
|
38
|
+
from correction_filing_constants.config.constants import (
|
|
39
|
+
ALL_REDACTION_PATTERNS,
|
|
40
|
+
ALL_SOURCES,
|
|
41
|
+
BODY_FIELD_TEMPLATE,
|
|
42
|
+
CLAUDE_DIRECTORY_NAME,
|
|
43
|
+
CONFIG_ENCODING,
|
|
44
|
+
CONFIG_FILE_NAME,
|
|
45
|
+
CONFIG_LABEL_KEY,
|
|
46
|
+
CONFIG_PATH_ENV_VAR,
|
|
47
|
+
CONFIG_REPOSITORY_KEY,
|
|
48
|
+
CREATED_URL_JQ_FILTER,
|
|
49
|
+
DEDUPE_KEY_LENGTH,
|
|
50
|
+
DEDUPE_MARKER_PATTERN,
|
|
51
|
+
DEDUPE_MARKER_TEMPLATE,
|
|
52
|
+
DEFAULT_SOURCE,
|
|
53
|
+
DUPLICATE_MESSAGE_TEMPLATE,
|
|
54
|
+
EMPTY_TEXT_MESSAGE,
|
|
55
|
+
FAILURE_EXIT_CODE,
|
|
56
|
+
FILED_MESSAGE_TEMPLATE,
|
|
57
|
+
GH_EXECUTABLE,
|
|
58
|
+
LABEL_FIELD_TEMPLATE,
|
|
59
|
+
LINE_SEPARATOR,
|
|
60
|
+
ISSUE_BODY_TEMPLATE,
|
|
61
|
+
ISSUES_CREATE_PATH_TEMPLATE,
|
|
62
|
+
ISSUES_LIST_JQ_FILTER,
|
|
63
|
+
ISSUES_LIST_PATH_TEMPLATE,
|
|
64
|
+
LEDGER_FILE_NAME,
|
|
65
|
+
LEDGER_KEY_TEMPLATE,
|
|
66
|
+
ISSUE_TITLE_PREFIX,
|
|
67
|
+
ISSUE_TITLE_TEXT_LENGTH,
|
|
68
|
+
LIST_LINE_TEMPLATE,
|
|
69
|
+
MISSING_CONFIG_MESSAGE_TEMPLATE,
|
|
70
|
+
NO_OPEN_CORRECTIONS_MESSAGE,
|
|
71
|
+
REDACTION_PLACEHOLDER,
|
|
72
|
+
SUCCESS_EXIT_CODE,
|
|
73
|
+
TITLE_FIELD_TEMPLATE,
|
|
74
|
+
UTF8_ENCODING,
|
|
75
|
+
WORD_SEPARATOR,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class FilingTarget:
|
|
81
|
+
"""The repository and label a correction files into."""
|
|
82
|
+
|
|
83
|
+
repository: str
|
|
84
|
+
label: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FilingConfigMissing(ValueError):
|
|
88
|
+
"""Stop the run when the private config file is absent or incomplete."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _config_path() -> Path:
|
|
92
|
+
"""Return the config path from the environment, or the default under ~/.claude."""
|
|
93
|
+
configured_path = os.environ.get(CONFIG_PATH_ENV_VAR, "")
|
|
94
|
+
if configured_path:
|
|
95
|
+
return Path(configured_path)
|
|
96
|
+
return Path.home() / CLAUDE_DIRECTORY_NAME / CONFIG_FILE_NAME
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_filing_target(from_path: Path) -> FilingTarget:
|
|
100
|
+
"""Read the repository and label from the config file.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
from_path: the private config file.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The repository and label to file into.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
FilingConfigMissing: the file is absent, unreadable, or lacks a key.
|
|
110
|
+
"""
|
|
111
|
+
missing_message = MISSING_CONFIG_MESSAGE_TEMPLATE.format(path=from_path)
|
|
112
|
+
try:
|
|
113
|
+
parsed_config = json.loads(from_path.read_text(encoding=CONFIG_ENCODING))
|
|
114
|
+
except (OSError, ValueError) as error:
|
|
115
|
+
raise FilingConfigMissing(missing_message) from error
|
|
116
|
+
if not isinstance(parsed_config, dict):
|
|
117
|
+
raise FilingConfigMissing(missing_message)
|
|
118
|
+
repository = parsed_config.get(CONFIG_REPOSITORY_KEY, "")
|
|
119
|
+
label = parsed_config.get(CONFIG_LABEL_KEY, "")
|
|
120
|
+
if not isinstance(repository, str) or not isinstance(label, str):
|
|
121
|
+
raise FilingConfigMissing(missing_message)
|
|
122
|
+
if not repository or not label:
|
|
123
|
+
raise FilingConfigMissing(missing_message)
|
|
124
|
+
return FilingTarget(repository=repository, label=label)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _redact_sensitive_text(correction_text: str) -> str:
|
|
128
|
+
"""Mask tokens, keys, and email addresses in the correction text."""
|
|
129
|
+
redacted_text = correction_text
|
|
130
|
+
for each_pattern in ALL_REDACTION_PATTERNS:
|
|
131
|
+
redacted_text = re.sub(each_pattern, REDACTION_PLACEHOLDER, redacted_text)
|
|
132
|
+
return redacted_text
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _dedupe_key_for(correction_text: str, supplied_key: str) -> str:
|
|
136
|
+
"""Return a hex key that names this correction across replays."""
|
|
137
|
+
key_source = supplied_key or WORD_SEPARATOR.join(correction_text.split())
|
|
138
|
+
full_digest = hashlib.sha256(key_source.encode(UTF8_ENCODING)).hexdigest()
|
|
139
|
+
return full_digest[:DEDUPE_KEY_LENGTH]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def issue_title_for(correction_text: str) -> str:
|
|
143
|
+
"""Return a one-line issue title cut from the correction text.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
correction_text: the redacted correction.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The prefix and the first characters of the text on one line.
|
|
150
|
+
"""
|
|
151
|
+
single_line_text = WORD_SEPARATOR.join(correction_text.split())
|
|
152
|
+
return ISSUE_TITLE_PREFIX + single_line_text[:ISSUE_TITLE_TEXT_LENGTH]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def issue_body_for(correction_text: str, source: str, dedupe_key: str) -> str:
|
|
156
|
+
"""Return the issue body with the quoted correction, its source, and the marker.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
correction_text: the redacted correction.
|
|
160
|
+
source: where the correction came from.
|
|
161
|
+
dedupe_key: the hex key the marker carries.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The markdown issue body.
|
|
165
|
+
"""
|
|
166
|
+
quoted_text = LINE_SEPARATOR.join(
|
|
167
|
+
"> " + each_line for each_line in correction_text.splitlines()
|
|
168
|
+
)
|
|
169
|
+
return ISSUE_BODY_TEMPLATE.format(
|
|
170
|
+
source=source,
|
|
171
|
+
quoted_text=quoted_text,
|
|
172
|
+
dedupe_marker=DEDUPE_MARKER_TEMPLATE.format(key=dedupe_key),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _run_gh(all_arguments: list[str]) -> str:
|
|
177
|
+
completed_process = subprocess.run(
|
|
178
|
+
[GH_EXECUTABLE, *all_arguments],
|
|
179
|
+
capture_output=True,
|
|
180
|
+
text=True,
|
|
181
|
+
encoding=UTF8_ENCODING,
|
|
182
|
+
check=True,
|
|
183
|
+
)
|
|
184
|
+
return completed_process.stdout
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _list_labeled_issues(target: FilingTarget, state: str) -> list[dict[str, object]]:
|
|
188
|
+
"""Return the issues carrying the label, with number, title, url, and body."""
|
|
189
|
+
issues_path = ISSUES_LIST_PATH_TEMPLATE.format(
|
|
190
|
+
repository=target.repository, label=quote(target.label), state=state
|
|
191
|
+
)
|
|
192
|
+
issue_lines = _run_gh(["api", "--paginate", issues_path, "--jq", ISSUES_LIST_JQ_FILTER])
|
|
193
|
+
return [json.loads(each_line) for each_line in issue_lines.splitlines() if each_line.strip()]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _ledger_path() -> Path:
|
|
197
|
+
return _config_path().with_name(LEDGER_FILE_NAME)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _read_ledger() -> dict[str, str]:
|
|
201
|
+
try:
|
|
202
|
+
parsed_ledger = json.loads(_ledger_path().read_text(encoding=CONFIG_ENCODING))
|
|
203
|
+
except (OSError, ValueError):
|
|
204
|
+
return {}
|
|
205
|
+
return parsed_ledger if isinstance(parsed_ledger, dict) else {}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _ledger_key_for(target: FilingTarget, dedupe_key: str) -> str:
|
|
209
|
+
return LEDGER_KEY_TEMPLATE.format(repository=target.repository, dedupe_key=dedupe_key)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _record_in_ledger(target: FilingTarget, dedupe_key: str, issue_url: str) -> None:
|
|
213
|
+
url_by_ledger_key = _read_ledger()
|
|
214
|
+
url_by_ledger_key[_ledger_key_for(target, dedupe_key)] = issue_url
|
|
215
|
+
_ledger_path().write_text(json.dumps(url_by_ledger_key), encoding=CONFIG_ENCODING)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _find_filed_issue_url(target: FilingTarget, dedupe_key: str) -> str:
|
|
219
|
+
"""Return the url of an issue already carrying this dedupe key, or empty."""
|
|
220
|
+
ledger_url = _read_ledger().get(_ledger_key_for(target, dedupe_key), "")
|
|
221
|
+
if ledger_url:
|
|
222
|
+
return ledger_url
|
|
223
|
+
for each_issue in _list_labeled_issues(target, "all"):
|
|
224
|
+
each_match = re.search(DEDUPE_MARKER_PATTERN, str(each_issue.get("body", "")))
|
|
225
|
+
if each_match and each_match.group(1) == dedupe_key:
|
|
226
|
+
return str(each_issue.get("url", ""))
|
|
227
|
+
return ""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _file_correction(
|
|
231
|
+
target: FilingTarget, correction_text: str, source: str, supplied_key: str
|
|
232
|
+
) -> str:
|
|
233
|
+
"""File the correction once and return the message to print."""
|
|
234
|
+
redacted_text = _redact_sensitive_text(correction_text.strip())
|
|
235
|
+
dedupe_key = _dedupe_key_for(redacted_text, supplied_key)
|
|
236
|
+
existing_url = _find_filed_issue_url(target, dedupe_key)
|
|
237
|
+
if existing_url:
|
|
238
|
+
return DUPLICATE_MESSAGE_TEMPLATE.format(url=existing_url)
|
|
239
|
+
created_url = _run_gh(
|
|
240
|
+
[
|
|
241
|
+
"api",
|
|
242
|
+
"--method",
|
|
243
|
+
"POST",
|
|
244
|
+
ISSUES_CREATE_PATH_TEMPLATE.format(repository=target.repository),
|
|
245
|
+
"-f",
|
|
246
|
+
TITLE_FIELD_TEMPLATE.format(value=issue_title_for(redacted_text)),
|
|
247
|
+
"-f",
|
|
248
|
+
BODY_FIELD_TEMPLATE.format(value=issue_body_for(redacted_text, source, dedupe_key)),
|
|
249
|
+
"-f",
|
|
250
|
+
LABEL_FIELD_TEMPLATE.format(value=target.label),
|
|
251
|
+
"--jq",
|
|
252
|
+
CREATED_URL_JQ_FILTER,
|
|
253
|
+
]
|
|
254
|
+
).strip()
|
|
255
|
+
_record_in_ledger(target, dedupe_key, created_url)
|
|
256
|
+
return FILED_MESSAGE_TEMPLATE.format(url=created_url)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _open_corrections_listing(target: FilingTarget) -> str:
|
|
260
|
+
"""Return one line per open labeled issue, or a no-open line."""
|
|
261
|
+
all_open_issues = _list_labeled_issues(target, "open")
|
|
262
|
+
if not all_open_issues:
|
|
263
|
+
return NO_OPEN_CORRECTIONS_MESSAGE
|
|
264
|
+
return "".join(
|
|
265
|
+
LIST_LINE_TEMPLATE.format(
|
|
266
|
+
number=each_issue["number"],
|
|
267
|
+
title=each_issue["title"],
|
|
268
|
+
url=each_issue["url"],
|
|
269
|
+
)
|
|
270
|
+
for each_issue in all_open_issues
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
275
|
+
parser = argparse.ArgumentParser(description="File or list user corrections.")
|
|
276
|
+
all_subcommands = parser.add_subparsers(dest="command", required=True)
|
|
277
|
+
file_parser = all_subcommands.add_parser("file", help="File one correction.")
|
|
278
|
+
file_parser.add_argument("--text", default=None)
|
|
279
|
+
file_parser.add_argument("--source", choices=ALL_SOURCES, default=DEFAULT_SOURCE)
|
|
280
|
+
file_parser.add_argument("--dedupe-key", default="")
|
|
281
|
+
all_subcommands.add_parser("list", help="List the open corrections.")
|
|
282
|
+
return parser
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _run_command(target: FilingTarget, parsed_arguments: argparse.Namespace) -> int:
|
|
286
|
+
if parsed_arguments.command == "list":
|
|
287
|
+
sys.stdout.write(_open_corrections_listing(target))
|
|
288
|
+
return SUCCESS_EXIT_CODE
|
|
289
|
+
correction_text = (
|
|
290
|
+
sys.stdin.buffer.read().decode(UTF8_ENCODING)
|
|
291
|
+
if parsed_arguments.text is None
|
|
292
|
+
else parsed_arguments.text
|
|
293
|
+
)
|
|
294
|
+
if not correction_text.strip():
|
|
295
|
+
sys.stderr.write(EMPTY_TEXT_MESSAGE)
|
|
296
|
+
return FAILURE_EXIT_CODE
|
|
297
|
+
sys.stdout.write(
|
|
298
|
+
_file_correction(
|
|
299
|
+
target, correction_text, parsed_arguments.source, parsed_arguments.dedupe_key
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
return SUCCESS_EXIT_CODE
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def main(all_arguments: list[str]) -> int:
|
|
306
|
+
"""Run the file or list command.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
all_arguments: the command-line arguments after the script name.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
The process exit code.
|
|
313
|
+
"""
|
|
314
|
+
parsed_arguments = _build_parser().parse_args(all_arguments)
|
|
315
|
+
try:
|
|
316
|
+
target = load_filing_target(_config_path())
|
|
317
|
+
except FilingConfigMissing as error:
|
|
318
|
+
sys.stderr.write(str(error))
|
|
319
|
+
return FAILURE_EXIT_CODE
|
|
320
|
+
try:
|
|
321
|
+
return _run_command(target, parsed_arguments)
|
|
322
|
+
except subprocess.CalledProcessError as error:
|
|
323
|
+
sys.stderr.write(error.stderr or str(error))
|
|
324
|
+
return FAILURE_EXIT_CODE
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
sys.exit(main(sys.argv[1:]))
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Configuration constants for the correction filing command."""
|
|
2
|
+
|
|
3
|
+
ALL_REDACTION_PATTERNS = (
|
|
4
|
+
r"gh[pousr]_[A-Za-z0-9]{20,}",
|
|
5
|
+
r"github_pat_[A-Za-z0-9_]{20,}",
|
|
6
|
+
r"sk-[A-Za-z0-9_-]{20,}",
|
|
7
|
+
r"xox[abprs]-[A-Za-z0-9-]{10,}",
|
|
8
|
+
r"AKIA[0-9A-Z]{16}",
|
|
9
|
+
r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
|
|
10
|
+
)
|
|
11
|
+
BODY_FIELD_TEMPLATE = "body={value}"
|
|
12
|
+
ALL_SOURCES = ("typed", "flag", "reaction", "hook")
|
|
13
|
+
CLAUDE_DIRECTORY_NAME = ".claude"
|
|
14
|
+
CONFIG_ENCODING = "utf-8"
|
|
15
|
+
CONFIG_FILE_NAME = "correction-capture.json"
|
|
16
|
+
CONFIG_LABEL_KEY = "label"
|
|
17
|
+
CONFIG_PATH_ENV_VAR = "CLAUDE_CORRECTION_CAPTURE_PATH"
|
|
18
|
+
CONFIG_REPOSITORY_KEY = "repository"
|
|
19
|
+
CREATED_URL_JQ_FILTER = ".html_url"
|
|
20
|
+
DEDUPE_MARKER_PATTERN = r"<!-- correction-dedupe: ([0-9a-f]{16,64}) -->"
|
|
21
|
+
DEDUPE_MARKER_TEMPLATE = "<!-- correction-dedupe: {key} -->"
|
|
22
|
+
DEDUPE_KEY_LENGTH = 16
|
|
23
|
+
DEFAULT_SOURCE = "typed"
|
|
24
|
+
DUPLICATE_MESSAGE_TEMPLATE = "Already filed: {url}\n"
|
|
25
|
+
EMPTY_TEXT_MESSAGE = "The correction text is empty. Nothing was filed.\n"
|
|
26
|
+
FAILURE_EXIT_CODE = 1
|
|
27
|
+
FILED_MESSAGE_TEMPLATE = "Filed: {url}\n"
|
|
28
|
+
GH_EXECUTABLE = "gh"
|
|
29
|
+
ISSUE_BODY_TEMPLATE = (
|
|
30
|
+
"A correction the user made to an agent.\n\n"
|
|
31
|
+
"Source: {source}\n\n"
|
|
32
|
+
"{quoted_text}\n\n"
|
|
33
|
+
"{dedupe_marker}\n"
|
|
34
|
+
)
|
|
35
|
+
ISSUES_CREATE_PATH_TEMPLATE = "repos/{repository}/issues"
|
|
36
|
+
ISSUES_LIST_JQ_FILTER = (
|
|
37
|
+
".[] | select(.pull_request | not) | {number, title, url: .html_url, body}"
|
|
38
|
+
)
|
|
39
|
+
ISSUES_LIST_PATH_TEMPLATE = "repos/{repository}/issues?labels={label}&state={state}&per_page=100"
|
|
40
|
+
ISSUE_TITLE_PREFIX = "Correction: "
|
|
41
|
+
ISSUE_TITLE_TEXT_LENGTH = 72
|
|
42
|
+
LABEL_FIELD_TEMPLATE = "labels[]={value}"
|
|
43
|
+
LEDGER_FILE_NAME = "correction-capture-filed.json"
|
|
44
|
+
LEDGER_KEY_TEMPLATE = "{repository} {dedupe_key}"
|
|
45
|
+
LINE_SEPARATOR = "\n"
|
|
46
|
+
LIST_LINE_TEMPLATE = "#{number} {title} {url}\n"
|
|
47
|
+
MISSING_CONFIG_MESSAGE_TEMPLATE = (
|
|
48
|
+
"No correction filing config at {path}. Write it as "
|
|
49
|
+
'{{"repository": "<owner>/<name>", "label": "<label>"}}. Nothing was filed.\n'
|
|
50
|
+
)
|
|
51
|
+
NO_OPEN_CORRECTIONS_MESSAGE = "No open corrections.\n"
|
|
52
|
+
REDACTION_PLACEHOLDER = "[redacted]"
|
|
53
|
+
SUCCESS_EXIT_CODE = 0
|
|
54
|
+
TITLE_FIELD_TEMPLATE = "title={value}"
|
|
55
|
+
UTF8_ENCODING = "utf-8"
|
|
56
|
+
WORD_SEPARATOR = " "
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Behavior tests for correction_filing with a stand-in gh on PATH."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
16
|
+
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
18
|
+
|
|
19
|
+
correction_filing = importlib.import_module("correction_filing")
|
|
20
|
+
|
|
21
|
+
STAND_IN_GH = r'''#!/usr/bin/env python3
|
|
22
|
+
import json, os, sys
|
|
23
|
+
from urllib.parse import parse_qs, urlsplit
|
|
24
|
+
store = os.environ["STAND_IN_GH_STORE"]
|
|
25
|
+
issues = json.load(open(store)) if os.path.exists(store) else []
|
|
26
|
+
args = sys.argv[1:]
|
|
27
|
+
if "POST" in args:
|
|
28
|
+
fields = dict(args[i + 1].split("=", 1) for i, a in enumerate(args) if a == "-f")
|
|
29
|
+
number = len(issues) + 1
|
|
30
|
+
url = "https://example.test/issues/%d" % number
|
|
31
|
+
issues.append({"number": number, "title": fields["title"], "url": url,
|
|
32
|
+
"body": fields["body"], "label": fields["labels[]"], "state": "open"})
|
|
33
|
+
json.dump(issues, open(store, "w"))
|
|
34
|
+
print(url)
|
|
35
|
+
else:
|
|
36
|
+
query = parse_qs(urlsplit(args[2]).query)
|
|
37
|
+
state = query["state"][0]
|
|
38
|
+
for i in issues:
|
|
39
|
+
if i["label"] == query["labels"][0] and (state == "all" or i["state"] == state):
|
|
40
|
+
print(json.dumps({k: i[k] for k in ("number", "title", "url", "body")}))
|
|
41
|
+
'''
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def filing_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
46
|
+
bin_directory = tmp_path / "bin"
|
|
47
|
+
bin_directory.mkdir()
|
|
48
|
+
gh_path = bin_directory / "gh"
|
|
49
|
+
gh_path.write_text(STAND_IN_GH, encoding="utf-8")
|
|
50
|
+
gh_path.chmod(gh_path.stat().st_mode | stat.S_IEXEC)
|
|
51
|
+
store_path = tmp_path / "issues.json"
|
|
52
|
+
config_file = tmp_path / "correction-capture.json"
|
|
53
|
+
config_file.write_text(json.dumps({"repository": "owner/name", "label": "correction"}))
|
|
54
|
+
monkeypatch.setenv("PATH", str(bin_directory) + os.pathsep + os.environ["PATH"])
|
|
55
|
+
monkeypatch.setenv("STAND_IN_GH_STORE", str(store_path))
|
|
56
|
+
monkeypatch.setenv("CLAUDE_CORRECTION_CAPTURE_PATH", str(config_file))
|
|
57
|
+
return store_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _stored_issues(store_path: Path) -> list[dict[str, object]]:
|
|
61
|
+
return json.loads(store_path.read_text()) if store_path.exists() else []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_should_file_one_labeled_issue_quoting_the_text(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
65
|
+
assert correction_filing.main(["file", "--text", "use shorter replies"]) == 0
|
|
66
|
+
all_issues = _stored_issues(filing_environment)
|
|
67
|
+
assert len(all_issues) == 1
|
|
68
|
+
assert all_issues[0]["label"] == "correction"
|
|
69
|
+
assert "> use shorter replies" in all_issues[0]["body"]
|
|
70
|
+
assert "Filed: https://example.test/issues/1" in capsys.readouterr().out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_should_file_a_replayed_correction_once(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
74
|
+
correction_filing.main(["file", "--text", "stop asking me"])
|
|
75
|
+
assert correction_filing.main(["file", "--text", "stop asking me"]) == 0
|
|
76
|
+
assert len(_stored_issues(filing_environment)) == 1
|
|
77
|
+
assert "Already filed: https://example.test/issues/1" in capsys.readouterr().out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_should_dedupe_on_a_supplied_key(filing_environment: Path) -> None:
|
|
81
|
+
correction_filing.main(["file", "--text", "first", "--dedupe-key", "msg-7"])
|
|
82
|
+
correction_filing.main(["file", "--text", "second", "--dedupe-key", "msg-7"])
|
|
83
|
+
correction_filing.main(["file", "--text", "second", "--dedupe-key", "msg-8"])
|
|
84
|
+
assert len(_stored_issues(filing_environment)) == 2
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_should_mask_tokens_and_emails(filing_environment: Path) -> None:
|
|
88
|
+
correction_filing.main(
|
|
89
|
+
["file", "--text", "you leaked ghp_" + "a" * 36 + " and me@example.com"]
|
|
90
|
+
)
|
|
91
|
+
body = _stored_issues(filing_environment)[0]["body"]
|
|
92
|
+
assert "ghp_" not in body and "me@example.com" not in body
|
|
93
|
+
assert body.count("[redacted]") == 2
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_should_file_nothing_without_config(filing_environment: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
97
|
+
monkeypatch.setenv("CLAUDE_CORRECTION_CAPTURE_PATH", str(tmp_path / "absent.json"))
|
|
98
|
+
assert correction_filing.main(["file", "--text", "anything"]) == 1
|
|
99
|
+
assert _stored_issues(filing_environment) == []
|
|
100
|
+
assert "No correction filing config" in capsys.readouterr().err
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_should_file_nothing_for_empty_text(filing_environment: Path) -> None:
|
|
104
|
+
assert correction_filing.main(["file", "--text", " "]) == 1
|
|
105
|
+
assert _stored_issues(filing_environment) == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_should_list_open_corrections(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
109
|
+
correction_filing.main(["list"])
|
|
110
|
+
assert "No open corrections." in capsys.readouterr().out
|
|
111
|
+
correction_filing.main(["file", "--text", "use plain words"])
|
|
112
|
+
capsys.readouterr()
|
|
113
|
+
correction_filing.main(["list"])
|
|
114
|
+
assert "#1 Correction: use plain words https://example.test/issues/1" in capsys.readouterr().out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_should_build_title_and_body_from_text() -> None:
|
|
118
|
+
assert correction_filing.issue_title_for("a\nb") == "Correction: a b"
|
|
119
|
+
body = correction_filing.issue_body_for("x\ny", "flag", "abcdef0123456789")
|
|
120
|
+
assert "> x\n> y" in body and "Source: flag" in body
|
|
121
|
+
assert "<!-- correction-dedupe: abcdef0123456789 -->" in body
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_should_read_target_and_reject_incomplete_config(tmp_path: Path) -> None:
|
|
125
|
+
good = tmp_path / "good.json"
|
|
126
|
+
good.write_text(json.dumps({"repository": "o/n", "label": "l"}))
|
|
127
|
+
assert correction_filing.load_filing_target(good) == correction_filing.FilingTarget("o/n", "l")
|
|
128
|
+
bad = tmp_path / "bad.json"
|
|
129
|
+
bad.write_text(json.dumps({"repository": "o/n"}))
|
|
130
|
+
with pytest.raises(correction_filing.FilingConfigMissing):
|
|
131
|
+
correction_filing.load_filing_target(bad)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_should_dedupe_from_the_ledger_when_the_listing_lags(filing_environment: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
135
|
+
correction_filing.main(["file", "--text", "keep replies short"])
|
|
136
|
+
filing_environment.write_text("[]")
|
|
137
|
+
capsys.readouterr()
|
|
138
|
+
correction_filing.main(["file", "--text", "keep replies short"])
|
|
139
|
+
assert "Already filed: https://example.test/issues/1" in capsys.readouterr().out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_should_read_the_text_from_stdin_without_expanding_it(filing_environment: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
143
|
+
piped_bytes = 'say "$(whoami)" and `id` in caf\u00e9\n'.encode("utf-8")
|
|
144
|
+
monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(piped_bytes), encoding="cp1252"))
|
|
145
|
+
assert correction_filing.main(["file"]) == 0
|
|
146
|
+
assert '> say "$(whoami)" and `id` in caf\u00e9' in _stored_issues(filing_environment)[0]["body"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_should_file_again_after_the_config_names_another_repository(filing_environment: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
|
150
|
+
correction_filing.main(["file", "--text", "keep replies short"])
|
|
151
|
+
filing_environment.write_text("[]")
|
|
152
|
+
(tmp_path / "correction-capture.json").write_text(json.dumps({"repository": "owner/other", "label": "correction"}))
|
|
153
|
+
capsys.readouterr()
|
|
154
|
+
correction_filing.main(["file", "--text", "keep replies short"])
|
|
155
|
+
assert "Filed: https://example.test/issues/1" in capsys.readouterr().out
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: refuse a Read of an image larger than the agent cap.
|
|
3
|
+
|
|
4
|
+
A Read of an image puts the whole picture in the context. A phone screenshot
|
|
5
|
+
at 1440x2560 costs about 4,784 tokens, the most one image can cost. The hook
|
|
6
|
+
reads the width and height from the file header and refuses the Read when the
|
|
7
|
+
long edge passes the cap, naming the command that writes a capped copy::
|
|
8
|
+
|
|
9
|
+
Read shot.png (1440x2560) -> refused, names agent_image_copy.py
|
|
10
|
+
Read shot.agent.png (288x512) -> allowed
|
|
11
|
+
Read notes.md -> allowed
|
|
12
|
+
Read broken.png (no header) -> allowed
|
|
13
|
+
|
|
14
|
+
A missing file, a WebP or other unknown format and an unreadable header all pass, so the
|
|
15
|
+
hook never fails closed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import struct
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
26
|
+
if hooks_root_directory not in sys.path:
|
|
27
|
+
sys.path.insert(0, hooks_root_directory)
|
|
28
|
+
|
|
29
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
30
|
+
HOOK_EVENT_NAME_KEY,
|
|
31
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
32
|
+
PERMISSION_DECISION_KEY,
|
|
33
|
+
)
|
|
34
|
+
from hooks_constants.image_read_size_gate_constants import (
|
|
35
|
+
AGENT_COPY_SCRIPT_PATH,
|
|
36
|
+
ALL_JPEG_FRAME_MARKERS,
|
|
37
|
+
ALL_JPEG_STANDALONE_MARKERS,
|
|
38
|
+
DENY_DECISION,
|
|
39
|
+
FILE_PATH_INPUT_KEY,
|
|
40
|
+
ALL_GIF_SIGNATURES,
|
|
41
|
+
GIF_SIZE_END,
|
|
42
|
+
GIF_SIZE_START,
|
|
43
|
+
HEADER_BYTE_COUNT,
|
|
44
|
+
JPEG_FRAME_SIZE_END,
|
|
45
|
+
JPEG_FRAME_SIZE_START,
|
|
46
|
+
JPEG_MARKER_LENGTH,
|
|
47
|
+
JPEG_MARKER_PREFIX,
|
|
48
|
+
JPEG_SIGNATURE,
|
|
49
|
+
MAXIMUM_LONG_EDGE_PIXELS,
|
|
50
|
+
OVERSIZED_IMAGE_REASON_TEMPLATE,
|
|
51
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
52
|
+
PNG_SIGNATURE,
|
|
53
|
+
PNG_SIZE_END,
|
|
54
|
+
PNG_SIZE_START,
|
|
55
|
+
PRE_TOOL_USE_EVENT_NAME,
|
|
56
|
+
READ_TOOL_NAME,
|
|
57
|
+
TOOL_INPUT_KEY,
|
|
58
|
+
TOOL_NAME_KEY,
|
|
59
|
+
)
|
|
60
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _jpeg_size(header: bytes) -> tuple[int, int] | None:
|
|
64
|
+
offset = len(JPEG_SIGNATURE)
|
|
65
|
+
while offset + JPEG_FRAME_SIZE_END <= len(header):
|
|
66
|
+
if header[offset] != JPEG_MARKER_PREFIX:
|
|
67
|
+
return None
|
|
68
|
+
marker = header[offset + 1]
|
|
69
|
+
if marker == JPEG_MARKER_PREFIX:
|
|
70
|
+
offset += 1
|
|
71
|
+
continue
|
|
72
|
+
if marker in ALL_JPEG_STANDALONE_MARKERS:
|
|
73
|
+
offset += JPEG_MARKER_LENGTH
|
|
74
|
+
continue
|
|
75
|
+
if marker in ALL_JPEG_FRAME_MARKERS:
|
|
76
|
+
height, width = struct.unpack(
|
|
77
|
+
">HH", header[offset + JPEG_FRAME_SIZE_START : offset + JPEG_FRAME_SIZE_END]
|
|
78
|
+
)
|
|
79
|
+
return width, height
|
|
80
|
+
segment_length_end = offset + JPEG_MARKER_LENGTH + JPEG_MARKER_LENGTH
|
|
81
|
+
offset += JPEG_MARKER_LENGTH + struct.unpack(">H", header[offset + JPEG_MARKER_LENGTH : segment_length_end])[0]
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def image_size(header: bytes) -> tuple[int, int] | None:
|
|
86
|
+
"""Return (width, height) read from the first bytes of a PNG, JPEG or GIF file.
|
|
87
|
+
|
|
88
|
+
::
|
|
89
|
+
|
|
90
|
+
PNG header of a 1440x2560 file -> (1440, 2560)
|
|
91
|
+
b"plain text" -> None
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
header: The first bytes of the file.
|
|
95
|
+
"""
|
|
96
|
+
if header.startswith(PNG_SIGNATURE) and len(header) >= PNG_SIZE_END:
|
|
97
|
+
width, height = struct.unpack(">II", header[PNG_SIZE_START:PNG_SIZE_END])
|
|
98
|
+
return width, height
|
|
99
|
+
if header.startswith(ALL_GIF_SIGNATURES) and len(header) >= GIF_SIZE_END:
|
|
100
|
+
width, height = struct.unpack("<HH", header[GIF_SIZE_START:GIF_SIZE_END])
|
|
101
|
+
return width, height
|
|
102
|
+
if header.startswith(JPEG_SIGNATURE):
|
|
103
|
+
return _jpeg_size(header)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_header(file_path: str) -> bytes:
|
|
108
|
+
try:
|
|
109
|
+
with open(file_path, "rb") as image_file:
|
|
110
|
+
return image_file.read(HEADER_BYTE_COUNT)
|
|
111
|
+
except OSError:
|
|
112
|
+
return b""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def oversized_image_reason(all_hook_fields: dict[str, object]) -> str | None:
|
|
116
|
+
"""Return the refusal for a Read of an image over the cap, or None to allow it.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
all_hook_fields: The parsed PreToolUse payload.
|
|
120
|
+
"""
|
|
121
|
+
tool_input = all_hook_fields.get(TOOL_INPUT_KEY)
|
|
122
|
+
if all_hook_fields.get(TOOL_NAME_KEY) != READ_TOOL_NAME or not isinstance(tool_input, dict):
|
|
123
|
+
return None
|
|
124
|
+
file_path = tool_input.get(FILE_PATH_INPUT_KEY)
|
|
125
|
+
if not isinstance(file_path, str):
|
|
126
|
+
return None
|
|
127
|
+
dimensions = image_size(_read_header(file_path))
|
|
128
|
+
if dimensions is None or max(dimensions) <= MAXIMUM_LONG_EDGE_PIXELS:
|
|
129
|
+
return None
|
|
130
|
+
width, height = dimensions
|
|
131
|
+
return OVERSIZED_IMAGE_REASON_TEMPLATE.format(
|
|
132
|
+
width=width,
|
|
133
|
+
height=height,
|
|
134
|
+
maximum_edge=MAXIMUM_LONG_EDGE_PIXELS,
|
|
135
|
+
script_path=AGENT_COPY_SCRIPT_PATH,
|
|
136
|
+
image_path=file_path,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main() -> int:
|
|
141
|
+
"""Read the PreToolUse payload and print a deny decision for an oversized image.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
0 in every case; the decision travels in the JSON output.
|
|
145
|
+
"""
|
|
146
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
147
|
+
if hook_payload is None:
|
|
148
|
+
return 0
|
|
149
|
+
refusal_reason = oversized_image_reason(hook_payload)
|
|
150
|
+
if refusal_reason is not None:
|
|
151
|
+
sys.stdout.write(
|
|
152
|
+
json.dumps(
|
|
153
|
+
{
|
|
154
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
155
|
+
HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
|
|
156
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
157
|
+
PERMISSION_DECISION_REASON_KEY: refusal_reason,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
sys.stdout.flush()
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
sys.exit(main())
|
|
@@ -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
|
+
)
|
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.
|
|
@@ -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.
|
package/hooks/hooks.json
CHANGED
|
@@ -97,6 +97,16 @@
|
|
|
97
97
|
}
|
|
98
98
|
]
|
|
99
99
|
},
|
|
100
|
+
{
|
|
101
|
+
"matcher": "Read",
|
|
102
|
+
"hooks": [
|
|
103
|
+
{
|
|
104
|
+
"type": "command",
|
|
105
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/image_read_size_gate.py",
|
|
106
|
+
"timeout": 10
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
},
|
|
100
110
|
{
|
|
101
111
|
"matcher": "EnterWorktree",
|
|
102
112
|
"hooks": [
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Names, limits and refusal text for the image_read_size_gate PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
READ_TOOL_NAME = "Read"
|
|
8
|
+
TOOL_NAME_KEY = "tool_name"
|
|
9
|
+
TOOL_INPUT_KEY = "tool_input"
|
|
10
|
+
FILE_PATH_INPUT_KEY = "file_path"
|
|
11
|
+
PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
|
|
12
|
+
DENY_DECISION = "deny"
|
|
13
|
+
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
14
|
+
MAXIMUM_LONG_EDGE_PIXELS = 512
|
|
15
|
+
HEADER_BYTE_COUNT = 64 * 1024
|
|
16
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
17
|
+
PNG_SIZE_START = 16
|
|
18
|
+
PNG_SIZE_END = 24
|
|
19
|
+
GIF_SIZE_START = 6
|
|
20
|
+
GIF_SIZE_END = 10
|
|
21
|
+
ALL_GIF_SIGNATURES = (b"GIF87a", b"GIF89a")
|
|
22
|
+
JPEG_SIGNATURE = b"\xff\xd8"
|
|
23
|
+
JPEG_MARKER_PREFIX = 0xFF
|
|
24
|
+
JPEG_MARKER_LENGTH = 2
|
|
25
|
+
JPEG_FRAME_SIZE_START = 5
|
|
26
|
+
JPEG_FRAME_SIZE_END = 9
|
|
27
|
+
ALL_JPEG_FRAME_MARKERS = frozenset(
|
|
28
|
+
{0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
|
|
29
|
+
)
|
|
30
|
+
ALL_JPEG_STANDALONE_MARKERS = frozenset(
|
|
31
|
+
{0x01, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8}
|
|
32
|
+
)
|
|
33
|
+
AGENT_COPY_SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "agent_image_copy.py"
|
|
34
|
+
OVERSIZED_IMAGE_REASON_TEMPLATE = (
|
|
35
|
+
"This image is {width}x{height} pixels. Agents read images at {maximum_edge} pixels or less"
|
|
36
|
+
" on the long edge, because a full-size image fills the context: a 1440x2560 screenshot costs"
|
|
37
|
+
" about 4,784 tokens, and a 288x512 copy costs about 209. Write a capped copy and read that"
|
|
38
|
+
' copy: `python3 "{script_path}" "{image_path}"` prints the path of the copy, which keeps'
|
|
39
|
+
" the aspect ratio. Crop the region you need with `--crop LEFT,TOP,RIGHT,BOTTOM` when a"
|
|
40
|
+
" detail is too small at that size. Send the full-size file to a person, never to an agent."
|
|
41
|
+
)
|
package/package.json
CHANGED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Write a copy of an image small enough for an agent to read.
|
|
2
|
+
|
|
3
|
+
The image_read_size_gate hook refuses a Read of an image whose long edge is over
|
|
4
|
+
512 pixels and names this script. The script writes ``<name>.agent.png`` next to
|
|
5
|
+
the source, scaled so the long edge is 512 pixels with the aspect ratio kept,
|
|
6
|
+
and prints its path::
|
|
7
|
+
|
|
8
|
+
python3 agent_image_copy.py shot.png -> shot.agent.png (288x512)
|
|
9
|
+
python3 agent_image_copy.py shot.png --crop 0,0,720,640
|
|
10
|
+
-> the top-left region, capped
|
|
11
|
+
python3 agent_image_copy.py icon.png (200x200) -> icon.agent.png, same size
|
|
12
|
+
|
|
13
|
+
The copy is newer than the source, so a check that wants a screenshot read
|
|
14
|
+
after a page changed still counts it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import math
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
scripts_directory = str(Path(__file__).resolve().parent)
|
|
25
|
+
if scripts_directory not in sys.path:
|
|
26
|
+
sys.path.insert(0, scripts_directory)
|
|
27
|
+
|
|
28
|
+
from dev_env_scripts_constants.agent_image_copy_constants import (
|
|
29
|
+
AGENT_COPY_SUFFIX,
|
|
30
|
+
ALL_PNG_WRITABLE_MODES,
|
|
31
|
+
CROP_BOX_PART_COUNT,
|
|
32
|
+
CROP_BOX_SEPARATOR,
|
|
33
|
+
MAXIMUM_LONG_EDGE_PIXELS,
|
|
34
|
+
PNG_FALLBACK_MODE,
|
|
35
|
+
VISUAL_TOKEN_PATCH_PIXELS,
|
|
36
|
+
)
|
|
37
|
+
from PIL import Image, ImageOps
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def capped_size(
|
|
41
|
+
width: int, height: int, maximum_edge: int = MAXIMUM_LONG_EDGE_PIXELS
|
|
42
|
+
) -> tuple[int, int]:
|
|
43
|
+
"""Return the size that fits the long edge into maximum_edge with the aspect ratio kept.
|
|
44
|
+
|
|
45
|
+
::
|
|
46
|
+
|
|
47
|
+
capped_size(1440, 2560) -> (288, 512)
|
|
48
|
+
capped_size(2000, 1000) -> (512, 256)
|
|
49
|
+
capped_size(300, 200) -> (300, 200)
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
width: Source width in pixels.
|
|
53
|
+
height: Source height in pixels.
|
|
54
|
+
maximum_edge: The largest long edge allowed.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The capped (width, height).
|
|
58
|
+
"""
|
|
59
|
+
long_edge = max(width, height)
|
|
60
|
+
if long_edge <= maximum_edge:
|
|
61
|
+
return width, height
|
|
62
|
+
scale = maximum_edge / long_edge
|
|
63
|
+
return max(1, round(width * scale)), max(1, round(height * scale))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def visual_tokens(width: int, height: int) -> int:
|
|
67
|
+
"""Return the documented visual-token cost of an image: one token per 28x28 patch.
|
|
68
|
+
|
|
69
|
+
::
|
|
70
|
+
|
|
71
|
+
visual_tokens(1440, 2560) -> 4784
|
|
72
|
+
visual_tokens(288, 512) -> 209
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
width: Image width in pixels as the model sees it.
|
|
76
|
+
height: Image height in pixels as the model sees it.
|
|
77
|
+
"""
|
|
78
|
+
return math.ceil(width / VISUAL_TOKEN_PATCH_PIXELS) * math.ceil(
|
|
79
|
+
height / VISUAL_TOKEN_PATCH_PIXELS
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_crop_box(crop_text: str) -> tuple[int, int, int, int]:
|
|
84
|
+
"""Return LEFT, TOP, RIGHT, BOTTOM parsed from text like ``0,0,720,640``.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
crop_text: Four comma-separated pixel coordinates.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The four coordinates as integers.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
argparse.ArgumentTypeError: When the text does not hold four numbers.
|
|
94
|
+
"""
|
|
95
|
+
all_parts = crop_text.split(CROP_BOX_SEPARATOR)
|
|
96
|
+
if len(all_parts) != CROP_BOX_PART_COUNT:
|
|
97
|
+
raise argparse.ArgumentTypeError(
|
|
98
|
+
f"--crop takes LEFT,TOP,RIGHT,BOTTOM, got {crop_text!r}"
|
|
99
|
+
)
|
|
100
|
+
left, top, right, bottom = (int(each_part) for each_part in all_parts)
|
|
101
|
+
return left, top, right, bottom
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_agent_copy(
|
|
105
|
+
source_path: Path, all_crop_coordinates: tuple[int, int, int, int] | None
|
|
106
|
+
) -> Path:
|
|
107
|
+
"""Write the capped copy beside the source and return its path.
|
|
108
|
+
|
|
109
|
+
The copy is turned upright by its EXIF orientation before the crop, and a mode PNG
|
|
110
|
+
cannot hold, such as CMYK, is converted to RGB.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
source_path: The full-size image.
|
|
114
|
+
all_crop_coordinates: A LEFT, TOP, RIGHT, BOTTOM region cut before scaling, or None for the whole image.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The path of the written copy.
|
|
118
|
+
"""
|
|
119
|
+
copy_path = source_path.with_name(source_path.stem + AGENT_COPY_SUFFIX)
|
|
120
|
+
with Image.open(source_path) as source_image:
|
|
121
|
+
upright_image = ImageOps.exif_transpose(source_image)
|
|
122
|
+
working_image = upright_image.crop(all_crop_coordinates) if all_crop_coordinates else upright_image.copy()
|
|
123
|
+
if working_image.mode not in ALL_PNG_WRITABLE_MODES:
|
|
124
|
+
working_image = working_image.convert(PNG_FALLBACK_MODE)
|
|
125
|
+
working_image = working_image.resize(
|
|
126
|
+
capped_size(*working_image.size), Image.Resampling.LANCZOS
|
|
127
|
+
)
|
|
128
|
+
working_image.save(copy_path, optimize=True)
|
|
129
|
+
return copy_path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main(all_arguments: list[str]) -> int:
|
|
133
|
+
"""Write the capped copy named on the command line and print its path and token cost.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
all_arguments: Command-line arguments after the program name.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
0 when the copy was written.
|
|
140
|
+
"""
|
|
141
|
+
parser = argparse.ArgumentParser(
|
|
142
|
+
description="Write an agent-size copy of an image."
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument("image_path", type=Path)
|
|
145
|
+
parser.add_argument("--crop", type=parse_crop_box, default=None)
|
|
146
|
+
parsed = parser.parse_args(all_arguments)
|
|
147
|
+
copy_path = write_agent_copy(parsed.image_path, parsed.crop)
|
|
148
|
+
with Image.open(copy_path) as copy_image:
|
|
149
|
+
width, height = copy_image.size
|
|
150
|
+
print(f"{copy_path} {width}x{height} about {visual_tokens(width, height)} tokens")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Limits and names for the agent_image_copy script."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
MAXIMUM_LONG_EDGE_PIXELS = 512
|
|
6
|
+
VISUAL_TOKEN_PATCH_PIXELS = 28
|
|
7
|
+
AGENT_COPY_SUFFIX = ".agent.png"
|
|
8
|
+
CROP_BOX_SEPARATOR = ","
|
|
9
|
+
CROP_BOX_PART_COUNT = 4
|
|
10
|
+
ALL_PNG_WRITABLE_MODES = frozenset({"1", "L", "LA", "I", "I;16", "P", "RGB", "RGBA"})
|
|
11
|
+
PNG_FALLBACK_MODE = "RGB"
|
|
@@ -71,6 +71,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
71
71
|
"blocking/verify_before_acting.py",
|
|
72
72
|
"blocking/session_title_format_gate.py",
|
|
73
73
|
"blocking/session_title_stop_gate.py",
|
|
74
|
+
"blocking/image_read_size_gate.py",
|
|
74
75
|
"blocking/artifact_dark_mode_gate.py",
|
|
75
76
|
"blocking/artifact_look_gate.py",
|
|
76
77
|
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Behavior tests for the agent image copy script."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
11
|
+
|
|
12
|
+
import agent_image_copy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_capped_size_should_keep_the_aspect_ratio() -> None:
|
|
16
|
+
assert agent_image_copy.capped_size(1440, 2560) == (288, 512)
|
|
17
|
+
assert agent_image_copy.capped_size(2000, 1000) == (512, 256)
|
|
18
|
+
assert agent_image_copy.capped_size(300, 200) == (300, 200)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_visual_tokens_should_count_28_pixel_patches() -> None:
|
|
22
|
+
assert agent_image_copy.visual_tokens(1440, 2560) == 4784
|
|
23
|
+
assert agent_image_copy.visual_tokens(288, 512) == 209
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_parse_crop_box_should_refuse_three_numbers() -> None:
|
|
27
|
+
assert agent_image_copy.parse_crop_box("0,0,10,20") == (0, 0, 10, 20)
|
|
28
|
+
with pytest.raises(argparse.ArgumentTypeError):
|
|
29
|
+
agent_image_copy.parse_crop_box("0,0,10")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_write_agent_copy_should_write_a_capped_newer_file(tmp_path: Path) -> None:
|
|
33
|
+
source_path = tmp_path / "preview.png"
|
|
34
|
+
Image.new("RGB", (1440, 2560), "white").save(source_path)
|
|
35
|
+
copy_path = agent_image_copy.write_agent_copy(source_path, None)
|
|
36
|
+
assert copy_path.name == "preview.agent.png"
|
|
37
|
+
with Image.open(copy_path) as copy_image:
|
|
38
|
+
assert copy_image.size == (288, 512)
|
|
39
|
+
assert copy_path.stat().st_mtime >= source_path.stat().st_mtime
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_write_agent_copy_should_crop_before_scaling(tmp_path: Path) -> None:
|
|
43
|
+
source_path = tmp_path / "preview.png"
|
|
44
|
+
Image.new("RGB", (1440, 2560), "white").save(source_path)
|
|
45
|
+
copy_path = agent_image_copy.write_agent_copy(source_path, (0, 0, 720, 640))
|
|
46
|
+
with Image.open(copy_path) as copy_image:
|
|
47
|
+
assert copy_image.size == (512, 455)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_write_agent_copy_should_write_a_cmyk_jpeg_as_rgb(tmp_path: Path) -> None:
|
|
51
|
+
source_path = tmp_path / "print.jpg"
|
|
52
|
+
Image.new("CMYK", (1000, 800)).save(source_path)
|
|
53
|
+
copy_path = agent_image_copy.write_agent_copy(source_path, None)
|
|
54
|
+
with Image.open(copy_path) as copy_image:
|
|
55
|
+
assert copy_image.mode == "RGB"
|
|
56
|
+
assert copy_image.size == (512, 410)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_write_agent_copy_should_follow_the_exif_orientation(tmp_path: Path) -> None:
|
|
60
|
+
source_path = tmp_path / "phone.jpg"
|
|
61
|
+
source_image = Image.new("RGB", (1000, 600), "white")
|
|
62
|
+
all_exif_tags = source_image.getexif()
|
|
63
|
+
all_exif_tags[0x0112] = 6
|
|
64
|
+
source_image.save(source_path, exif=all_exif_tags)
|
|
65
|
+
copy_path = agent_image_copy.write_agent_copy(source_path, None)
|
|
66
|
+
with Image.open(copy_path) as copy_image:
|
|
67
|
+
assert copy_image.size == (307, 512)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_main_should_print_the_copy_path(
|
|
71
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
72
|
+
) -> None:
|
|
73
|
+
source_path = tmp_path / "shot.png"
|
|
74
|
+
Image.new("RGB", (1600, 900), "white").save(source_path)
|
|
75
|
+
assert agent_image_copy.main([str(source_path)]) == 0
|
|
76
|
+
assert "shot.agent.png 512x288 about 209 tokens" in capsys.readouterr().out
|