claude-dev-env 1.39.0 → 1.41.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/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +10 -0
- package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
- package/_shared/pr-loop/scripts/post_audit_thread.py +298 -3
- package/_shared/pr-loop/scripts/preflight.py +129 -2
- package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +194 -1
- package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
- package/agents/pr-description-writer.md +150 -52
- package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
- package/hooks/_gh_pr_author_swap_utils.py +1211 -0
- package/hooks/blocking/gh_body_arg_blocker.py +9 -6
- package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
- package/hooks/blocking/gh_pr_author_restore.py +100 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
- package/hooks/blocking/pr_description_enforcer.py +56 -23
- package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
- package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
- package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
- package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
- package/hooks/blocking/test_pr_description_enforcer.py +69 -8
- package/hooks/config/gh_pr_author_swap_constants.py +76 -0
- package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
- package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
- package/hooks/config/pr_description_enforcer_constants.py +19 -0
- package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
- package/hooks/hooks.json +40 -0
- package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
- package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
- package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
- package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
- package/hooks/test__gh_pr_author_swap_utils.py +333 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
- package/skills/bugteam/SKILL.md +28 -10
- package/skills/bugteam/reference/audit-contract.md +22 -0
- package/skills/bugteam/reference/github-pr-reviews.md +1 -1
- package/skills/bugteam/reference/team-setup.md +5 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
- package/skills/bugteam/scripts/bugteam_preflight.py +36 -2
- package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +41 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
- package/skills/copilot-review/SKILL.md +16 -0
- package/skills/findbugs/SKILL.md +35 -7
- package/skills/monitor-open-prs/SKILL.md +2 -1
- package/skills/pr-converge/SKILL.md +11 -3
- package/skills/pr-converge/config/constants.py +3 -1
- package/skills/pr-converge/reference/per-tick.md +17 -0
- package/skills/pr-converge/reference/state-schema.md +36 -8
- package/skills/pr-converge/scripts/check_bugbot_ci.py +113 -8
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
- package/skills/qbug/SKILL.md +33 -8
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Canonical-location tests for the gh-pr-author swap utils module.
|
|
2
|
+
|
|
3
|
+
The TDD enforcer matches a production filename ``X.py`` to ``test_X.py``;
|
|
4
|
+
``_gh_pr_author_swap_utils.py`` carries a leading underscore that the
|
|
5
|
+
enforcer treats as part of the name. This file's tests are the canonical
|
|
6
|
+
match. The broader behavioural suite lives alongside in
|
|
7
|
+
``blocking/test_gh_pr_author_swap_utils.py``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from config.gh_pr_author_swap_constants import STATE_FILE_PERMISSION_MODE
|
|
21
|
+
|
|
22
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent
|
|
23
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
24
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
25
|
+
|
|
26
|
+
utils_module_spec = importlib.util.spec_from_file_location(
|
|
27
|
+
"_gh_pr_author_swap_utils",
|
|
28
|
+
_HOOKS_ROOT / "_gh_pr_author_swap_utils.py",
|
|
29
|
+
)
|
|
30
|
+
assert utils_module_spec is not None
|
|
31
|
+
assert utils_module_spec.loader is not None
|
|
32
|
+
utils_module = importlib.util.module_from_spec(utils_module_spec)
|
|
33
|
+
utils_module_spec.loader.exec_module(utils_module)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_state_file_path_rejects_path_traversal_session_id() -> None:
|
|
37
|
+
"""A session_id containing path-traversal characters must not escape tempdir.
|
|
38
|
+
|
|
39
|
+
Regression guard: an unsanitised ``session_id`` containing ``../`` or
|
|
40
|
+
``/`` would interpolate into the filename and let the resulting path
|
|
41
|
+
land outside ``tempfile.gettempdir()``. The sanitiser strips every
|
|
42
|
+
character outside ``[A-Za-z0-9_-]`` and falls back to the default
|
|
43
|
+
session id when the result is empty.
|
|
44
|
+
"""
|
|
45
|
+
sanitised_path = utils_module._state_file_path("../../tmp/evil")
|
|
46
|
+
temporary_directory_path = pathlib.Path(tempfile.gettempdir()).resolve()
|
|
47
|
+
assert sanitised_path.parent.resolve() == temporary_directory_path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_state_file_path_rejects_backslash_in_session_id() -> None:
|
|
51
|
+
"""Backslashes are also unsafe path separators on Windows."""
|
|
52
|
+
sanitised_path = utils_module._state_file_path("evil\\..\\..\\system32")
|
|
53
|
+
temporary_directory_path = pathlib.Path(tempfile.gettempdir()).resolve()
|
|
54
|
+
assert sanitised_path.parent.resolve() == temporary_directory_path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_state_file_path_rejects_nul_byte_in_session_id() -> None:
|
|
58
|
+
"""A NUL byte inside the session id must not reach the filename."""
|
|
59
|
+
sanitised_path = utils_module._state_file_path("abc\x00../def")
|
|
60
|
+
temporary_directory_path = pathlib.Path(tempfile.gettempdir()).resolve()
|
|
61
|
+
assert sanitised_path.parent.resolve() == temporary_directory_path
|
|
62
|
+
assert "\x00" not in sanitised_path.name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_state_file_path_preserves_safe_session_id() -> None:
|
|
66
|
+
"""A well-formed session id passes through unchanged."""
|
|
67
|
+
safe_session_id = "session-001_abc"
|
|
68
|
+
produced_path = utils_module._state_file_path(safe_session_id)
|
|
69
|
+
assert safe_session_id in produced_path.name
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_backtick_substitution_blanks_inner_quoted_literals() -> None:
|
|
73
|
+
"""A ``gh pr create`` literal inside a single-quoted argument of a backtick body must not trigger.
|
|
74
|
+
|
|
75
|
+
Mirrors ``$(printf '...')`` behaviour: when the backtick body's
|
|
76
|
+
quoted literal contains the token, the matcher must blank the
|
|
77
|
+
quoted region before searching.
|
|
78
|
+
"""
|
|
79
|
+
stripped_command = utils_module._strip_quoted_regions("foo `printf ';gh pr create'`")
|
|
80
|
+
assert not utils_module._command_invokes_gh_pr_create_in_stripped(stripped_command)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_backtick_substitution_matches_unquoted_gh_pr_create_inside_body() -> None:
|
|
84
|
+
"""A bare ``gh pr create`` inside a backtick body still matches.
|
|
85
|
+
|
|
86
|
+
Symmetric to ``$(gh pr create)`` — the substitution body is real
|
|
87
|
+
code, so the matcher must see it.
|
|
88
|
+
"""
|
|
89
|
+
stripped_command = utils_module._strip_quoted_regions("echo `gh pr create --title T`")
|
|
90
|
+
assert utils_module._command_invokes_gh_pr_create_in_stripped(stripped_command)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_state_file_is_attacker_planted_returns_true_for_world_readable_mode(
|
|
94
|
+
tmp_path: pathlib.Path,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""A state file with mode 0o644 is flagged as attacker-planted on POSIX.
|
|
97
|
+
|
|
98
|
+
The enforcer always atomically creates state files at 0o600. A file
|
|
99
|
+
at the predictable swap-state path with any other mode bits cannot
|
|
100
|
+
have come from the enforcer running as this user.
|
|
101
|
+
"""
|
|
102
|
+
if not hasattr(os, "getuid"):
|
|
103
|
+
return
|
|
104
|
+
state_file = tmp_path / "gh_pr_author_swap_session-attacker.json"
|
|
105
|
+
state_file.write_text("{}", encoding="utf-8")
|
|
106
|
+
os.chmod(state_file, 0o644)
|
|
107
|
+
|
|
108
|
+
assert utils_module._state_file_is_attacker_planted(state_file) is True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_state_file_is_attacker_planted_returns_false_for_well_formed_file(
|
|
112
|
+
tmp_path: pathlib.Path,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""A state file written exactly the way the enforcer writes is not flagged."""
|
|
115
|
+
state_file = tmp_path / "gh_pr_author_swap_session-good.json"
|
|
116
|
+
state_file.write_text("{}", encoding="utf-8")
|
|
117
|
+
if hasattr(os, "getuid"):
|
|
118
|
+
os.chmod(state_file, STATE_FILE_PERMISSION_MODE)
|
|
119
|
+
|
|
120
|
+
assert utils_module._state_file_is_attacker_planted(state_file) is False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_state_file_is_attacker_planted_returns_false_for_missing_file(
|
|
124
|
+
tmp_path: pathlib.Path,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""A missing state file is treated as not-planted so callers can no-op cleanly."""
|
|
127
|
+
missing_state_file = tmp_path / "gh_pr_author_swap_session-missing.json"
|
|
128
|
+
|
|
129
|
+
assert utils_module._state_file_is_attacker_planted(missing_state_file) is False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_state_file_is_attacker_planted_returns_true_for_non_regular_file(
|
|
133
|
+
tmp_path: pathlib.Path,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""A FIFO at the predictable swap-state path is flagged as attacker-planted.
|
|
136
|
+
|
|
137
|
+
The enforcer only writes regular files; any non-regular file type
|
|
138
|
+
(symlink, FIFO, device) at the predictable path indicates another
|
|
139
|
+
party pre-created it to redirect the restore or cleanup hook.
|
|
140
|
+
"""
|
|
141
|
+
if not hasattr(os, "mkfifo"):
|
|
142
|
+
pytest.skip("mkfifo not available on this platform")
|
|
143
|
+
if not hasattr(os, "getuid"):
|
|
144
|
+
pytest.skip("POSIX ownership semantics not available on this platform")
|
|
145
|
+
fifo_state_file = tmp_path / "gh_pr_author_swap_session-fifo.json"
|
|
146
|
+
os.mkfifo(fifo_state_file, STATE_FILE_PERMISSION_MODE)
|
|
147
|
+
|
|
148
|
+
assert utils_module._state_file_is_attacker_planted(fifo_state_file) is True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_state_file_is_attacker_planted_returns_true_when_lstat_raises_os_error(
|
|
152
|
+
tmp_path: pathlib.Path,
|
|
153
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""An ``OSError`` from ``lstat`` fails closed — the path is treated as planted.
|
|
156
|
+
|
|
157
|
+
A path that exists but is unreadable (permission denied, broken
|
|
158
|
+
filesystem mount, etc.) cannot be proven safe, so the helper
|
|
159
|
+
returns True to keep the restore or cleanup hook from trusting it.
|
|
160
|
+
"""
|
|
161
|
+
if not hasattr(os, "getuid"):
|
|
162
|
+
pytest.skip("POSIX ownership semantics not available on this platform")
|
|
163
|
+
unreadable_state_file = tmp_path / "gh_pr_author_swap_session-unreadable.json"
|
|
164
|
+
unreadable_state_file.write_text("{}", encoding="utf-8")
|
|
165
|
+
|
|
166
|
+
def raise_permission_error(self: pathlib.Path) -> os.stat_result:
|
|
167
|
+
raise PermissionError("simulated lstat failure")
|
|
168
|
+
|
|
169
|
+
monkeypatch.setattr(pathlib.Path, "lstat", raise_permission_error)
|
|
170
|
+
|
|
171
|
+
assert utils_module._state_file_is_attacker_planted(unreadable_state_file) is True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_strip_bash_comments_preserves_substitution_body_when_hash_is_inside_dollar_paren() -> None:
|
|
175
|
+
"""Regression: ``$(date +%H # 24h) && gh pr create`` must not let the regex eat past the substitution closer.
|
|
176
|
+
|
|
177
|
+
Before this fix ``_strip_bash_comments`` ran a flat regex sweep that
|
|
178
|
+
blanked from ``#`` to end-of-line regardless of substitution depth.
|
|
179
|
+
A ``#`` preceded by whitespace inside a ``$(...)`` body consumed
|
|
180
|
+
the closing ``)`` AND every byte after it on the same line,
|
|
181
|
+
erasing a real ``gh pr create`` invocation from the enforcer's
|
|
182
|
+
view and silently bypassing the swap.
|
|
183
|
+
"""
|
|
184
|
+
raw_command = "$(date +%H # 24h) && gh pr create --title T"
|
|
185
|
+
preprocessed_command = utils_module._preprocess_command_for_matching(raw_command)
|
|
186
|
+
assert "gh pr create" in preprocessed_command
|
|
187
|
+
assert utils_module._command_invokes_gh_pr_create_in_stripped(preprocessed_command)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_strip_bash_comments_preserves_substitution_body_when_hash_is_inside_backtick() -> None:
|
|
191
|
+
"""Regression: a ``#`` inside a backtick body must not erase a trailing ``gh pr create``.
|
|
192
|
+
|
|
193
|
+
Backtick substitution is symmetric with ``$(...)``; the
|
|
194
|
+
substitution-aware comment walker must treat both shapes the same.
|
|
195
|
+
"""
|
|
196
|
+
raw_command = "foo `cmd # comment` bar && gh pr create"
|
|
197
|
+
preprocessed_command = utils_module._preprocess_command_for_matching(raw_command)
|
|
198
|
+
assert "gh pr create" in preprocessed_command
|
|
199
|
+
assert utils_module._command_invokes_gh_pr_create_in_stripped(preprocessed_command)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_strip_bash_comments_strips_top_level_trailing_comment() -> None:
|
|
203
|
+
"""Existing behaviour: a top-level trailing ``#`` comment after ``gh pr create`` is blanked.
|
|
204
|
+
|
|
205
|
+
The comment-stripping pass must still erase a real top-level
|
|
206
|
+
comment so the enforcer treats only the command portion as code.
|
|
207
|
+
"""
|
|
208
|
+
raw_command = "gh pr create # this is a comment"
|
|
209
|
+
preprocessed_command = utils_module._preprocess_command_for_matching(raw_command)
|
|
210
|
+
assert "this is a comment" not in preprocessed_command
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_strip_bash_comments_strips_prior_line_comment_only() -> None:
|
|
214
|
+
"""Existing behaviour: a comment on line 1 is blanked but a ``gh pr create`` on line 2 still matches.
|
|
215
|
+
|
|
216
|
+
The newline is preserved so the matcher can still tell the two
|
|
217
|
+
lines apart, and the second-line command stays intact.
|
|
218
|
+
"""
|
|
219
|
+
raw_command = "echo a # b\ngh pr create"
|
|
220
|
+
preprocessed_command = utils_module._preprocess_command_for_matching(raw_command)
|
|
221
|
+
assert "gh pr create" in preprocessed_command
|
|
222
|
+
assert "# b" not in preprocessed_command
|
|
223
|
+
assert utils_module._command_invokes_gh_pr_create_in_stripped(preprocessed_command)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_lstat_indicates_attacker_planted_returns_false_for_well_formed_lstat(
|
|
227
|
+
tmp_path: pathlib.Path,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""A 0o600 regular file owned by the current user is not flagged.
|
|
230
|
+
|
|
231
|
+
Mirrors ``test_state_file_is_attacker_planted_returns_false_for_well_formed_file``
|
|
232
|
+
but feeds the helper a pre-computed ``lstat`` result so the helper
|
|
233
|
+
does not perform its own syscall.
|
|
234
|
+
"""
|
|
235
|
+
state_file = tmp_path / "gh_pr_author_swap_session-well_formed.json"
|
|
236
|
+
state_file.write_text("{}", encoding="utf-8")
|
|
237
|
+
if hasattr(os, "getuid"):
|
|
238
|
+
os.chmod(state_file, STATE_FILE_PERMISSION_MODE)
|
|
239
|
+
|
|
240
|
+
file_lstat_result = state_file.lstat()
|
|
241
|
+
|
|
242
|
+
assert utils_module._lstat_indicates_attacker_planted(file_lstat_result) is False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_lstat_indicates_attacker_planted_returns_true_for_world_readable_mode(
|
|
246
|
+
tmp_path: pathlib.Path,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""A 0o644 regular file is flagged as attacker-planted on POSIX.
|
|
249
|
+
|
|
250
|
+
The enforcer always creates state files at 0o600, so a divergent
|
|
251
|
+
mode is treated as a plant.
|
|
252
|
+
"""
|
|
253
|
+
if not hasattr(os, "getuid"):
|
|
254
|
+
pytest.skip("POSIX ownership semantics not available on this platform")
|
|
255
|
+
state_file = tmp_path / "gh_pr_author_swap_session-mode_wrong.json"
|
|
256
|
+
state_file.write_text("{}", encoding="utf-8")
|
|
257
|
+
os.chmod(state_file, 0o644)
|
|
258
|
+
|
|
259
|
+
file_lstat_result = state_file.lstat()
|
|
260
|
+
|
|
261
|
+
assert utils_module._lstat_indicates_attacker_planted(file_lstat_result) is True
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_lstat_indicates_attacker_planted_returns_true_for_foreign_uid(
|
|
265
|
+
tmp_path: pathlib.Path,
|
|
266
|
+
) -> None:
|
|
267
|
+
"""A regular 0o600 file owned by a different uid is flagged on POSIX.
|
|
268
|
+
|
|
269
|
+
The helper sees only the ``stat_result`` it is given, so the test
|
|
270
|
+
builds a synthetic ``os.stat_result`` whose ``st_uid`` does not match
|
|
271
|
+
``os.getuid()`` and feeds it directly to the helper.
|
|
272
|
+
"""
|
|
273
|
+
if not hasattr(os, "getuid"):
|
|
274
|
+
pytest.skip("POSIX ownership semantics not available on this platform")
|
|
275
|
+
state_file = tmp_path / "gh_pr_author_swap_session-foreign_uid.json"
|
|
276
|
+
state_file.write_text("{}", encoding="utf-8")
|
|
277
|
+
os.chmod(state_file, STATE_FILE_PERMISSION_MODE)
|
|
278
|
+
real_lstat_result = state_file.lstat()
|
|
279
|
+
foreign_user_id = os.getuid() + 1
|
|
280
|
+
synthetic_stat_fields = (
|
|
281
|
+
real_lstat_result.st_mode,
|
|
282
|
+
real_lstat_result.st_ino,
|
|
283
|
+
real_lstat_result.st_dev,
|
|
284
|
+
real_lstat_result.st_nlink,
|
|
285
|
+
foreign_user_id,
|
|
286
|
+
real_lstat_result.st_gid,
|
|
287
|
+
real_lstat_result.st_size,
|
|
288
|
+
real_lstat_result.st_atime,
|
|
289
|
+
real_lstat_result.st_mtime,
|
|
290
|
+
real_lstat_result.st_ctime,
|
|
291
|
+
)
|
|
292
|
+
synthetic_stat_result = os.stat_result(synthetic_stat_fields)
|
|
293
|
+
|
|
294
|
+
assert utils_module._lstat_indicates_attacker_planted(synthetic_stat_result) is True
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_lstat_indicates_attacker_planted_returns_true_for_non_regular_file(
|
|
298
|
+
tmp_path: pathlib.Path,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""A FIFO at the predictable swap-state path is flagged.
|
|
301
|
+
|
|
302
|
+
Mirrors ``test_state_file_is_attacker_planted_returns_true_for_non_regular_file``
|
|
303
|
+
but feeds the helper the FIFO's own ``lstat`` result rather than the
|
|
304
|
+
path.
|
|
305
|
+
"""
|
|
306
|
+
if not hasattr(os, "mkfifo"):
|
|
307
|
+
pytest.skip("mkfifo not available on this platform")
|
|
308
|
+
if not hasattr(os, "getuid"):
|
|
309
|
+
pytest.skip("POSIX ownership semantics not available on this platform")
|
|
310
|
+
fifo_state_file = tmp_path / "gh_pr_author_swap_session-fifo.json"
|
|
311
|
+
os.mkfifo(fifo_state_file, STATE_FILE_PERMISSION_MODE)
|
|
312
|
+
|
|
313
|
+
file_lstat_result = fifo_state_file.lstat()
|
|
314
|
+
|
|
315
|
+
assert utils_module._lstat_indicates_attacker_planted(file_lstat_result) is True
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_lstat_indicates_attacker_planted_returns_false_on_windows(
|
|
319
|
+
tmp_path: pathlib.Path,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""On Windows (no ``os.getuid``) the helper short-circuits to False.
|
|
322
|
+
|
|
323
|
+
Windows tempdir is already per-user, so the cross-user attack
|
|
324
|
+
surface this check guards against on POSIX does not exist there.
|
|
325
|
+
"""
|
|
326
|
+
if hasattr(os, "getuid"):
|
|
327
|
+
pytest.skip("POSIX has os.getuid; this case asserts Windows-only behaviour")
|
|
328
|
+
state_file = tmp_path / "gh_pr_author_swap_session-windows.json"
|
|
329
|
+
state_file.write_text("{}", encoding="utf-8")
|
|
330
|
+
|
|
331
|
+
file_lstat_result = state_file.lstat()
|
|
332
|
+
|
|
333
|
+
assert utils_module._lstat_indicates_attacker_planted(file_lstat_result) is False
|
package/package.json
CHANGED
|
@@ -70,8 +70,8 @@ def _populate_findings(parent: Element, findings_data: list[dict[str, object]])
|
|
|
70
70
|
|
|
71
71
|
Scalar finding fields become XML attributes on `<finding>`; the
|
|
72
72
|
body fields named in `ALL_FINDING_BODY_ELEMENT_KEYS` (defined in
|
|
73
|
-
`config/path_resolver_constants.py`
|
|
74
|
-
`("title", "excerpt", "description")`) become child elements.
|
|
73
|
+
`packages/claude-dev-env/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py`
|
|
74
|
+
and currently `("title", "excerpt", "description")`) become child elements.
|
|
75
75
|
Nested dicts or lists in scalar slots are flattened to string form
|
|
76
76
|
so attribute serialization stays well-defined.
|
|
77
77
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Validate status enum values and write <bugteam_fix> XML at the canonical path.
|
|
2
2
|
|
|
3
3
|
Status enum (canonical source: `ALL_VALID_FIX_STATUSES` in
|
|
4
|
-
`config/path_resolver_constants.py`):
|
|
5
|
-
hook_blocked | unverified_fixed.
|
|
4
|
+
`packages/claude-dev-env/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py`):
|
|
5
|
+
fixed | could_not_address | hook_blocked | unverified_fixed.
|
|
6
6
|
|
|
7
7
|
Each outcome's scalar fields become XML attributes on `<outcome>`; the
|
|
8
8
|
body fields named in `ALL_FIX_OUTCOME_BODY_ELEMENT_KEYS` (currently
|
package/skills/bugteam/SKILL.md
CHANGED
|
@@ -32,6 +32,11 @@ other failures require manual fix before Step 0. Full detail:
|
|
|
32
32
|
|
|
33
33
|
First match wins; respond with the quoted line exactly and stop:
|
|
34
34
|
|
|
35
|
+
- **Disabled via environment.** When `CLAUDE_REVIEWS_DISABLED` contains the
|
|
36
|
+
token `bugteam` (comma-separated, case-insensitive, whitespace-tolerant):
|
|
37
|
+
`/bugteam is disabled via CLAUDE_REVIEWS_DISABLED.` The pre-flight script
|
|
38
|
+
also exits 7 in this case so any caller invoking it directly halts on the
|
|
39
|
+
same signal.
|
|
35
40
|
- **No PR or upstream diff.** `No PR or upstream diff. /bugteam needs a target.`
|
|
36
41
|
- **Dirty tree.** `Uncommitted changes detected. Stash, commit, or revert before
|
|
37
42
|
/bugteam.`
|
|
@@ -50,16 +55,29 @@ Every internal audit pass (CLEAN or DIRTY) ends with one call to
|
|
|
50
55
|
finding; each becomes its own resolvable thread). The mandate applies
|
|
51
56
|
whether bugteam runs inside `/pr-converge` or standalone.
|
|
52
57
|
|
|
53
|
-
**Self-PR
|
|
54
|
-
`REQUEST_CHANGES` reviews when the authenticated identity
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
**Self-PR auto-toggle.** GitHub rejects both `APPROVE` and
|
|
59
|
+
`REQUEST_CHANGES` reviews with HTTP 422 when the authenticated identity
|
|
60
|
+
matches the PR author ("Cannot approve/request changes on your own pull
|
|
61
|
+
request"). `post_audit_thread.py` detects this case via `gh api user` +
|
|
62
|
+
`gh api repos/<o>/<r>/pulls/<n>` and auto-resolves an alternate gh
|
|
63
|
+
account's token for the reviews POST — the active `gh auth` account is
|
|
64
|
+
not mutated; only the bearer token sent on the request changes. After
|
|
65
|
+
the POST the active account is still whoever it was before, so no
|
|
66
|
+
"swap back" step is needed.
|
|
67
|
+
|
|
68
|
+
Configuration:
|
|
69
|
+
|
|
70
|
+
- `GH_TOKEN` / `GITHUB_TOKEN` env vars take precedence over the toggle.
|
|
71
|
+
Set them when you need to pin a specific reviewer identity by token
|
|
72
|
+
rather than by account login.
|
|
73
|
+
- `BUGTEAM_REVIEWER_ACCOUNT` env var names which authenticated alternate
|
|
74
|
+
to prefer when a toggle is needed (for example,
|
|
75
|
+
`BUGTEAM_REVIEWER_ACCOUNT=jl-cmd`). When unset, the script falls back
|
|
76
|
+
to the first alternate account `gh auth status` reports.
|
|
77
|
+
- The named alternate must be logged in (`gh auth login -h github.com -u
|
|
78
|
+
<login>`) before the audit skill runs. The script exits 1 with a
|
|
79
|
+
pointing-at-`gh auth login` message when self-PR is detected and no
|
|
80
|
+
usable alternate is authenticated.
|
|
63
81
|
|
|
64
82
|
```
|
|
65
83
|
python "${CLAUDE_SKILL_DIR}/../../_shared/pr-loop/scripts/post_audit_thread.py" \
|
|
@@ -91,6 +91,17 @@ The audit must either produce new Shape A findings citing new file:line referenc
|
|
|
91
91
|
|
|
92
92
|
For `/bugteam`, the single audit agent provides per-category coverage by walking all A–K rubrics in one invocation.
|
|
93
93
|
|
|
94
|
+
## Merge rules
|
|
95
|
+
|
|
96
|
+
When the LEAD combines findings from multiple sources (primary auditor, Haiku secondary auditor, adversarial pass), it applies these rules:
|
|
97
|
+
|
|
98
|
+
- **De-dup key**: `(file, line, category)`. Two findings sharing the same `(file, line, category)` tuple are the same finding and collapse into one entry.
|
|
99
|
+
- **Severity conflict**: max wins (`P0 > P1 > P2`). When sources disagree on severity for the same de-dup key, the merged entry keeps the highest severity.
|
|
100
|
+
- **Unique-to-secondary findings**: added to the merged set with the secondary's severity and source annotation.
|
|
101
|
+
- **Unique-to-primary findings**: kept as-is.
|
|
102
|
+
- **Zero secondary findings**: primary set trusted; proceed.
|
|
103
|
+
- **Malformed or non-parseable secondary output**: lead trusts the primary set and logs the event in `loop-<L>-diagnostics.json` under `haiku_findings` as `[{"parse_error": "<message>"}]`.
|
|
104
|
+
|
|
94
105
|
## Post-fix self-audit
|
|
95
106
|
|
|
96
107
|
Audit-and-fix skills (`/qbug`, `/bugteam`) MUST re-audit modified files between `py_compile` and `git add`. This catches fix-induced regressions in the same loop that introduced them rather than on loop N+1.
|
|
@@ -109,6 +120,17 @@ Sequence:
|
|
|
109
120
|
|
|
110
121
|
`converged` exit condition: `primary_audit_clean AND post_fix_audit_clean` for the committing loop.
|
|
111
122
|
|
|
123
|
+
## De-dup and merge
|
|
124
|
+
|
|
125
|
+
Findings from primary, adversarial, Haiku secondary, and post-fix passes are merged into a single deduped finding list before persistence.
|
|
126
|
+
|
|
127
|
+
- **De-dup key:** `(file, line, category)`. Two findings sharing the same `(file, line, category)` tuple collapse into a single deduped entry.
|
|
128
|
+
- **Severity conflict resolution:** `max wins`. When merged findings disagree on severity, the deduped entry carries the maximum severity (`P0 > P1 > P2`).
|
|
129
|
+
- **Excerpt and failure_mode:** the deduped entry inherits these fields from the highest-severity contributing finding. Ties keep the first observed contributor.
|
|
130
|
+
- **`evidence_files`:** the deduped entry carries the union of every contributor's `evidence_files`, deduplicated and sorted.
|
|
131
|
+
|
|
132
|
+
The merged list lands in `loop-<L>-diagnostics.json` under both `merged` (one entry per contributing finding) and `deduped` (one entry per unique `(file, line, category)` tuple).
|
|
133
|
+
|
|
112
134
|
## Persistence
|
|
113
135
|
|
|
114
136
|
Every audit loop writes two JSON files under the skill's scoped temp directory (resolved via `tempfile.gettempdir()`):
|
|
@@ -22,7 +22,7 @@ python "${CLAUDE_SKILL_DIR}/../../_shared/pr-loop/scripts/post_audit_thread.py"
|
|
|
22
22
|
|
|
23
23
|
Capture `<head_sha>` via `git rev-parse HEAD` in the subagent cwd immediately before this call so the review attaches to the commit the audit actually scoped.
|
|
24
24
|
|
|
25
|
-
`--findings-json` points to a JSON file whose root is a list of objects shaped `{path, line, side, severity, description, fix_summary}`. Build it from the merged Shape A findings: finding `file` → `path`. Each finding's `failure_mode` carries the full audit-to-fix handoff text per [`agents/code-quality-agent.md`](../../../agents/code-quality-agent.md); split `failure_mode` at the literal `Fix:` heading so the failure narrative becomes `description` and the suffix beginning at `Fix:` (including the trailing `Validation:` clause) becomes `fix_summary`. When a finding's `failure_mode` omits the `Fix:` heading, write the full text to BOTH `description` and `fix_summary` so the script's body template (`INLINE_COMMENT_BODY_TEMPLATE` in [`scripts/config/post_audit_thread_constants.py`](../../../_shared/pr-loop/scripts/config/post_audit_thread_constants.py)) renders coherently. Set `side="RIGHT"` for every entry. On CLEAN the list is empty (`[]`); on DIRTY the list carries one entry per finding.
|
|
25
|
+
`--findings-json` points to a JSON file whose root is a list of objects shaped `{path, line, side, severity, description, fix_summary}`. Build it from the merged Shape A findings: finding `file` → `path`. Each finding's `failure_mode` carries the full audit-to-fix handoff text per [`agents/code-quality-agent.md`](../../../agents/code-quality-agent.md); split `failure_mode` at the literal `Fix:` heading so the failure narrative becomes `description` and the suffix beginning at `Fix:` (including the trailing `Validation:` clause) becomes `fix_summary`. When a finding's `failure_mode` omits the `Fix:` heading, write the full text to BOTH `description` and `fix_summary` so the script's body template (`INLINE_COMMENT_BODY_TEMPLATE` in [`packages/claude-dev-env/_shared/pr-loop/scripts/config/post_audit_thread_constants.py`](../../../_shared/pr-loop/scripts/config/post_audit_thread_constants.py)) renders coherently. Set `side="RIGHT"` for every entry. On CLEAN the list is empty (`[]`); on DIRTY the list carries one entry per finding.
|
|
26
26
|
|
|
27
27
|
The script handles retries internally — 1s / 4s / 16s backoff across four attempts (one initial plus three retries). Exit codes:
|
|
28
28
|
|
|
@@ -138,3 +138,8 @@ Self-claiming by task subject prefix keeps each teammate on its assigned PR.
|
|
|
138
138
|
**`--bugbot-retrigger` flag:** when present, the FIX subagent posts a `bugbot
|
|
139
139
|
run` issue comment via the Step 2.5 issue-comments fallback endpoint after
|
|
140
140
|
every successful FIX push, to re-trigger Cursor's bugbot on the new commit.
|
|
141
|
+
|
|
142
|
+
**Opt-out gate.** When `CLAUDE_REVIEWS_DISABLED` (comma-separated,
|
|
143
|
+
case-insensitive, whitespace-tolerant) contains the token `bugbot`, the FIX
|
|
144
|
+
subagent skips the re-trigger post even when the flag is present. The rest of
|
|
145
|
+
the bugteam audit/fix cycle continues unchanged.
|
|
@@ -5,14 +5,20 @@ import subprocess
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
parent_directory = str(Path(__file__).resolve().parent)
|
|
9
|
+
try:
|
|
10
|
+
sys.path.remove(parent_directory)
|
|
11
|
+
except ValueError:
|
|
12
|
+
pass
|
|
13
|
+
if parent_directory not in sys.path:
|
|
14
|
+
sys.path.insert(0, parent_directory)
|
|
15
|
+
|
|
8
16
|
for each_cached_module_name in [
|
|
9
17
|
each_module_key
|
|
10
18
|
for each_module_key in list(sys.modules)
|
|
11
19
|
if each_module_key == "config" or each_module_key.startswith("config.")
|
|
12
20
|
]:
|
|
13
21
|
sys.modules.pop(each_cached_module_name, None)
|
|
14
|
-
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
15
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
16
22
|
|
|
17
23
|
from config.bugteam_fix_hookspath_constants import (
|
|
18
24
|
ALL_CANONICAL_HOOKS_DIRECTORY_COMPONENTS,
|
|
@@ -12,8 +12,11 @@ for each_cached_module_name in [
|
|
|
12
12
|
if each_module_key == "config" or each_module_key.startswith("config.")
|
|
13
13
|
]:
|
|
14
14
|
sys.modules.pop(each_cached_module_name, None)
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
_bugteam_scripts_directory = str(Path(__file__).absolute().parent)
|
|
16
|
+
while _bugteam_scripts_directory in sys.path:
|
|
17
|
+
sys.path.remove(_bugteam_scripts_directory)
|
|
18
|
+
if _bugteam_scripts_directory not in sys.path:
|
|
19
|
+
sys.path.insert(0, _bugteam_scripts_directory)
|
|
17
20
|
|
|
18
21
|
from config.bugteam_preflight_constants import (
|
|
19
22
|
ALL_DISCOVERY_IGNORE_DIRECTORIES,
|
|
@@ -32,6 +35,26 @@ from config.bugteam_preflight_constants import (
|
|
|
32
35
|
PYTEST_INI_FILENAME,
|
|
33
36
|
)
|
|
34
37
|
|
|
38
|
+
for each_cached_module_name in [
|
|
39
|
+
each_module_key
|
|
40
|
+
for each_module_key in list(sys.modules)
|
|
41
|
+
if each_module_key == "config" or each_module_key.startswith("config.")
|
|
42
|
+
]:
|
|
43
|
+
sys.modules.pop(each_cached_module_name, None)
|
|
44
|
+
_shared_pr_loop_scripts_directory = (
|
|
45
|
+
Path(__file__).absolute().parent
|
|
46
|
+
/ ".." / ".." / ".." / "_shared" / "pr-loop" / "scripts"
|
|
47
|
+
).absolute()
|
|
48
|
+
if str(_shared_pr_loop_scripts_directory) not in sys.path:
|
|
49
|
+
sys.path.insert(0, str(_shared_pr_loop_scripts_directory))
|
|
50
|
+
|
|
51
|
+
from reviews_disabled import (
|
|
52
|
+
CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN,
|
|
53
|
+
CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME,
|
|
54
|
+
EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV,
|
|
55
|
+
is_bugteam_disabled_via_env,
|
|
56
|
+
)
|
|
57
|
+
|
|
35
58
|
|
|
36
59
|
def verify_git_hooks_path(repository_root: Path | None) -> int:
|
|
37
60
|
"""Check that core.hooksPath resolves to the claude-dev-env git-hooks directory.
|
|
@@ -259,6 +282,17 @@ def main(all_argv: list[str] | None = None) -> int:
|
|
|
259
282
|
if os.environ.get(BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME, "").strip() == "1":
|
|
260
283
|
print(f"{BUGTEAM_PREFLIGHT_PREFIX}skipped (BUGTEAM_PREFLIGHT_SKIP=1).", file=sys.stderr)
|
|
261
284
|
return 0
|
|
285
|
+
reviews_disabled_env_var_name = CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME
|
|
286
|
+
reviews_disabled_bugteam_token = CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN
|
|
287
|
+
disabled_via_env_exit_code = EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV
|
|
288
|
+
if is_bugteam_disabled_via_env():
|
|
289
|
+
print(
|
|
290
|
+
f"{BUGTEAM_PREFLIGHT_PREFIX}halted "
|
|
291
|
+
f"({reviews_disabled_env_var_name} contains "
|
|
292
|
+
f"'{reviews_disabled_bugteam_token}').",
|
|
293
|
+
file=sys.stderr,
|
|
294
|
+
)
|
|
295
|
+
return disabled_via_env_exit_code
|
|
262
296
|
start = Path.cwd()
|
|
263
297
|
resolved_repository_root: Path = (
|
|
264
298
|
arguments.repo_root.resolve()
|
|
@@ -19,6 +19,54 @@ if _script_directory not in sys.path:
|
|
|
19
19
|
sys.path.insert(0, _script_directory)
|
|
20
20
|
|
|
21
21
|
import _claude_permissions_common as common_module
|
|
22
|
+
import grant_project_claude_permissions as grant_module
|
|
23
|
+
import revoke_project_claude_permissions as revoke_module
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_is_valid_project_root_not_defined_on_common_module() -> None:
|
|
27
|
+
"""The common module must not expose ``is_valid_project_root``.
|
|
28
|
+
|
|
29
|
+
Both grant and revoke define their own local copy. A shared copy on
|
|
30
|
+
the common module would be a third parallel definition with no
|
|
31
|
+
production caller, so its absence is the asserted contract.
|
|
32
|
+
"""
|
|
33
|
+
assert not hasattr(common_module, "is_valid_project_root")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_grant_is_valid_project_root_detects_git_marker(tmp_path: Path) -> None:
|
|
37
|
+
git_project_root = tmp_path / "git_project"
|
|
38
|
+
(git_project_root / ".git").mkdir(parents=True)
|
|
39
|
+
assert grant_module.is_valid_project_root(git_project_root) is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_grant_is_valid_project_root_detects_claude_marker(tmp_path: Path) -> None:
|
|
43
|
+
claude_project_root = tmp_path / "claude_project"
|
|
44
|
+
(claude_project_root / ".claude").mkdir(parents=True)
|
|
45
|
+
assert grant_module.is_valid_project_root(claude_project_root) is True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_grant_is_valid_project_root_rejects_unmarked_directory(tmp_path: Path) -> None:
|
|
49
|
+
unmarked_directory = tmp_path / "no_marker"
|
|
50
|
+
unmarked_directory.mkdir()
|
|
51
|
+
assert grant_module.is_valid_project_root(unmarked_directory) is False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_revoke_is_valid_project_root_detects_git_marker(tmp_path: Path) -> None:
|
|
55
|
+
git_project_root = tmp_path / "git_project"
|
|
56
|
+
(git_project_root / ".git").mkdir(parents=True)
|
|
57
|
+
assert revoke_module.is_valid_project_root(git_project_root) is True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_revoke_is_valid_project_root_detects_claude_marker(tmp_path: Path) -> None:
|
|
61
|
+
claude_project_root = tmp_path / "claude_project"
|
|
62
|
+
(claude_project_root / ".claude").mkdir(parents=True)
|
|
63
|
+
assert revoke_module.is_valid_project_root(claude_project_root) is True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_revoke_is_valid_project_root_rejects_unmarked_directory(tmp_path: Path) -> None:
|
|
67
|
+
unmarked_directory = tmp_path / "no_marker"
|
|
68
|
+
unmarked_directory.mkdir()
|
|
69
|
+
assert revoke_module.is_valid_project_root(unmarked_directory) is False
|
|
22
70
|
|
|
23
71
|
|
|
24
72
|
def test_write_atomically_with_mode_releases_fd_when_fdopen_raises(
|
|
@@ -266,3 +266,44 @@ def test_has_pytest_configuration_returns_false_without_either_file(
|
|
|
266
266
|
repository_root = tmp_path / "repo"
|
|
267
267
|
repository_root.mkdir()
|
|
268
268
|
assert bugteam_preflight.has_pytest_configuration(repository_root) is False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_main_should_halt_when_env_var_lists_bugteam(
|
|
272
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
273
|
+
capsys: pytest.CaptureFixture[str],
|
|
274
|
+
) -> None:
|
|
275
|
+
"""CLAUDE_REVIEWS_DISABLED=bugteam must halt preflight with the dedicated exit code."""
|
|
276
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugteam")
|
|
277
|
+
monkeypatch.delenv("BUGTEAM_PREFLIGHT_SKIP", raising=False)
|
|
278
|
+
exit_code = bugteam_preflight.main(["--no-pytest"])
|
|
279
|
+
assert exit_code == bugteam_preflight.EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV
|
|
280
|
+
captured = capsys.readouterr()
|
|
281
|
+
assert "CLAUDE_REVIEWS_DISABLED" in captured.err
|
|
282
|
+
assert "bugteam" in captured.err
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_main_should_continue_when_env_var_omits_bugteam(
|
|
286
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
287
|
+
tmp_path: Path,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""CLAUDE_REVIEWS_DISABLED without the bugteam token must not halt preflight."""
|
|
290
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot,bugbot")
|
|
291
|
+
monkeypatch.delenv("BUGTEAM_PREFLIGHT_SKIP", raising=False)
|
|
292
|
+
claude_hooks_path = tmp_path / ".claude" / "hooks" / "git-hooks"
|
|
293
|
+
claude_hooks_path.mkdir(parents=True)
|
|
294
|
+
with patch("subprocess.run") as mock_run:
|
|
295
|
+
mock_run.return_value = _make_completed_process(
|
|
296
|
+
str(claude_hooks_path) + "\n", returncode=0
|
|
297
|
+
)
|
|
298
|
+
exit_code = bugteam_preflight.main(["--no-pytest"])
|
|
299
|
+
assert exit_code != bugteam_preflight.EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_main_should_halt_when_env_var_contains_uppercase_or_whitespace_bugteam_token(
|
|
303
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Token matching must be case-insensitive and whitespace-tolerant."""
|
|
306
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", " BugTeam , copilot ")
|
|
307
|
+
monkeypatch.delenv("BUGTEAM_PREFLIGHT_SKIP", raising=False)
|
|
308
|
+
exit_code = bugteam_preflight.main(["--no-pytest"])
|
|
309
|
+
assert exit_code == bugteam_preflight.EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV
|