claude-dev-env 1.88.1 → 1.90.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.
@@ -0,0 +1,210 @@
1
+ """Unit and integration tests for the volatile_path_in_post_blocker PreToolUse hook."""
2
+
3
+ import importlib.util
4
+ import json
5
+ import pathlib
6
+ import subprocess
7
+ import sys
8
+
9
+ _HOOK_DIR = pathlib.Path(__file__).parent
10
+ if str(_HOOK_DIR) not in sys.path:
11
+ sys.path.insert(0, str(_HOOK_DIR))
12
+
13
+ hook_spec = importlib.util.spec_from_file_location(
14
+ "volatile_path_in_post_blocker",
15
+ _HOOK_DIR / "volatile_path_in_post_blocker.py",
16
+ )
17
+ assert hook_spec is not None
18
+ assert hook_spec.loader is not None
19
+ hook_module = importlib.util.module_from_spec(hook_spec)
20
+ hook_spec.loader.exec_module(hook_module)
21
+
22
+ scan_text_for_volatile_marker = hook_module.scan_text_for_volatile_marker
23
+ extract_gh_post_body_texts = hook_module.extract_gh_post_body_texts
24
+ extract_mcp_body_texts = hook_module.extract_mcp_body_texts
25
+ _collect_body_texts = hook_module._collect_body_texts
26
+
27
+
28
+ def _body_names_volatile_path(tool_name: str, tool_input: dict[str, object]) -> bool:
29
+ all_body_texts = _collect_body_texts(tool_name, tool_input)
30
+ return hook_module._first_volatile_marker(all_body_texts) is not None
31
+
32
+
33
+ def test_scan_detects_job_scratch_path_backslash() -> None:
34
+ text = r"See C:\Users\jon\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"
35
+ assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
36
+
37
+
38
+ def test_scan_detects_job_scratch_path_forward_slash() -> None:
39
+ text = "artifact at /home/user/.claude-editor/jobs/abc/tmp/out.png"
40
+ assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
41
+
42
+
43
+ def test_scan_detects_worktree_path() -> None:
44
+ text = r"edited .claude\worktrees\feature\file.py"
45
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
46
+
47
+
48
+ def test_scan_detects_appdata_temp_case_insensitive() -> None:
49
+ text = r"C:\Users\jon\AppData\Local\Temp\bugteam\worktree"
50
+ assert scan_text_for_volatile_marker(text) == "appdata/local/temp"
51
+
52
+
53
+ def test_scan_detects_unix_tmp() -> None:
54
+ assert scan_text_for_volatile_marker("output written to /tmp/run/out.log") == "/tmp/"
55
+
56
+
57
+ def test_scan_detects_percent_temp_token() -> None:
58
+ assert scan_text_for_volatile_marker("saved to %TEMP%\\out.txt") == "%temp%"
59
+
60
+
61
+ def test_scan_detects_env_temp_token() -> None:
62
+ assert scan_text_for_volatile_marker("path is $env:TEMP\\out.txt") == "$env:temp"
63
+
64
+
65
+ def test_scan_detects_claude_job_dir_token() -> None:
66
+ assert scan_text_for_volatile_marker("see $CLAUDE_JOB_DIR/staging/report.md") == "$claude_job_dir"
67
+
68
+
69
+ def test_scan_clean_body_returns_none() -> None:
70
+ text = "The failing table is pasted below:\n| case | result |\n| a | pass |"
71
+ assert scan_text_for_volatile_marker(text) is None
72
+
73
+
74
+ def test_gh_comment_with_job_scratch_path_is_blocked() -> None:
75
+ command = (
76
+ 'gh pr comment 669 --body "Contact sheet at '
77
+ r'C:\Users\jon\.claude-editor\jobs\95762cea\tmp\staging\contact_sheet.png"'
78
+ )
79
+ assert _body_names_volatile_path("Bash", {"command": command})
80
+
81
+
82
+ def test_gh_comment_with_clean_body_is_allowed() -> None:
83
+ command = 'gh pr comment 669 --body "All checks pass. Table pasted inline above."'
84
+ assert not _body_names_volatile_path("Bash", {"command": command})
85
+
86
+
87
+ def test_gh_pr_create_short_flag_blocked() -> None:
88
+ command = 'gh pr create --title "T" -b "logs under /tmp/run/out.log"'
89
+ assert _body_names_volatile_path("Bash", {"command": command})
90
+
91
+
92
+ def test_gh_issue_create_body_equals_form_blocked() -> None:
93
+ command = 'gh issue create --title "T" --body="dump in %TEMP%\\dump.json"'
94
+ assert _body_names_volatile_path("Bash", {"command": command})
95
+
96
+
97
+ def test_body_file_content_is_scanned_and_blocked(tmp_path: pathlib.Path) -> None:
98
+ body_file = tmp_path / "body.md"
99
+ body_file.write_text(
100
+ "Artifact: $CLAUDE_JOB_DIR/tmp/contact_sheet.png",
101
+ encoding="utf-8",
102
+ )
103
+ command = f"gh pr comment 669 --body-file {body_file}"
104
+ assert _body_names_volatile_path("Bash", {"command": command})
105
+
106
+
107
+ def test_body_file_clean_content_is_allowed(tmp_path: pathlib.Path) -> None:
108
+ body_file = tmp_path / "body.md"
109
+ body_file.write_text("Everything is green. Results inline above.", encoding="utf-8")
110
+ command = f"gh pr comment 669 --body-file {body_file}"
111
+ assert not _body_names_volatile_path("Bash", {"command": command})
112
+
113
+
114
+ def test_non_post_gh_command_is_untouched() -> None:
115
+ assert not _body_names_volatile_path("Bash", {"command": "gh pr list --repo owner/repo"})
116
+
117
+
118
+ def test_gh_pr_view_with_tmp_in_flag_is_untouched() -> None:
119
+ command = "gh pr view 10 --json body --jq .body > /tmp/out.json"
120
+ assert not _body_names_volatile_path("Bash", {"command": command})
121
+
122
+
123
+ def test_substring_mention_of_gh_post_does_not_classify() -> None:
124
+ command = 'echo "example: gh pr comment 42 --body \\"see /tmp/x\\"" >> notes.txt'
125
+ assert not _body_names_volatile_path("Bash", {"command": command})
126
+
127
+
128
+ def test_gh_word_not_first_token_does_not_classify() -> None:
129
+ command = r'echo gh pr comment 42 --body "see C:\.claude-editor\jobs\x"'
130
+ assert not _body_names_volatile_path("Bash", {"command": command})
131
+
132
+
133
+ def test_env_assignment_prefix_still_classifies() -> None:
134
+ command = 'GH_TOKEN=abc gh pr comment 669 --body "log at /tmp/run.log"'
135
+ assert _body_names_volatile_path("Bash", {"command": command})
136
+
137
+
138
+ def test_unparseable_command_is_allowed() -> None:
139
+ assert not _body_names_volatile_path(
140
+ "Bash", {"command": "gh pr comment 1 --body 'unterminated"}
141
+ )
142
+
143
+
144
+ def test_mcp_issue_comment_body_blocked() -> None:
145
+ tool_input: dict[str, object] = {"body": "artifact at $CLAUDE_JOB_DIR/tmp/out.png"}
146
+ assert _body_names_volatile_path("mcp__plugin_github_github__add_issue_comment", tool_input)
147
+
148
+
149
+ def test_mcp_review_comment_param_blocked() -> None:
150
+ tool_input: dict[str, object] = {"comment": r"see .claude\worktrees\x\file"}
151
+ assert _body_names_volatile_path(
152
+ "mcp__plugin_github_github__add_reply_to_pull_request_comment", tool_input
153
+ )
154
+
155
+
156
+ def test_mcp_clean_body_allowed() -> None:
157
+ tool_input: dict[str, object] = {"body": "LGTM, results pasted inline."}
158
+ assert not _body_names_volatile_path("mcp__plugin_github_github__add_issue_comment", tool_input)
159
+
160
+
161
+ def test_mcp_read_tool_without_body_allowed() -> None:
162
+ tool_input: dict[str, object] = {"pullNumber": 10}
163
+ assert not _body_names_volatile_path("mcp__plugin_github_github__pull_request_read", tool_input)
164
+
165
+
166
+ def test_extract_mcp_body_texts_skips_non_string_values() -> None:
167
+ tool_input: dict[str, object] = {"body": None, "comment": 42}
168
+ assert extract_mcp_body_texts(tool_input) == []
169
+
170
+
171
+ def test_extract_gh_post_body_texts_returns_inline_and_file(tmp_path: pathlib.Path) -> None:
172
+ body_file = tmp_path / "b.md"
173
+ body_file.write_text("file body text", encoding="utf-8")
174
+ command = f'gh pr create --title "T" --body "inline body" --body-file {body_file}'
175
+ all_texts = extract_gh_post_body_texts(command)
176
+ assert "inline body" in all_texts
177
+ assert "file body text" in all_texts
178
+
179
+
180
+ def test_hook_subprocess_denies_volatile_gh_comment() -> None:
181
+ payload = {
182
+ "tool_name": "Bash",
183
+ "tool_input": {
184
+ "command": 'gh pr comment 669 --body "art at $CLAUDE_JOB_DIR/tmp/x.png"',
185
+ },
186
+ }
187
+ completion = subprocess.run(
188
+ [sys.executable, str(_HOOK_DIR / "volatile_path_in_post_blocker.py")],
189
+ input=json.dumps(payload),
190
+ capture_output=True,
191
+ text=True,
192
+ check=False,
193
+ )
194
+ decision = json.loads(completion.stdout)
195
+ assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
196
+
197
+
198
+ def test_hook_subprocess_allows_clean_gh_comment() -> None:
199
+ payload = {
200
+ "tool_name": "Bash",
201
+ "tool_input": {"command": 'gh pr comment 669 --body "all green, results inline"'},
202
+ }
203
+ completion = subprocess.run(
204
+ [sys.executable, str(_HOOK_DIR / "volatile_path_in_post_blocker.py")],
205
+ input=json.dumps(payload),
206
+ capture_output=True,
207
+ text=True,
208
+ check=False,
209
+ )
210
+ assert completion.stdout.strip() == ""
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block durable GitHub posts that reference volatile paths.
3
+
4
+ Root cause: a background job posted a PR comment citing an artifact under a job
5
+ scratch directory. Job tmp dirs, worktrees, and system temp locations are
6
+ ephemeral — they are cleaned soon after the run — while a posted comment, PR
7
+ body, issue, or review lives forever. A durable post that points at ephemeral
8
+ scratch breaks the moment that scratch is removed.
9
+
10
+ Detection strategy: gather the post body text from the tool call, then scan it
11
+ for volatile-path markers. Two tool families carry post bodies: shell ``gh``
12
+ post subcommands (``pr create/comment/edit/review``, ``issue
13
+ create/comment/edit``) and the GitHub MCP post tools (any
14
+ ``mcp__plugin_github_github__*`` tool that carries a ``body`` or ``comment``
15
+ parameter). For a ``gh`` command the body comes from ``--body``/``-b`` inline
16
+ strings and from the file named by ``--body-file``/``-F`` (its contents are read
17
+ and scanned, since that content is what gets embedded in the post). The ``gh``
18
+ command is tokenized with ``shlex.split`` and the ``gh`` word must be the first
19
+ command token, so a ``gh pr comment`` that only appears as quoted data inside
20
+ another argument never classifies.
21
+ """
22
+
23
+ import json
24
+ import shlex
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
29
+ if _hooks_dir not in sys.path:
30
+ sys.path.insert(0, _hooks_dir)
31
+
32
+ from blocking._gh_body_arg_utils import ( # noqa: E402
33
+ all_body_flags,
34
+ body_file_flag,
35
+ body_file_short_flag,
36
+ count_extra_tokens_to_skip_for_split_quoted_value,
37
+ get_logical_first_line,
38
+ is_unresolvable_shell_value,
39
+ match_body_file_equals_prefix,
40
+ match_body_flag_equals_prefix,
41
+ strip_surrounding_quotes,
42
+ )
43
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
44
+ from hooks_constants.volatile_path_in_post_blocker_constants import ( # noqa: E402
45
+ ALL_GH_POST_SUBCOMMANDS,
46
+ ALL_MCP_BODY_PARAM_NAMES,
47
+ ALL_VOLATILE_PATH_MARKERS,
48
+ BASH_TOOL_NAME,
49
+ BODY_FILE_ENCODING,
50
+ CORRECTIVE_MESSAGE,
51
+ GH_COMMAND_NAME,
52
+ MCP_GITHUB_TOOL_PREFIX,
53
+ MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT,
54
+ TOKEN_JOIN_SEPARATOR,
55
+ )
56
+
57
+
58
+ def scan_text_for_volatile_marker(text: str) -> str | None:
59
+ """Return the first volatile-path marker found in text, or None.
60
+
61
+ Backslashes normalize to forward slashes and the text lowercases before the
62
+ scan, so a marker matches regardless of slash direction or letter case::
63
+
64
+ ok: "See the log table pasted below." -> None
65
+ flag: r"C:\\Users\\me\\.claude-editor\\jobs\\x" -> ".claude-editor/jobs/"
66
+
67
+ Env-token markers such as ``%TEMP%`` and ``$CLAUDE_JOB_DIR`` match on the
68
+ same lowercased text.
69
+
70
+ Args:
71
+ text: The post body text to scan.
72
+
73
+ Returns:
74
+ The matched marker string, or None when the text names no volatile path.
75
+ """
76
+ normalized_text = text.replace("\\", "/").lower()
77
+ for each_marker in ALL_VOLATILE_PATH_MARKERS:
78
+ if each_marker in normalized_text:
79
+ return each_marker
80
+ return None
81
+
82
+
83
+ def _is_environment_assignment(token: str) -> bool:
84
+ """Return whether a token is a ``NAME=value`` shell environment assignment."""
85
+ equals_index = token.find("=")
86
+ if equals_index <= 0:
87
+ return False
88
+ name_part = token[:equals_index]
89
+ if not (name_part[0].isalpha() or name_part[0] == "_"):
90
+ return False
91
+ return all(
92
+ each_character.isalnum() or each_character == "_"
93
+ for each_character in name_part
94
+ )
95
+
96
+
97
+ def _tokens_name_gh_post_command(all_command_tokens: list[str]) -> bool:
98
+ """Return whether the tokens start with an affected ``gh`` post subcommand.
99
+
100
+ Leading ``NAME=value`` env assignments are skipped, then the next token must
101
+ be the literal ``gh`` command word followed by a post noun and verb. Anchoring
102
+ to the first command word keeps a ``gh pr comment`` mentioned only as quoted
103
+ argument data from classifying.
104
+
105
+ Args:
106
+ all_command_tokens: The shlex-split tokens of the logical command line.
107
+
108
+ Returns:
109
+ True when the command invokes an affected ``gh`` post subcommand.
110
+ """
111
+ command_index = 0
112
+ while command_index < len(all_command_tokens) and _is_environment_assignment(
113
+ all_command_tokens[command_index]
114
+ ):
115
+ command_index += 1
116
+ if command_index >= len(all_command_tokens):
117
+ return False
118
+ if all_command_tokens[command_index] != GH_COMMAND_NAME:
119
+ return False
120
+ remaining_tokens = all_command_tokens[command_index + 1 :]
121
+ if len(remaining_tokens) < MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT:
122
+ return False
123
+ post_noun = remaining_tokens[0]
124
+ post_verb = remaining_tokens[1]
125
+ return post_verb in ALL_GH_POST_SUBCOMMANDS.get(post_noun, frozenset())
126
+
127
+
128
+ def _reassemble_split_quoted_value(
129
+ value_token: str, all_following_tokens: list[str]
130
+ ) -> tuple[str, int]:
131
+ """Rejoin a quoted value that ``shlex.split(posix=False)`` split on spaces.
132
+
133
+ ``posix=False`` keeps backslashes intact (so Windows paths survive) but
134
+ splits a quoted value such as ``"dump in %TEMP%"`` into three tokens. This
135
+ rejoins the value token with the follow-on tokens up to the closing quote,
136
+ then strips the surrounding quotes so the full body text can be scanned.
137
+
138
+ Args:
139
+ value_token: The first token of the value, following the flag.
140
+ all_following_tokens: The tokens after ``value_token`` on the line.
141
+
142
+ Returns:
143
+ A ``(full_value, extra_tokens_consumed)`` pair; ``extra_tokens_consumed``
144
+ counts the follow-on tokens joined into the value.
145
+ """
146
+ extra_tokens = count_extra_tokens_to_skip_for_split_quoted_value(
147
+ all_following_tokens, value_token
148
+ )
149
+ if not extra_tokens:
150
+ return strip_surrounding_quotes(value_token), 0
151
+ joined_value = value_token + TOKEN_JOIN_SEPARATOR + TOKEN_JOIN_SEPARATOR.join(
152
+ all_following_tokens[:extra_tokens]
153
+ )
154
+ return strip_surrounding_quotes(joined_value), extra_tokens
155
+
156
+
157
+ def _extract_flag_value_at(
158
+ all_command_tokens: list[str], token_index: int
159
+ ) -> tuple[bool, str, int] | None:
160
+ """Return a body/body-file value starting at token_index, or None.
161
+
162
+ Args:
163
+ all_command_tokens: The shlex-split tokens of the logical command line.
164
+ token_index: The index of the token to inspect.
165
+
166
+ Returns:
167
+ An ``(is_body_file, value, next_index)`` triple when the token opens a
168
+ body or body-file flag, else None. ``next_index`` is where scanning
169
+ resumes after the value's tokens.
170
+ """
171
+ current_token = all_command_tokens[token_index]
172
+ following_tokens = all_command_tokens[token_index + 1:]
173
+ body_equals_prefix = match_body_flag_equals_prefix(current_token)
174
+ if body_equals_prefix is not None:
175
+ value, extra = _reassemble_split_quoted_value(
176
+ current_token[len(body_equals_prefix):], following_tokens
177
+ )
178
+ return False, value, token_index + 1 + extra
179
+ body_file_equals_prefix = match_body_file_equals_prefix(current_token)
180
+ if body_file_equals_prefix is not None:
181
+ value, extra = _reassemble_split_quoted_value(
182
+ current_token[len(body_file_equals_prefix):], following_tokens
183
+ )
184
+ return True, value, token_index + 1 + extra
185
+ if current_token in all_body_flags and following_tokens:
186
+ value, extra = _reassemble_split_quoted_value(following_tokens[0], following_tokens[1:])
187
+ return False, value, token_index + 2 + extra
188
+ if current_token in (body_file_flag, body_file_short_flag) and following_tokens:
189
+ value, extra = _reassemble_split_quoted_value(following_tokens[0], following_tokens[1:])
190
+ return True, value, token_index + 2 + extra
191
+ return None
192
+
193
+
194
+ def _collect_body_flag_values(
195
+ all_command_tokens: list[str],
196
+ ) -> tuple[list[str], list[str]]:
197
+ """Return inline body strings and body-file paths from the command tokens.
198
+
199
+ Args:
200
+ all_command_tokens: The shlex-split tokens of the logical command line.
201
+
202
+ Returns:
203
+ A ``(all_inline_bodies, all_body_file_paths)`` pair, each value stripped
204
+ of its surrounding quotes and rejoined across split-quote tokens.
205
+ """
206
+ all_inline_bodies: list[str] = []
207
+ all_body_file_paths: list[str] = []
208
+ token_index = 0
209
+ while token_index < len(all_command_tokens):
210
+ extraction = _extract_flag_value_at(all_command_tokens, token_index)
211
+ if extraction is None:
212
+ token_index += 1
213
+ continue
214
+ is_body_file, value, next_index = extraction
215
+ if is_body_file:
216
+ all_body_file_paths.append(value)
217
+ else:
218
+ all_inline_bodies.append(value)
219
+ token_index = next_index
220
+ return all_inline_bodies, all_body_file_paths
221
+
222
+
223
+ def _read_body_file(body_file_path: str) -> str | None:
224
+ """Return the contents of a body-file path, or None when it cannot be read.
225
+
226
+ Args:
227
+ body_file_path: The path given to ``--body-file``/``-F``.
228
+
229
+ Returns:
230
+ The file text, or None for an unresolvable shell value (such as ``-`` for
231
+ stdin or a ``$VAR`` path) or an unreadable file.
232
+ """
233
+ if is_unresolvable_shell_value(body_file_path):
234
+ return None
235
+ try:
236
+ return Path(body_file_path).read_text(encoding=BODY_FILE_ENCODING)
237
+ except OSError:
238
+ return None
239
+
240
+
241
+ def extract_gh_post_body_texts(command: str) -> list[str]:
242
+ """Return every post body text an affected ``gh`` command would send.
243
+
244
+ Non-post ``gh`` commands and unparseable command lines yield an empty list.
245
+ Body-file contents are read from disk so the embedded text is scanned rather
246
+ than the file path.
247
+
248
+ Args:
249
+ command: The raw Bash tool command string.
250
+
251
+ Returns:
252
+ Inline ``--body`` strings plus the contents of each readable body-file.
253
+ """
254
+ logical_line = get_logical_first_line(command)
255
+ if not logical_line:
256
+ return []
257
+ try:
258
+ all_command_tokens = shlex.split(logical_line, posix=False)
259
+ except ValueError:
260
+ return []
261
+ if not _tokens_name_gh_post_command(all_command_tokens):
262
+ return []
263
+ all_inline_bodies, all_body_file_paths = _collect_body_flag_values(
264
+ all_command_tokens
265
+ )
266
+ all_body_texts = list(all_inline_bodies)
267
+ for each_path in all_body_file_paths:
268
+ file_text = _read_body_file(each_path)
269
+ if file_text is not None:
270
+ all_body_texts.append(file_text)
271
+ return all_body_texts
272
+
273
+
274
+ def extract_mcp_body_texts(all_tool_input: dict[str, object]) -> list[str]:
275
+ """Return the body and comment strings from a GitHub MCP post tool input.
276
+
277
+ Args:
278
+ all_tool_input: The MCP tool's input mapping.
279
+
280
+ Returns:
281
+ The non-empty string values of the ``body`` and ``comment`` parameters.
282
+ """
283
+ all_body_texts: list[str] = []
284
+ for each_param_name in ALL_MCP_BODY_PARAM_NAMES:
285
+ param_value = all_tool_input.get(each_param_name)
286
+ if isinstance(param_value, str) and param_value:
287
+ all_body_texts.append(param_value)
288
+ return all_body_texts
289
+
290
+
291
+ def _collect_body_texts(tool_name: str, all_tool_input: dict[str, object]) -> list[str]:
292
+ if tool_name == BASH_TOOL_NAME:
293
+ command = all_tool_input.get("command", "")
294
+ if not isinstance(command, str) or not command:
295
+ return []
296
+ return extract_gh_post_body_texts(command)
297
+ if tool_name.startswith(MCP_GITHUB_TOOL_PREFIX):
298
+ return extract_mcp_body_texts(all_tool_input)
299
+ return []
300
+
301
+
302
+ def _first_volatile_marker(all_body_texts: list[str]) -> str | None:
303
+ for each_text in all_body_texts:
304
+ matched_marker = scan_text_for_volatile_marker(each_text)
305
+ if matched_marker is not None:
306
+ return matched_marker
307
+ return None
308
+
309
+
310
+ def _emit_block(tool_name: str, matched_marker: str) -> None:
311
+ deny_payload = {
312
+ "hookSpecificOutput": {
313
+ "hookEventName": "PreToolUse",
314
+ "permissionDecision": "deny",
315
+ "permissionDecisionReason": CORRECTIVE_MESSAGE,
316
+ }
317
+ }
318
+ log_hook_block(
319
+ calling_hook_name="volatile_path_in_post_blocker.py",
320
+ hook_event="PreToolUse",
321
+ block_reason=CORRECTIVE_MESSAGE,
322
+ tool_name=tool_name,
323
+ offending_input_preview=matched_marker,
324
+ )
325
+ print(json.dumps(deny_payload))
326
+ sys.stdout.flush()
327
+
328
+
329
+ def main() -> None:
330
+ """Read the PreToolUse payload and block a post that names a volatile path."""
331
+ try:
332
+ hook_input = json.load(sys.stdin)
333
+ except json.JSONDecodeError:
334
+ sys.exit(0)
335
+
336
+ tool_name = hook_input.get("tool_name", "")
337
+ tool_input = hook_input.get("tool_input", {})
338
+ if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
339
+ sys.exit(0)
340
+
341
+ all_body_texts = _collect_body_texts(tool_name, tool_input)
342
+ matched_marker = _first_volatile_marker(all_body_texts)
343
+ if matched_marker is None:
344
+ sys.exit(0)
345
+
346
+ _emit_block(tool_name, matched_marker)
347
+ sys.exit(0)
348
+
349
+
350
+ if __name__ == "__main__":
351
+ main()
package/hooks/hooks.json CHANGED
@@ -55,6 +55,11 @@
55
55
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_body_arg_blocker.py",
56
56
  "timeout": 10
57
57
  },
58
+ {
59
+ "type": "command",
60
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/volatile_path_in_post_blocker.py",
61
+ "timeout": 10
62
+ },
58
63
  {
59
64
  "type": "command",
60
65
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/conventional_pr_title_gate.py",
@@ -142,6 +147,16 @@
142
147
  }
143
148
  ]
144
149
  },
150
+ {
151
+ "matcher": "mcp__plugin_github_github__.*",
152
+ "hooks": [
153
+ {
154
+ "type": "command",
155
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/volatile_path_in_post_blocker.py",
156
+ "timeout": 10
157
+ }
158
+ ]
159
+ },
145
160
  {
146
161
  "matcher": "Agent",
147
162
  "hooks": [
@@ -186,6 +201,16 @@
186
201
  "timeout": 10
187
202
  }
188
203
  ]
204
+ },
205
+ {
206
+ "matcher": "EnterWorktree",
207
+ "hooks": [
208
+ {
209
+ "type": "command",
210
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/enter_worktree_origin_prefetch.py",
211
+ "timeout": 25
212
+ }
213
+ ]
189
214
  }
190
215
  ],
191
216
  "SessionStart": [
@@ -13,6 +13,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
13
13
  | `bot_mention_comment_blocker_constants.py` | Patterns for detecting bot @-mentions in PR comments |
14
14
  | `claude_md_orphan_file_blocker_constants.py` | Table patterns, file extensions, scan budget, and block-message text for the CLAUDE.md orphan-file blocker |
15
15
  | `code_rules_enforcer_constants.py` | File-extension sets, test-path patterns, advisory line thresholds, boolean-name prefixes |
16
+ | `enter_worktree_prefetch_constants.py` | Tool name, origin-remote ref names, and git-command timeouts for the EnterWorktree origin-prefetch hook |
16
17
  | `env_var_table_code_drift_constants.py` | Table patterns, env-var-name and code-file recognizers, scan budget, and block-message text for the env-var-table code-drift blocker |
17
18
  | `code_rules_path_utils_constants.py` | Path-matching helpers used by the code-rules check modules |
18
19
  | `code_verifier_spawn_preflight_gate_constants.py` | Subagent type, merge-tree command flags, timeouts, and deny-message text for the code-verifier spawn pre-flight gate |
@@ -65,6 +66,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
65
66
  | `task_list_loop_starter_constants.py` | The one-line task-list instruction and the full session-start directive text for the task-list loop starter hook |
66
67
  | `text_stripping.py` | `strip_code_and_quotes()` — shared helper that removes fenced code blocks, inline code, and blockquotes from prose, imported by the Stop-hook prose blockers |
67
68
  | `unused_module_import_constants.py` | Patterns for detecting unused module-level imports |
69
+ | `volatile_path_in_post_blocker_constants.py` | Volatile path markers, affected `gh` post subcommands, MCP body param names, and the corrective message for the volatile-path post blocker |
68
70
  | `windows_rmtree_blocker_constants.py` | The unsafe `shutil.rmtree` pattern and the safe replacement pattern |
69
71
  | `workflow_substitution_slot_blocker_constants.py` | Per-iteration token patterns for the workflow-slot blocker |
70
72
 
@@ -0,0 +1,18 @@
1
+ """Configuration constants for the ``enter_worktree_origin_prefetch`` hook.
2
+
3
+ The hook keeps a freshly created worktree from starting on a stale cached
4
+ ``refs/remotes/origin/<default-branch>`` value: it fetches that ref right
5
+ before ``EnterWorktree`` resolves its base.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ ENTER_WORKTREE_TOOL_NAME: str = "EnterWorktree"
11
+ ENTER_WORKTREE_PATH_INPUT_KEY: str = "path"
12
+
13
+ ORIGIN_REMOTE_NAME: str = "origin"
14
+ ORIGIN_HEAD_SYMBOLIC_REF: str = "refs/remotes/origin/HEAD"
15
+ ORIGIN_REMOTE_REF_PREFIX: str = "refs/remotes/origin/"
16
+
17
+ GIT_SYMBOLIC_REF_TIMEOUT_SECONDS: int = 5
18
+ GIT_FETCH_TIMEOUT_SECONDS: int = 15
@@ -0,0 +1,48 @@
1
+ """Configuration constants for the volatile_path_in_post_blocker PreToolUse hook."""
2
+
3
+ BASH_TOOL_NAME: str = "Bash"
4
+
5
+ MCP_GITHUB_TOOL_PREFIX: str = "mcp__plugin_github_github__"
6
+
7
+ ALL_MCP_BODY_PARAM_NAMES: tuple[str, ...] = ("body", "comment")
8
+
9
+ GH_COMMAND_NAME: str = "gh"
10
+
11
+ MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT: int = 2
12
+
13
+ TOKEN_JOIN_SEPARATOR: str = " "
14
+
15
+ BODY_FILE_ENCODING: str = "utf-8"
16
+
17
+ ALL_GH_POST_SUBCOMMANDS: dict[str, frozenset[str]] = {
18
+ "pr": frozenset({"create", "comment", "edit", "review"}),
19
+ "issue": frozenset({"create", "comment", "edit"}),
20
+ }
21
+
22
+ ALL_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
23
+ ".claude-editor/jobs/",
24
+ ".claude/worktrees/",
25
+ "appdata/local/temp",
26
+ "/tmp/",
27
+ "%temp%",
28
+ "$env:temp",
29
+ "$claude_job_dir",
30
+ )
31
+
32
+ GH_ARTIFACT_UPLOAD_INVOCATION: str = (
33
+ "python3 ~/.claude/scripts/gh_artifact_upload.py <file-path> <owner/repo>"
34
+ )
35
+
36
+ CORRECTIVE_MESSAGE: str = (
37
+ "BLOCKED [durable-post-artifacts]: this post body references a volatile path "
38
+ "(a job scratch dir, worktree, or system temp location). The post is durable "
39
+ "and outlives that scratch, so the reference breaks the moment the directory "
40
+ "is cleaned.\n\n"
41
+ "Fix it before posting:\n"
42
+ " 1. Text data (logs, tables, diffs): paste the actual content inline in the "
43
+ "post instead of linking a scratch file path.\n"
44
+ " 2. Binary artifacts (images, archives): upload the file to the repo's "
45
+ "durable 'artifacts' release and link the permanent asset URL it prints:\n"
46
+ f" {GH_ARTIFACT_UPLOAD_INVOCATION}\n\n"
47
+ "See ~/.claude/rules/durable-post-artifacts.md for the full contract."
48
+ )