claude-dev-env 1.24.0 → 1.25.1

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.
@@ -18,6 +18,8 @@ hook_module = importlib.util.module_from_spec(hook_spec)
18
18
  hook_spec.loader.exec_module(hook_module)
19
19
  _uses_body_string_arg = hook_module._uses_body_string_arg
20
20
 
21
+ from _gh_body_arg_utils import iter_significant_tokens
22
+
21
23
 
22
24
  def test_blocks_issue_create_with_body_string() -> None:
23
25
  assert _uses_body_string_arg('gh issue create --title "T" --body "text"')
@@ -144,5 +146,230 @@ def test_no_false_positive_heredoc_body_text() -> None:
144
146
 
145
147
 
146
148
  def test_no_false_positive_unparseable_command() -> None:
147
- """Unparseable first line (unmatched quote) falls back to approve."""
148
- assert not _uses_body_string_arg("gh pr create --title 'unmatched --body oops")
149
+ """Unparseable line WITHOUT --body token must approve (out of hook scope)."""
150
+ assert not _uses_body_string_arg("gh pr create --title 'unmatched quote here")
151
+
152
+
153
+ def test_blocks_unparseable_command_when_body_token_present() -> None:
154
+ """C2: shlex-unparseable command on affected subcommand WITH --body must BLOCK.
155
+
156
+ A heredoc-style command like `gh pr create --body "$(cat <<EOF...)"` raises
157
+ ValueError in shlex.split(posix=False); silently approving lets the exact
158
+ pattern this hook exists to block slip through. Detect a bare --body literal
159
+ via regex and deny.
160
+ """
161
+ command = 'gh pr create --title "T" --body "$(cat <<EOF\nbody text\nEOF\n)"'
162
+ assert _uses_body_string_arg(command)
163
+
164
+
165
+ def test_blocks_unparseable_command_with_short_b_token_present() -> None:
166
+ """C2 short form: unparseable command (odd single quote) with bare -b must BLOCK."""
167
+ command = "gh pr comment 42 --unterminated 'quote here -b short body text"
168
+ assert _uses_body_string_arg(command)
169
+
170
+
171
+ def test_approves_body_file_dash_stdin_sentinel() -> None:
172
+ """`gh pr create --body-file -` reads body from stdin and is allowed."""
173
+ assert not _uses_body_string_arg("gh pr create --title 'T' --body-file -")
174
+
175
+
176
+ def test_blocks_crlf_line_endings_with_bash_continuation() -> None:
177
+ """CRLF line endings must not break bash continuation joining."""
178
+ command = 'gh pr create \\\r\n --title "T" \\\r\n --body "text"\r\n'
179
+ assert _uses_body_string_arg(command)
180
+
181
+
182
+ def test_blocks_continuation_with_tab_after_marker() -> None:
183
+ """Tab whitespace after a continuation marker still counts as continuation."""
184
+ command = 'gh pr create \\\t\n --title "T" \\\t\n --body "text"\n'
185
+ assert _uses_body_string_arg(command)
186
+
187
+
188
+ def test_blocks_utf8_body_with_emoji() -> None:
189
+ """UTF-8 body content (emoji) must still be detected as a body string."""
190
+ assert _uses_body_string_arg('gh pr create --title "T" --body "ship it 🚀"')
191
+
192
+
193
+ def test_blocks_pr_review_request_changes_short_b() -> None:
194
+ """gh pr review --request-changes -b "text" must block."""
195
+ assert _uses_body_string_arg(
196
+ 'gh pr review 10 --request-changes -b "needs work"'
197
+ )
198
+
199
+
200
+ def test_blocks_empty_body_string() -> None:
201
+ """`gh pr create --body=""` is still a body-string call; must block."""
202
+ assert _uses_body_string_arg('gh pr create --title "T" --body=""')
203
+
204
+
205
+ def test_blocks_body_file_followed_by_body_string() -> None:
206
+ """H5: malformed `gh pr create --body-file --body "text"` must BLOCK.
207
+
208
+ Without the flag-shape guard, --body-file consumes --body as its path and
209
+ the real body string slips through as a positional. Treat a value-taking
210
+ flag as value-missing when the next token is itself flag-shaped.
211
+ """
212
+ assert _uses_body_string_arg(
213
+ 'gh pr create --body-file --body "real body text"'
214
+ )
215
+
216
+
217
+ def test_blocks_windows_path_with_trailing_backslash_continuation() -> None:
218
+ """C1: prior line ending in `C:\\Users\\jon\\` must still continue lines.
219
+
220
+ Naive `count("\\\\") % 2 == 1` mis-classifies this (count=4, even -> no
221
+ continuation) and misses --body on the next line. Counting only the
222
+ trailing run of backslashes correctly identifies a single trailing
223
+ backslash as a continuation marker.
224
+ """
225
+ command = (
226
+ 'gh pr create --title C:\\Users\\jon\\ \\\n'
227
+ ' --body "real body text"\n'
228
+ )
229
+ assert _uses_body_string_arg(command)
230
+
231
+
232
+ def test_does_not_join_lines_after_markdown_fence() -> None:
233
+ """C1: a prior line ending in three backticks (count=3, odd) must NOT continue.
234
+
235
+ Old logic treated any odd backtick count as a PowerShell continuation; a
236
+ closing markdown fence trailing the line would falsely join the next line.
237
+ The fix requires whitespace before a trailing backtick (PowerShell
238
+ continuation marker is `<space>` + backtick + newline) so a bare ``` line
239
+ end does not continue.
240
+ """
241
+ command = (
242
+ "echo ```\n"
243
+ 'gh pr create --title "T" --body "real body text"\n'
244
+ )
245
+ assert _uses_body_string_arg(command) is False
246
+
247
+
248
+ def test_does_not_false_positive_equals_form_in_title_value() -> None:
249
+ """`--title="use --body x"` posix=False keeps the value quoted; must approve."""
250
+ assert not _uses_body_string_arg(
251
+ 'gh pr create --title="use --body x" --body-file /tmp/b.md'
252
+ )
253
+
254
+
255
+ def test_blocks_short_body_file_F_then_body_string_later() -> None:
256
+ """`-F /path` (short for --body-file) consumes its value, then --body must still block."""
257
+ assert _uses_body_string_arg(
258
+ 'gh pr create -F /tmp/file.md --body "extra body"'
259
+ )
260
+
261
+
262
+ def test_approves_short_body_file_F_alone() -> None:
263
+ """`-F /path` alone must be approved (it is the short form of --body-file)."""
264
+ assert not _uses_body_string_arg(
265
+ 'gh pr create --title "T" -F /tmp/file.md'
266
+ )
267
+
268
+
269
+ def test_approves_template_flag_value_does_not_trigger() -> None:
270
+ """`--template path` consumes its value; `--body` inside path must not trigger."""
271
+ assert not _uses_body_string_arg(
272
+ 'gh pr create --title "T" --template /tmp/--body-template.md --body-file /tmp/b.md'
273
+ )
274
+
275
+
276
+ def test_approves_recover_flag_value() -> None:
277
+ """`--recover path` is value-taking and must not leak `--body` mis-detection."""
278
+ assert not _uses_body_string_arg(
279
+ 'gh pr create --recover /tmp/state.json --body-file /tmp/b.md'
280
+ )
281
+
282
+
283
+ def test_approves_body_file_only_shlex_unparseable() -> None:
284
+ """loop1-1: shlex-unparseable command with only --body-file must NOT block.
285
+
286
+ The old \b boundary in _BARE_BODY_TOKEN_PATTERN fired between 'y' and '-'
287
+ in '--body-file', causing any unparseable command containing only --body-file
288
+ (no bare --body) to be incorrectly blocked.
289
+ """
290
+ assert not _uses_body_string_arg(
291
+ "gh pr create --title 'unmatched --body-file /tmp/body.md"
292
+ )
293
+
294
+
295
+ def test_blocks_body_equals_spaced_value() -> None:
296
+ """loop1-3: --body='has space' split by shlex(posix=False) into two tokens.
297
+
298
+ shlex.split(posix=False) splits `--body='has space'` into ["--body='has", "space'"].
299
+ The continuation token "space'" must be skipped, not yielded as a significant
300
+ positional — the leading --body='has token must still trigger a block.
301
+ """
302
+ assert _uses_body_string_arg(
303
+ "gh pr create --title 'T' --body='has space'"
304
+ )
305
+
306
+
307
+ def test_blocks_short_b_equals_spaced_value() -> None:
308
+ """loop1-3: -b='has space' split by shlex(posix=False) — continuation token skipped."""
309
+ assert _uses_body_string_arg(
310
+ "gh pr create --title 'T' -b='has space'"
311
+ )
312
+
313
+
314
+ def test_blocks_body_after_unclosed_quoted_title() -> None:
315
+ """loop2-1: unclosed quote in --title= must not consume --body token.
316
+
317
+ shlex.split(posix=False) keeps --title="unclosed as one token whose value
318
+ starts with a quote but has no closing match among subsequent tokens.
319
+ count_extra_tokens_to_skip_for_split_quoted_value must return 0 (not the
320
+ full remaining list length) so the following --body flag stays visible.
321
+ """
322
+ assert _uses_body_string_arg(
323
+ 'gh pr create --title="unclosed --body "real body"'
324
+ )
325
+
326
+
327
+ def test_space_form_value_flag_remaining_excludes_consumed_value() -> None:
328
+ """loop2-2: remaining_tokens for space-form value flag must not include the consumed value.
329
+
330
+ When iter_significant_tokens yields (--title, remaining) for --title MyTitle,
331
+ remaining must contain only tokens after MyTitle — not MyTitle itself.
332
+ """
333
+ all_yielded = list(iter_significant_tokens('gh pr create --title MyTitle --body "desc"'))
334
+ title_remaining = next(remaining for token, remaining in all_yielded if token == "--title")
335
+ assert "MyTitle" not in title_remaining
336
+
337
+
338
+ def test_quoted_value_starts_split_unclosed_single_quote() -> None:
339
+ from _gh_body_arg_utils import _quoted_value_starts_split
340
+ assert _quoted_value_starts_split("'it") is True
341
+
342
+
343
+ def test_quoted_value_starts_split_fully_closed() -> None:
344
+ from _gh_body_arg_utils import _quoted_value_starts_split
345
+ assert _quoted_value_starts_split("'hello'") is False
346
+
347
+
348
+ def test_quoted_value_starts_split_double_quote_unclosed() -> None:
349
+ from _gh_body_arg_utils import _quoted_value_starts_split
350
+ assert _quoted_value_starts_split('"hello') is True
351
+
352
+
353
+ def test_count_extra_tokens_returns_none_when_exhausted() -> None:
354
+ from _gh_body_arg_utils import count_extra_tokens_to_skip_for_split_quoted_value
355
+ result = count_extra_tokens_to_skip_for_split_quoted_value([], "'unclosed")
356
+ assert result is None
357
+
358
+
359
+ def test_count_extra_tokens_returns_none_no_closing_in_remaining() -> None:
360
+ from _gh_body_arg_utils import count_extra_tokens_to_skip_for_split_quoted_value
361
+ result = count_extra_tokens_to_skip_for_split_quoted_value(["word", "another"], "'unclosed")
362
+ assert result is None
363
+
364
+
365
+ def test_count_extra_tokens_returns_zero_for_self_contained() -> None:
366
+ from _gh_body_arg_utils import count_extra_tokens_to_skip_for_split_quoted_value
367
+ result = count_extra_tokens_to_skip_for_split_quoted_value(["next"], "'complete'")
368
+ assert result == 0
369
+
370
+
371
+ def test_all_body_flag_prefixes_used_for_equals_skip() -> None:
372
+ from _gh_body_arg_utils import _all_equals_prefixes_for_skip, all_body_flag_prefixes
373
+ for each_prefix in all_body_flag_prefixes:
374
+ assert each_prefix in _all_equals_prefixes_for_skip
375
+
@@ -65,10 +65,10 @@ def test_extract_body_file_missing_path_returns_none() -> None:
65
65
  assert extract_body_from_command(command) is None
66
66
 
67
67
 
68
- def test_extract_body_file_shell_variable_returns_empty() -> None:
69
- """Shell variables like $bodyPath can't be resolved at hook time -- approve safely."""
68
+ def test_extract_body_file_shell_variable_returns_none() -> None:
69
+ """Shell variables like $bodyPath can't be resolved at hook time -- return None to skip enforcement."""
70
70
  command = 'gh pr create --title "T" --body-file $bodyPath'
71
- assert extract_body_from_command(command) == ""
71
+ assert extract_body_from_command(command) is None
72
72
 
73
73
 
74
74
  def test_extract_body_file_no_false_positive_in_title() -> None:
@@ -185,3 +185,193 @@ def test_main_does_not_block_when_no_body_flag_present() -> None:
185
185
  except SystemExit:
186
186
  pass
187
187
  assert "deny" not in captured_stdout.getvalue()
188
+
189
+
190
+ def test_extract_body_from_body_file_short_F_form(tmp_path: pathlib.Path) -> None:
191
+ """`gh pr create -F PATH` (short form of --body-file) must read the file."""
192
+ body_file = tmp_path / "body.md"
193
+ body_file.write_text(VALID_BODY)
194
+ command = f'gh pr create --title "T" -F {body_file}'
195
+ assert extract_body_from_command(command) == VALID_BODY
196
+
197
+
198
+ def test_extract_body_ignores_body_inside_title_quoted_value() -> None:
199
+ """Migration to shared iterator: `--title "contains --body here"` must not false-match."""
200
+ command = 'gh pr create --title "contains --body here" --body-file /tmp/real.md'
201
+ extracted_body = extract_body_from_command(command)
202
+ assert extracted_body is None or extracted_body == ""
203
+
204
+
205
+ def test_extract_body_reassembles_split_quoted_equals_value() -> None:
206
+ """`--body="has multiple spaces inside"` must reassemble across posix=False tokens."""
207
+ command = 'gh pr create --title "T" --body="this body has multiple words"'
208
+ assert extract_body_from_command(command) == "this body has multiple words"
209
+
210
+
211
+ def test_read_body_file_rejects_relative_path_traversal(tmp_path) -> None:
212
+ import importlib.util, pathlib, sys
213
+ _HOOK_DIR = pathlib.Path(__file__).parent
214
+ if str(_HOOK_DIR) not in sys.path:
215
+ sys.path.insert(0, str(_HOOK_DIR))
216
+ spec = importlib.util.spec_from_file_location('pde', _HOOK_DIR / 'pr-description-enforcer.py')
217
+ m = importlib.util.module_from_spec(spec)
218
+ spec.loader.exec_module(m)
219
+ import os, pytest
220
+ sentinel_file = tmp_path / 'secret.txt'
221
+ sentinel_file.write_text('secret')
222
+ rel_path = os.path.relpath(str(sentinel_file))
223
+ if '..' not in rel_path:
224
+ pytest.skip('file is under cwd, not a traversal case')
225
+ with pytest.raises(m.PathTraversalError):
226
+ m._read_body_file_contents(rel_path)
227
+
228
+
229
+ def test_read_body_file_allows_absolute_path_outside_cwd(tmp_path) -> None:
230
+ import importlib.util, pathlib, sys
231
+ _HOOK_DIR = pathlib.Path(__file__).parent
232
+ spec = importlib.util.spec_from_file_location('pde2', _HOOK_DIR / 'pr-description-enforcer.py')
233
+ m = importlib.util.module_from_spec(spec)
234
+ spec.loader.exec_module(m)
235
+ body_file = tmp_path / 'body.md'
236
+ body_file.write_text('hello')
237
+ result = m._read_body_file_contents(str(body_file))
238
+ assert result == 'hello'
239
+
240
+
241
+ def test_reassemble_split_quoted_value_returns_none_for_unclosed_quote() -> None:
242
+ import importlib.util, pathlib, sys
243
+ _HOOK_DIR = pathlib.Path(__file__).parent
244
+ spec = importlib.util.spec_from_file_location('pde3', _HOOK_DIR / 'pr-description-enforcer.py')
245
+ m = importlib.util.module_from_spec(spec)
246
+ spec.loader.exec_module(m)
247
+ result = m._reassemble_split_quoted_value("'unclosed", [])
248
+ assert result is None
249
+
250
+
251
+ def test_extract_body_returns_none_for_unclosed_quote_value() -> None:
252
+ result = extract_body_from_command("gh pr create --title T --body='unclosed")
253
+ assert result is None
254
+
255
+
256
+
257
+ def test_body_file_stdin_sentinel_returns_none() -> None:
258
+ """--body-file - (stdin sentinel) must return None so enforcer skips validation."""
259
+ command = 'gh pr create --title "T" --body-file -'
260
+ assert extract_body_from_command(command) is None
261
+
262
+
263
+ def test_body_file_shell_variable_returns_none() -> None:
264
+ """--body-file $VAR cannot be audited at hook time -- must return None, not empty string."""
265
+ command = 'gh pr create --title "T" --body-file $BODY_VAR'
266
+ assert extract_body_from_command(command) is None
267
+
268
+
269
+ def test_body_file_path_traversal_returns_none() -> None:
270
+ """Path traversal rejection must return None so enforcer does not raise false positive."""
271
+ import os
272
+ import importlib.util
273
+ import pathlib
274
+ _HOOK_DIR = pathlib.Path(__file__).parent
275
+ spec = importlib.util.spec_from_file_location('pde_t', _HOOK_DIR / 'pr-description-enforcer.py')
276
+ m = importlib.util.module_from_spec(spec)
277
+ spec.loader.exec_module(m)
278
+ result = m._resolve_body_file_value("../../../etc/passwd")
279
+ assert result is None
280
+
281
+
282
+ def test_main_allows_through_stdin_sentinel_body_file() -> None:
283
+ """--body-file - must not be blocked (stdin body is unauditable)."""
284
+ import io
285
+ import json
286
+ from unittest.mock import patch
287
+ hook_input = {
288
+ "tool_name": "Bash",
289
+ "tool_input": {"command": 'gh pr create --title "T" --body-file -'},
290
+ }
291
+ captured_stdout = io.StringIO()
292
+ with patch("sys.stdin", io.StringIO(json.dumps(hook_input))):
293
+ with patch("sys.stdout", captured_stdout):
294
+ try:
295
+ hook_module.main()
296
+ except SystemExit:
297
+ pass
298
+ assert "deny" not in captured_stdout.getvalue()
299
+
300
+
301
+ def test_read_body_file_rejects_absolute_symlink_outside_cwd(tmp_path: pathlib.Path) -> None:
302
+ """Absolute symlink pointing outside cwd must raise PathTraversalError."""
303
+ import importlib.util
304
+ import pytest
305
+ _HOOK_DIR = pathlib.Path(__file__).parent
306
+ spec = importlib.util.spec_from_file_location('pde_sym', _HOOK_DIR / 'pr-description-enforcer.py')
307
+ m = importlib.util.module_from_spec(spec)
308
+ spec.loader.exec_module(m)
309
+ target_file = tmp_path / "secret.txt"
310
+ target_file.write_text("secret content")
311
+ link_path = tmp_path / "evil_link"
312
+ try:
313
+ link_path.symlink_to(target_file)
314
+ except (OSError, NotImplementedError):
315
+ pytest.skip("symlinks not supported on this platform")
316
+ with pytest.raises(m.PathTraversalError):
317
+ m._read_body_file_contents(str(link_path))
318
+
319
+
320
+ def test_read_body_file_allows_real_absolute_file_inside_cwd(tmp_path: pathlib.Path) -> None:
321
+ """Real absolute file path that exists must be read successfully."""
322
+ import importlib.util
323
+ _HOOK_DIR = pathlib.Path(__file__).parent
324
+ spec = importlib.util.spec_from_file_location('pde_abs', _HOOK_DIR / 'pr-description-enforcer.py')
325
+ m = importlib.util.module_from_spec(spec)
326
+ spec.loader.exec_module(m)
327
+ body_file = tmp_path / "body.md"
328
+ body_file.write_text("hello body")
329
+ result = m._read_body_file_contents(str(body_file))
330
+ assert result == "hello body"
331
+
332
+
333
+ def test_read_body_file_allows_in_cwd_symlink_pointing_into_cwd(tmp_path: pathlib.Path) -> None:
334
+ """Symlink inside cwd pointing to another file inside cwd must be readable."""
335
+ import importlib.util
336
+ _HOOK_DIR = pathlib.Path(__file__).parent
337
+ spec = importlib.util.spec_from_file_location('pde_inlink', _HOOK_DIR / 'pr-description-enforcer.py')
338
+ m = importlib.util.module_from_spec(spec)
339
+ spec.loader.exec_module(m)
340
+ real_file = tmp_path / "real.md"
341
+ real_file.write_text("real content")
342
+ link_file = tmp_path / "link.md"
343
+ try:
344
+ link_file.symlink_to(real_file)
345
+ except (OSError, NotImplementedError):
346
+ import pytest
347
+ pytest.skip("symlinks not supported on this platform")
348
+ with patch("pathlib.Path.cwd", return_value=tmp_path):
349
+ result = m._read_body_file_contents(str(link_file))
350
+ assert result == "real content"
351
+
352
+
353
+ def test_iter_significant_tokens_unclosed_quote_raises_value_error() -> None:
354
+ """Unclosed quoted value in a value-taking flag raises ValueError so callers block conservatively.
355
+
356
+ For equals-form: --title="unclosed raises ValueError (unclosed quote not in remaining tokens).
357
+ For space-form: shlex.split itself raises ValueError before iter_significant_tokens is entered.
358
+ Both paths result in ValueError propagating to callers.
359
+ """
360
+ import pytest
361
+ from _gh_body_arg_utils import iter_significant_tokens
362
+ with pytest.raises(ValueError):
363
+ list(iter_significant_tokens('gh pr create --title="unclosed --body real_body'))
364
+
365
+
366
+ def test_scan_raw_tokens_does_not_false_match_body_in_title_value(tmp_path: pathlib.Path) -> None:
367
+ """--title 'using --body-file is required' must not match --body-file inside the title value."""
368
+ body_file = tmp_path / "real_body.md"
369
+ body_file.write_text(VALID_BODY)
370
+ command = f'gh pr create --title "using --body-file is required" --body-file {body_file}'
371
+ result = extract_body_from_command(command)
372
+ assert result == VALID_BODY
373
+
374
+
375
+ def test_extract_body_returns_none_for_unclosed_quote_value() -> None:
376
+ result = extract_body_from_command("gh pr create --title T --body='unclosed")
377
+ assert result is None