claude-dev-env 1.21.1 → 1.22.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/hooks/blocking/_gh_body_arg_utils.py +48 -0
- package/hooks/blocking/gh-body-arg-blocker.py +124 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +148 -0
- package/hooks/blocking/test_pr_description_enforcer.py +187 -0
- package/hooks/lifecycle/test_config_change_guard.py +111 -0
- package/package.json +1 -1
- package/rules/gh-body-file.md +70 -0
- package/skills/bugteam/SKILL.md +530 -0
- package/skills/findbugs/SKILL.md +194 -0
- package/skills/fixbugs/SKILL.md +142 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Shared gh body-arg parsing utilities for blocking hooks."""
|
|
2
|
+
|
|
3
|
+
body_file_flag: str = "--body-file"
|
|
4
|
+
body_file_flag_prefix: str = "--body-file="
|
|
5
|
+
|
|
6
|
+
all_body_flags: frozenset[str] = frozenset({"--body", "-b"})
|
|
7
|
+
all_body_flag_prefixes: tuple[str, ...] = ("--body=", "-b=")
|
|
8
|
+
all_value_flags: frozenset[str] = frozenset(
|
|
9
|
+
{
|
|
10
|
+
"--title",
|
|
11
|
+
"-t",
|
|
12
|
+
"--reviewer",
|
|
13
|
+
"-r",
|
|
14
|
+
"--assignee",
|
|
15
|
+
"-a",
|
|
16
|
+
"--label",
|
|
17
|
+
"-l",
|
|
18
|
+
"--milestone",
|
|
19
|
+
"-m",
|
|
20
|
+
"--project",
|
|
21
|
+
"-p",
|
|
22
|
+
"--base",
|
|
23
|
+
"-B",
|
|
24
|
+
"--head",
|
|
25
|
+
"-H",
|
|
26
|
+
"--repo",
|
|
27
|
+
"-R",
|
|
28
|
+
body_file_flag,
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_logical_first_line(command: str) -> str:
|
|
34
|
+
logical = ""
|
|
35
|
+
for each_line in command.splitlines():
|
|
36
|
+
stripped_line = each_line.rstrip()
|
|
37
|
+
is_backslash_continuation = (
|
|
38
|
+
stripped_line.endswith("\\") and stripped_line.count("\\") % 2 == 1
|
|
39
|
+
)
|
|
40
|
+
is_powershell_backtick_continuation = (
|
|
41
|
+
stripped_line.endswith("`") and stripped_line.count("`") % 2 == 1
|
|
42
|
+
)
|
|
43
|
+
if is_backslash_continuation or is_powershell_backtick_continuation:
|
|
44
|
+
logical += stripped_line[:-1].rstrip() + " "
|
|
45
|
+
else:
|
|
46
|
+
logical += each_line
|
|
47
|
+
break
|
|
48
|
+
return logical.strip()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block gh commands that use --body <string> instead of --body-file.
|
|
3
|
+
|
|
4
|
+
Root cause: in shell-invoked gh command contexts, passing markdown body text via
|
|
5
|
+
--body "..." can cause backticks to be stored as literal backslash-backtick sequences
|
|
6
|
+
on GitHub instead of rendering as inline code or code fences. Quoting and escaping
|
|
7
|
+
rules vary by execution environment (Bash, PowerShell, CMD) but the failure mode is
|
|
8
|
+
the same. The fix is always to write the body to a temp file and pass --body-file.
|
|
9
|
+
|
|
10
|
+
Affected subcommands: gh issue create/edit/comment, gh pr create/edit/comment/review.
|
|
11
|
+
|
|
12
|
+
Detection strategy: join bash \\ and PowerShell ` line continuations into a single
|
|
13
|
+
logical line, then use shlex.split(..., posix=False) on that line so '--body'
|
|
14
|
+
appearing inside a quoted flag value or in heredoc body content on non-continuation
|
|
15
|
+
lines does not trigger a false positive. Both '--body value' and '--body=value' forms are blocked,
|
|
16
|
+
as are their short '-b' equivalents. '--body-file' and '--body-file=...' are allowed.
|
|
17
|
+
Falls back to a conservative approve if the logical line is unparseable.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import shlex
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
from _gh_body_arg_utils import (
|
|
26
|
+
all_body_flags,
|
|
27
|
+
all_body_flag_prefixes,
|
|
28
|
+
all_value_flags,
|
|
29
|
+
get_logical_first_line,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_GH_BODY_SUBCOMMANDS = re.compile(
|
|
33
|
+
r"\bgh\s+(?:"
|
|
34
|
+
r"issue\s+(?:create|edit|comment)|"
|
|
35
|
+
r"pr\s+(?:create|edit|comment|review)"
|
|
36
|
+
r")\b",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_BASH_TOOL_NAME = "Bash"
|
|
41
|
+
|
|
42
|
+
_CORRECTIVE_MESSAGE = (
|
|
43
|
+
"BLOCKED [gh-body-file]: gh --body <string> escapes backticks as \\` on GitHub, "
|
|
44
|
+
"corrupting inline code and code fences in issues, PRs, comments, and reviews. "
|
|
45
|
+
"Write the body to a temp file and use --body-file instead.\n\n"
|
|
46
|
+
"Safe Python pattern:\n"
|
|
47
|
+
" import tempfile\n"
|
|
48
|
+
" with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:\n"
|
|
49
|
+
" f.write(body_text)\n"
|
|
50
|
+
" body_path = f.name\n"
|
|
51
|
+
" # then: gh ... --body-file body_path\n\n"
|
|
52
|
+
"Safe PowerShell pattern:\n"
|
|
53
|
+
" $bodyPath = [System.IO.Path]::ChangeExtension((New-TemporaryFile).FullName, '.md')\n"
|
|
54
|
+
" @'\n"
|
|
55
|
+
" <your markdown body>\n"
|
|
56
|
+
" '@ | Set-Content -Path $bodyPath -Encoding utf8\n"
|
|
57
|
+
" gh ... --body-file $bodyPath\n\n"
|
|
58
|
+
"See ~/.claude/rules/gh-body-file.md for full guidance."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _uses_body_string_arg(command: str) -> bool:
|
|
63
|
+
"""Return True if command calls an affected gh subcommand with --body <string>.
|
|
64
|
+
|
|
65
|
+
Joins bash \\ and PowerShell ` line continuations before scanning so that
|
|
66
|
+
'--body' on a continuation line is not missed. Uses shlex.split(posix=False)
|
|
67
|
+
for Windows-friendly tokenization: backslashes in unquoted paths are preserved
|
|
68
|
+
and quoted values retain their surrounding quotes as part of the token, so
|
|
69
|
+
'--body' embedded in a quoted value cannot be mistaken for a standalone flag.
|
|
70
|
+
Detects both '--body value'/'--body=value' forms and their short '-b'
|
|
71
|
+
equivalents. Falls back to a conservative approve if the line is unparseable.
|
|
72
|
+
"""
|
|
73
|
+
logical_line = get_logical_first_line(command)
|
|
74
|
+
if not _GH_BODY_SUBCOMMANDS.search(logical_line):
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
tokens = shlex.split(logical_line, posix=False)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return False
|
|
80
|
+
should_skip_next_token = False
|
|
81
|
+
for each_token in tokens:
|
|
82
|
+
if should_skip_next_token:
|
|
83
|
+
should_skip_next_token = False
|
|
84
|
+
continue
|
|
85
|
+
if each_token in all_body_flags or any(
|
|
86
|
+
each_token.startswith(each_prefix) for each_prefix in all_body_flag_prefixes
|
|
87
|
+
):
|
|
88
|
+
return True
|
|
89
|
+
if each_token in all_value_flags:
|
|
90
|
+
should_skip_next_token = True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main() -> None:
|
|
95
|
+
try:
|
|
96
|
+
hook_input = json.load(sys.stdin)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
sys.exit(0)
|
|
99
|
+
|
|
100
|
+
tool_name = hook_input.get("tool_name", "")
|
|
101
|
+
if tool_name != _BASH_TOOL_NAME:
|
|
102
|
+
sys.exit(0)
|
|
103
|
+
|
|
104
|
+
command = hook_input.get("tool_input", {}).get("command", "")
|
|
105
|
+
if not command:
|
|
106
|
+
sys.exit(0)
|
|
107
|
+
|
|
108
|
+
if not _uses_body_string_arg(command):
|
|
109
|
+
sys.exit(0)
|
|
110
|
+
|
|
111
|
+
deny_payload = {
|
|
112
|
+
"hookSpecificOutput": {
|
|
113
|
+
"hookEventName": "PreToolUse",
|
|
114
|
+
"permissionDecision": "deny",
|
|
115
|
+
"permissionDecisionReason": _CORRECTIVE_MESSAGE,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
print(json.dumps(deny_payload))
|
|
119
|
+
sys.stdout.flush()
|
|
120
|
+
sys.exit(0)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Unit tests for gh-body-arg-blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
8
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
10
|
+
|
|
11
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
12
|
+
"gh_body_arg_blocker",
|
|
13
|
+
_HOOK_DIR / "gh-body-arg-blocker.py",
|
|
14
|
+
)
|
|
15
|
+
assert hook_spec is not None
|
|
16
|
+
assert hook_spec.loader is not None
|
|
17
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
18
|
+
hook_spec.loader.exec_module(hook_module)
|
|
19
|
+
_uses_body_string_arg = hook_module._uses_body_string_arg
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_blocks_issue_create_with_body_string() -> None:
|
|
23
|
+
assert _uses_body_string_arg('gh issue create --title "T" --body "text"')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_blocks_issue_edit_with_body_string() -> None:
|
|
27
|
+
assert _uses_body_string_arg('gh issue edit 42 --body "updated text"')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_blocks_issue_comment_with_body_string() -> None:
|
|
31
|
+
assert _uses_body_string_arg('gh issue comment 42 --body "my comment"')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_blocks_pr_create_with_body_string() -> None:
|
|
35
|
+
assert _uses_body_string_arg('gh pr create --title "T" --body "desc"')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_blocks_pr_edit_with_body_string() -> None:
|
|
39
|
+
assert _uses_body_string_arg('gh pr edit 10 --body "new desc"')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_blocks_pr_comment_with_body_string() -> None:
|
|
43
|
+
assert _uses_body_string_arg('gh pr comment 10 --body "LGTM"')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_blocks_pr_review_with_body_string() -> None:
|
|
47
|
+
assert _uses_body_string_arg('gh pr review 10 --approve --body "looks good"')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_blocks_short_b_flag() -> None:
|
|
51
|
+
assert _uses_body_string_arg('gh pr create --title "T" -b "desc"')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_blocks_pr_create_with_body_equals_syntax() -> None:
|
|
55
|
+
assert _uses_body_string_arg('gh pr create --title "T" --body="text"')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_blocks_pr_create_with_short_b_equals_syntax() -> None:
|
|
59
|
+
assert _uses_body_string_arg('gh pr create --title "T" -b="text"')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_blocks_multiline_bash_continuation_body_on_later_line() -> None:
|
|
63
|
+
command = 'gh pr create \\\n --title "T" \\\n --body "text"\n'
|
|
64
|
+
assert _uses_body_string_arg(command)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_blocks_multiline_continuation_with_trailing_whitespace() -> None:
|
|
68
|
+
"""Continuation marker followed by trailing spaces must still join lines."""
|
|
69
|
+
command = 'gh pr create \\ \n --title "T" \\ \n --body "text"\n'
|
|
70
|
+
assert _uses_body_string_arg(command)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_blocks_multiline_powershell_continuation_body_on_later_line() -> None:
|
|
74
|
+
"""PowerShell backtick continuation lines must be joined before tokenizing."""
|
|
75
|
+
command = 'gh pr create `\n --title "T" `\n --body "text"\n'
|
|
76
|
+
assert _uses_body_string_arg(command)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_blocks_multiline_powershell_continuation_with_trailing_whitespace() -> None:
|
|
80
|
+
"""PowerShell backtick continuation with trailing spaces must still join."""
|
|
81
|
+
command = 'gh pr create ` \n --title "T" ` \n --body "text"\n'
|
|
82
|
+
assert _uses_body_string_arg(command)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_approves_body_file() -> None:
|
|
86
|
+
assert not _uses_body_string_arg(
|
|
87
|
+
'gh pr create --title "T" --body-file /tmp/body.md'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_approves_body_file_equals_syntax() -> None:
|
|
92
|
+
assert not _uses_body_string_arg(
|
|
93
|
+
'gh pr create --title "T" --body-file=/tmp/body.md'
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_approves_issue_create_with_body_file() -> None:
|
|
98
|
+
assert not _uses_body_string_arg(
|
|
99
|
+
'gh issue create --title "T" --body-file /tmp/body.md'
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_approves_unrelated_gh_command() -> None:
|
|
104
|
+
assert not _uses_body_string_arg("gh pr list --repo owner/repo")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_approves_gh_pr_merge() -> None:
|
|
108
|
+
assert not _uses_body_string_arg("gh pr merge 10 --squash")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_approves_empty_command() -> None:
|
|
112
|
+
assert not _uses_body_string_arg("")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_no_false_positive_body_in_title_value() -> None:
|
|
116
|
+
"""--body inside a quoted --title value must not trigger."""
|
|
117
|
+
assert not _uses_body_string_arg(
|
|
118
|
+
'gh pr create --title "block gh --body string arg" --body-file /tmp/b.md'
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_no_false_positive_body_as_title_value() -> None:
|
|
123
|
+
"""--title "--body" must not trigger; posix=False retains quotes so the value is not a bare flag."""
|
|
124
|
+
assert not _uses_body_string_arg(
|
|
125
|
+
'gh pr create --title "--body" --body-file /tmp/b.md'
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_no_false_positive_windows_path_in_body_file() -> None:
|
|
130
|
+
"""Unquoted Windows path with backslashes in --body-file must be approved without token corruption."""
|
|
131
|
+
assert not _uses_body_string_arg(
|
|
132
|
+
r'gh pr create --title "T" --body-file C:\Users\jon\tmp\body.md'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_no_false_positive_heredoc_body_text() -> None:
|
|
137
|
+
"""Multiline command where body content mentions --body must not trigger."""
|
|
138
|
+
command = (
|
|
139
|
+
"gh pr create --title 'My PR' --body-file /tmp/body.md\n"
|
|
140
|
+
"# body content below mentions --body for documentation\n"
|
|
141
|
+
'# Use --body-file not --body "string"\n'
|
|
142
|
+
)
|
|
143
|
+
assert not _uses_body_string_arg(command)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
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")
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Unit tests for pr-description-enforcer PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
11
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
13
|
+
|
|
14
|
+
from _gh_body_arg_utils import get_logical_first_line
|
|
15
|
+
|
|
16
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
17
|
+
"pr_description_enforcer",
|
|
18
|
+
_HOOK_DIR / "pr-description-enforcer.py",
|
|
19
|
+
)
|
|
20
|
+
assert hook_spec is not None
|
|
21
|
+
assert hook_spec.loader is not None
|
|
22
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
23
|
+
hook_spec.loader.exec_module(hook_module)
|
|
24
|
+
extract_body_from_command = hook_module.extract_body_from_command
|
|
25
|
+
validate_pr_body = hook_module.validate_pr_body
|
|
26
|
+
|
|
27
|
+
VALID_BODY = (
|
|
28
|
+
"## Description\n\nThis PR fixes a real bug.\n\n"
|
|
29
|
+
"## Why\n\nBecause it was broken in production.\n\n"
|
|
30
|
+
"## How\n\nRefactored the auth module to handle edge cases correctly.\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_extract_body_from_body_string() -> None:
|
|
35
|
+
command = 'gh pr create --title "T" --body "Description and some text."'
|
|
36
|
+
assert "Description" in extract_body_from_command(command)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_extract_body_from_body_file_space_form(tmp_path: pathlib.Path) -> None:
|
|
40
|
+
body_file = tmp_path / "body.md"
|
|
41
|
+
body_file.write_text(VALID_BODY)
|
|
42
|
+
command = f'gh pr create --title "T" --body-file {body_file}'
|
|
43
|
+
assert extract_body_from_command(command) == VALID_BODY
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_extract_body_from_body_file_equals_form(tmp_path: pathlib.Path) -> None:
|
|
47
|
+
body_file = tmp_path / "body.md"
|
|
48
|
+
body_file.write_text(VALID_BODY)
|
|
49
|
+
command = f'gh pr create --title "T" --body-file="{body_file}"'
|
|
50
|
+
assert extract_body_from_command(command) == VALID_BODY
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_extract_body_from_body_file_equals_form_with_spaces(
|
|
54
|
+
tmp_path: pathlib.Path,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Quoted --body-file=VALUE with spaces in path must be reassembled, not truncated."""
|
|
57
|
+
body_file = tmp_path / "my body with spaces.md"
|
|
58
|
+
body_file.write_text(VALID_BODY)
|
|
59
|
+
command = f'gh pr create --title "T" --body-file="{body_file}"'
|
|
60
|
+
assert extract_body_from_command(command) == VALID_BODY
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_extract_body_file_missing_path_returns_none() -> None:
|
|
64
|
+
command = 'gh pr create --title "T" --body-file /nonexistent/path.md'
|
|
65
|
+
assert extract_body_from_command(command) is None
|
|
66
|
+
|
|
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."""
|
|
70
|
+
command = 'gh pr create --title "T" --body-file $bodyPath'
|
|
71
|
+
assert extract_body_from_command(command) == ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_extract_body_file_no_false_positive_in_title() -> None:
|
|
75
|
+
command = 'gh pr create --title "use --body-file /tmp/x.md" --body "actual body"'
|
|
76
|
+
extracted_body = extract_body_from_command(command)
|
|
77
|
+
assert extracted_body == "actual body"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_no_false_positive_body_in_title_string_value() -> None:
|
|
81
|
+
command = 'gh pr create --title \'use --body "x"\' --body "actual body"'
|
|
82
|
+
assert extract_body_from_command(command) == "actual body"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_extract_body_from_body_equals_double_quote_form() -> None:
|
|
86
|
+
command = 'gh pr create --title "T" --body="Some body text here."'
|
|
87
|
+
assert extract_body_from_command(command) == "Some body text here."
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_extract_body_from_body_equals_single_quote_form() -> None:
|
|
91
|
+
command = "gh pr create --title 'T' --body='Some body text here.'"
|
|
92
|
+
assert extract_body_from_command(command) == "Some body text here."
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_extract_body_equals_shell_var_returns_empty() -> None:
|
|
96
|
+
"""Shell variable like --body=$bodyText cannot be resolved at hook time -- approve safely."""
|
|
97
|
+
command = 'gh pr create --title "T" --body=$bodyText'
|
|
98
|
+
assert extract_body_from_command(command) == ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_extract_short_flag_equals_form() -> None:
|
|
102
|
+
command = 'gh pr create --title "T" -b="Some body text here."'
|
|
103
|
+
assert extract_body_from_command(command) == "Some body text here."
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_extract_short_flag_shell_var_returns_empty() -> None:
|
|
107
|
+
"""Shell variable like -b=$var cannot be resolved at hook time -- approve safely."""
|
|
108
|
+
command = 'gh pr create --title "T" -b=$bodyVar'
|
|
109
|
+
assert extract_body_from_command(command) == ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_validate_passes_complete_body() -> None:
|
|
113
|
+
assert validate_pr_body(VALID_BODY) == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_validate_blocks_missing_sections() -> None:
|
|
117
|
+
violations = validate_pr_body("Some body text without required sections.\n" * 5)
|
|
118
|
+
assert any(
|
|
119
|
+
"Missing required section" in each_violation for each_violation in violations
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_validate_blocks_vague_language() -> None:
|
|
124
|
+
body = VALID_BODY + "\nFixed bug in the auth module.\n"
|
|
125
|
+
violations = validate_pr_body(body)
|
|
126
|
+
assert any("Vague language" in each_violation for each_violation in violations)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_validate_blocks_short_body() -> None:
|
|
130
|
+
violations = validate_pr_body("Too short.")
|
|
131
|
+
assert any("too short" in each_violation.lower() for each_violation in violations)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_body_file_content_validated(tmp_path: pathlib.Path) -> None:
|
|
135
|
+
body_file = tmp_path / "body.md"
|
|
136
|
+
body_file.write_text("Too short.")
|
|
137
|
+
body = extract_body_from_command(
|
|
138
|
+
f'gh pr create --title "T" --body-file {body_file}'
|
|
139
|
+
)
|
|
140
|
+
assert body == "Too short."
|
|
141
|
+
violations = validate_pr_body(body)
|
|
142
|
+
assert violations
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_extract_body_string_value_skips_body_file_path_token() -> None:
|
|
146
|
+
command = 'gh pr create --body-file --body "actual text"'
|
|
147
|
+
assert extract_body_from_command(command) is None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_get_logical_first_line_does_not_join_bash_command_substitution() -> None:
|
|
151
|
+
command = 'VAR=`cmd`\ngh pr create --body "text"'
|
|
152
|
+
assert get_logical_first_line(command) == "VAR=`cmd`"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_get_logical_first_line_joins_powershell_backtick_continuation() -> None:
|
|
156
|
+
command = 'Some-Command -Param `\n"value"'
|
|
157
|
+
assert get_logical_first_line(command) == 'Some-Command -Param "value"'
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_main_does_not_block_when_dash_b_only_appears_in_word() -> None:
|
|
161
|
+
hook_input = {
|
|
162
|
+
"tool_name": "Bash",
|
|
163
|
+
"tool_input": {"command": 'gh pr create --title "fix sub-branch handling"'},
|
|
164
|
+
}
|
|
165
|
+
captured_stdout = io.StringIO()
|
|
166
|
+
with patch("sys.stdin", io.StringIO(json.dumps(hook_input))):
|
|
167
|
+
with patch("sys.stdout", captured_stdout):
|
|
168
|
+
try:
|
|
169
|
+
hook_module.main()
|
|
170
|
+
except SystemExit:
|
|
171
|
+
pass
|
|
172
|
+
assert "deny" not in captured_stdout.getvalue()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_main_does_not_block_when_no_body_flag_present() -> None:
|
|
176
|
+
hook_input = {
|
|
177
|
+
"tool_name": "Bash",
|
|
178
|
+
"tool_input": {"command": 'gh pr create --title "My PR"'},
|
|
179
|
+
}
|
|
180
|
+
captured_stdout = io.StringIO()
|
|
181
|
+
with patch("sys.stdin", io.StringIO(json.dumps(hook_input))):
|
|
182
|
+
with patch("sys.stdout", captured_stdout):
|
|
183
|
+
try:
|
|
184
|
+
hook_module.main()
|
|
185
|
+
except SystemExit:
|
|
186
|
+
pass
|
|
187
|
+
assert "deny" not in captured_stdout.getvalue()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
HOOK_PATH = Path(__file__).parent / "config-change-guard.py"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run_hook(
|
|
12
|
+
source: str,
|
|
13
|
+
file_path: str,
|
|
14
|
+
extra_env: dict[str, str] | None = None,
|
|
15
|
+
) -> subprocess.CompletedProcess[str]:
|
|
16
|
+
payload = {"source": source, "file_path": file_path}
|
|
17
|
+
env = {**os.environ, **(extra_env or {})}
|
|
18
|
+
return subprocess.run(
|
|
19
|
+
[sys.executable, str(HOOK_PATH)],
|
|
20
|
+
input=json.dumps(payload),
|
|
21
|
+
text=True,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
check=False,
|
|
24
|
+
env=env,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_settings_with_hook_count(hook_count: int, tmp_path: Path) -> str:
|
|
29
|
+
hooks_list = [
|
|
30
|
+
{"type": "command", "command": f"hook_{each_index}.py"}
|
|
31
|
+
for each_index in range(hook_count)
|
|
32
|
+
]
|
|
33
|
+
settings = {"hooks": {"PreToolUse": [{"hooks": hooks_list}]}}
|
|
34
|
+
settings_file = tmp_path / "settings.json"
|
|
35
|
+
settings_file.write_text(json.dumps(settings))
|
|
36
|
+
return str(settings_file)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_hook_count_increase_emits_user_visible_output(tmp_path: Path) -> None:
|
|
40
|
+
known_count_file = tmp_path / "known-hook-count.txt"
|
|
41
|
+
known_count_file.write_text("2")
|
|
42
|
+
settings_path = _make_settings_with_hook_count(5, tmp_path)
|
|
43
|
+
|
|
44
|
+
hook_run = _run_hook(
|
|
45
|
+
source="user_settings",
|
|
46
|
+
file_path=settings_path,
|
|
47
|
+
extra_env={"KNOWN_HOOK_COUNT_FILE": str(known_count_file)},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
assert hook_run.returncode == 0
|
|
51
|
+
assert hook_run.stderr.strip() == ""
|
|
52
|
+
block_payload = json.loads(hook_run.stdout)
|
|
53
|
+
assert block_payload["decision"] == "block"
|
|
54
|
+
assert "2" in block_payload["reason"] and "5" in block_payload["reason"]
|
|
55
|
+
assert block_payload["hookSpecificOutput"]["hookEventName"] == "ConfigChange"
|
|
56
|
+
assert "hook" in block_payload["hookSpecificOutput"]["additionalContext"].lower()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_hook_count_stable_produces_no_output(tmp_path: Path) -> None:
|
|
60
|
+
known_count_file = tmp_path / "known-hook-count.txt"
|
|
61
|
+
known_count_file.write_text("3")
|
|
62
|
+
settings_path = _make_settings_with_hook_count(3, tmp_path)
|
|
63
|
+
|
|
64
|
+
hook_run = _run_hook(
|
|
65
|
+
source="user_settings",
|
|
66
|
+
file_path=settings_path,
|
|
67
|
+
extra_env={"KNOWN_HOOK_COUNT_FILE": str(known_count_file)},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
assert hook_run.returncode == 0
|
|
71
|
+
assert hook_run.stderr.strip() == ""
|
|
72
|
+
assert hook_run.stdout.strip() == ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_hook_count_decrease_produces_no_output(tmp_path: Path) -> None:
|
|
76
|
+
known_count_file = tmp_path / "known-hook-count.txt"
|
|
77
|
+
known_count_file.write_text("5")
|
|
78
|
+
settings_path = _make_settings_with_hook_count(3, tmp_path)
|
|
79
|
+
|
|
80
|
+
hook_run = _run_hook(
|
|
81
|
+
source="user_settings",
|
|
82
|
+
file_path=settings_path,
|
|
83
|
+
extra_env={"KNOWN_HOOK_COUNT_FILE": str(known_count_file)},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assert hook_run.returncode == 0
|
|
87
|
+
assert hook_run.stderr.strip() == ""
|
|
88
|
+
assert hook_run.stdout.strip() == ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_hook_count_increase_blocks_on_second_invocation(tmp_path: Path) -> None:
|
|
92
|
+
known_count_file = tmp_path / "known-hook-count.txt"
|
|
93
|
+
known_count_file.write_text("2")
|
|
94
|
+
settings_path = _make_settings_with_hook_count(5, tmp_path)
|
|
95
|
+
extra_env = {"KNOWN_HOOK_COUNT_FILE": str(known_count_file)}
|
|
96
|
+
|
|
97
|
+
first_run = _run_hook("user_settings", settings_path, extra_env)
|
|
98
|
+
assert first_run.returncode == 0
|
|
99
|
+
assert first_run.stdout.strip() != ""
|
|
100
|
+
|
|
101
|
+
second_run = _run_hook("user_settings", settings_path, extra_env)
|
|
102
|
+
assert second_run.returncode == 0
|
|
103
|
+
assert second_run.stdout.strip() != ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_non_user_settings_source_produces_no_output(tmp_path: Path) -> None:
|
|
107
|
+
settings_path = _make_settings_with_hook_count(10, tmp_path)
|
|
108
|
+
hook_run = _run_hook(source="system", file_path=settings_path)
|
|
109
|
+
assert hook_run.returncode == 0
|
|
110
|
+
assert hook_run.stderr.strip() == ""
|
|
111
|
+
assert hook_run.stdout.strip() == ""
|