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.
- package/CLAUDE.md +5 -18
- package/docs/CODE_RULES.md +14 -1
- package/hooks/blocking/_gh_body_arg_utils.py +171 -13
- package/hooks/blocking/code-rules-enforcer.py +490 -15
- package/hooks/blocking/gh-body-arg-blocker.py +27 -21
- package/hooks/blocking/pr-description-enforcer.py +247 -11
- package/hooks/blocking/tdd-enforcer.py +208 -13
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +116 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +231 -0
- package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +51 -0
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +144 -0
- package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +102 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +112 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +229 -2
- package/hooks/blocking/test_pr_description_enforcer.py +193 -3
- package/hooks/blocking/test_tdd_enforcer.py +249 -0
- package/hooks/validators/exempt_paths.py +99 -0
- package/hooks/validators/magic_value_checks.py +126 -26
- package/hooks/validators/test_magic_value_checks.py +356 -2
- package/package.json +1 -1
- package/rules/gh-body-file.md +11 -2
- package/skills/searching-obsidian-vault/SKILL.md +131 -0
|
@@ -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
|
|
148
|
-
assert not _uses_body_string_arg("gh pr create --title 'unmatched
|
|
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
|
|
69
|
-
"""Shell variables like $bodyPath can't be resolved at hook time --
|
|
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
|