claude-dev-env 2.0.2 → 2.2.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/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_shared.py +126 -47
- package/hooks/blocking/config/CLAUDE.md +2 -0
- package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
- package/hooks/blocking/conftest.py +36 -30
- package/hooks/blocking/convergence_gate_blocker.py +76 -8
- package/hooks/blocking/plain_language_blocker.py +8 -0
- package/hooks/blocking/state_description_blocker.py +4 -1
- package/hooks/blocking/test_code_rules_shared.py +120 -20
- package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
- package/hooks/blocking/test_plain_language_blocker.py +15 -0
- package/hooks/blocking/test_state_description_blocker.py +15 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
- package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
- package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
- package/hooks/blocking/verified_commit_gate.py +113 -568
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
- package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
- package/hooks/validators/mypy_integration.py +145 -24
- package/hooks/validators/python_antipattern_checks.py +16 -0
- package/hooks/validators/run_all_validators.py +9 -3
- package/hooks/validators/test_mypy_integration.py +154 -0
- package/hooks/validators/test_python_antipattern_checks.py +112 -1
- package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/verified-commit-gate-skip.md +28 -0
- package/skills/_shared/pr-loop/CLAUDE.md +18 -13
- package/skills/_shared/pr-loop/portable-driver.md +150 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
- package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
- package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
- package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
- package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
- package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
- package/skills/auditing-claude-config/CLAUDE.md +2 -1
- package/skills/auditing-claude-config/SKILL.md +114 -176
- package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
- package/skills/autoconverge/CLAUDE.md +6 -3
- package/skills/autoconverge/SKILL.md +421 -346
- package/skills/autoconverge/reference/CLAUDE.md +1 -0
- package/skills/autoconverge/reference/convergence.md +5 -5
- package/skills/autoconverge/reference/copilot-findings.md +51 -0
- package/skills/autoconverge/reference/multi-pr.md +33 -2
- package/skills/autoconverge/reference/stop-conditions.md +9 -6
- package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
- package/skills/closeout/SKILL.md +7 -9
- package/skills/copilot-finding-triage/SKILL.md +21 -11
- package/skills/copilot-review/CLAUDE.md +3 -2
- package/skills/copilot-review/SKILL.md +119 -155
- package/skills/copilot-review/templates/subagent-prompt.md +49 -0
- package/skills/fresh-branch/CLAUDE.md +6 -9
- package/skills/fresh-branch/SKILL.md +84 -33
- package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
- package/skills/pr-converge/CLAUDE.md +4 -3
- package/skills/pr-converge/SKILL.md +469 -422
- package/skills/pr-converge/reference/CLAUDE.md +1 -0
- package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
- package/skills/pr-converge/reference/per-tick.md +51 -24
- package/skills/pr-converge/reference/progress-checklist.md +168 -0
- package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
- package/skills/pr-converge/test_step5_host_branch.py +8 -6
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -115
- package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
- package/skills/session-log/CLAUDE.md +2 -1
- package/skills/session-log/SKILL.md +4 -26
- package/skills/session-log/templates/frontmatter.md +40 -0
- package/skills/skill-builder/CLAUDE.md +8 -7
- package/skills/skill-builder/SKILL.md +26 -11
- package/skills/skill-builder/references/CLAUDE.md +3 -1
- package/skills/skill-builder/references/delegation-map.md +21 -12
- package/skills/skill-builder/references/description-field.md +9 -11
- package/skills/skill-builder/references/deterministic-elements.md +218 -0
- package/skills/skill-builder/references/self-audit-checklist.md +62 -45
- package/skills/skill-builder/references/skill-modularity.md +8 -9
- package/skills/skill-builder/templates/CLAUDE.md +2 -2
- package/skills/skill-builder/templates/gap-analysis.md +15 -0
- package/skills/skill-builder/workflows/CLAUDE.md +5 -5
- package/skills/skill-builder/workflows/improve-skill.md +18 -9
- package/skills/skill-builder/workflows/new-skill.md +23 -15
- package/skills/skill-builder/workflows/polish-skill.md +28 -21
|
@@ -12,6 +12,9 @@ SHARED_MODULE_PATH = Path(__file__).parent / "code_rules_shared.py"
|
|
|
12
12
|
FIXED_USER_ID = 4242
|
|
13
13
|
WORKING_DIRECTORY = "/home/user/project"
|
|
14
14
|
SESSION_ID = "session-abc-123"
|
|
15
|
+
HARNESS_USER_DIRECTORY_WINDOWS = "claude"
|
|
16
|
+
WINDOWS_MANGLED_WORKING_DIRECTORY = "c--Users-dev--claude-project"
|
|
17
|
+
SESSION_ID_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_SESSION_ID"
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
def _load_shared_module() -> ModuleType:
|
|
@@ -27,10 +30,6 @@ def _load_shared_module() -> ModuleType:
|
|
|
27
30
|
_SHARED_MODULE = _load_shared_module()
|
|
28
31
|
|
|
29
32
|
|
|
30
|
-
def _install_fixed_user_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
31
|
-
monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
|
|
32
|
-
|
|
33
|
-
|
|
34
33
|
def _point_temporary_directory_at(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
|
|
35
34
|
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
|
|
36
35
|
|
|
@@ -48,6 +47,24 @@ def _build_scratchpad_directory(temporary_root: Path) -> Path:
|
|
|
48
47
|
return scratchpad_directory
|
|
49
48
|
|
|
50
49
|
|
|
50
|
+
def _build_windows_scratchpad_directory(temporary_root: Path) -> Path:
|
|
51
|
+
"""Build the Windows-shaped scratchpad directory that carries no user id segment."""
|
|
52
|
+
scratchpad_directory = (
|
|
53
|
+
temporary_root
|
|
54
|
+
/ HARNESS_USER_DIRECTORY_WINDOWS
|
|
55
|
+
/ WINDOWS_MANGLED_WORKING_DIRECTORY
|
|
56
|
+
/ SESSION_ID
|
|
57
|
+
/ "scratchpad"
|
|
58
|
+
)
|
|
59
|
+
scratchpad_directory.mkdir(parents=True)
|
|
60
|
+
return scratchpad_directory
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _simulate_windows_platform(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
|
|
64
|
+
monkeypatch.delattr(os, "getuid", raising=False)
|
|
65
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
|
|
66
|
+
|
|
67
|
+
|
|
51
68
|
def _session_payload() -> dict[str, str]:
|
|
52
69
|
return {"cwd": WORKING_DIRECTORY, "session_id": SESSION_ID}
|
|
53
70
|
|
|
@@ -55,7 +72,6 @@ def _session_payload() -> dict[str, str]:
|
|
|
55
72
|
def test_returns_true_for_file_inside_reconstructed_scratchpad(
|
|
56
73
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
57
74
|
) -> None:
|
|
58
|
-
_install_fixed_user_id(monkeypatch)
|
|
59
75
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
60
76
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
61
77
|
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
@@ -69,7 +85,6 @@ def test_returns_true_for_file_inside_reconstructed_scratchpad(
|
|
|
69
85
|
def test_returns_false_for_file_outside_scratchpad(
|
|
70
86
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
71
87
|
) -> None:
|
|
72
|
-
_install_fixed_user_id(monkeypatch)
|
|
73
88
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
74
89
|
_build_scratchpad_directory(tmp_path)
|
|
75
90
|
project_module = tmp_path / "elsewhere" / "orders.py"
|
|
@@ -82,7 +97,6 @@ def test_returns_false_for_file_outside_scratchpad(
|
|
|
82
97
|
def test_resolves_symlink_into_scratchpad_to_true(
|
|
83
98
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
84
99
|
) -> None:
|
|
85
|
-
_install_fixed_user_id(monkeypatch)
|
|
86
100
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
87
101
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
88
102
|
real_script = scratchpad_directory / "real_tool.py"
|
|
@@ -96,7 +110,6 @@ def test_resolves_symlink_into_scratchpad_to_true(
|
|
|
96
110
|
def test_symlink_resolving_outside_scratchpad_is_false(
|
|
97
111
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
98
112
|
) -> None:
|
|
99
|
-
_install_fixed_user_id(monkeypatch)
|
|
100
113
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
101
114
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
102
115
|
real_target_outside = tmp_path / "outside_target.py"
|
|
@@ -113,8 +126,8 @@ def test_symlink_resolving_outside_scratchpad_is_false(
|
|
|
113
126
|
def test_missing_session_id_signal_returns_false(
|
|
114
127
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
115
128
|
) -> None:
|
|
116
|
-
_install_fixed_user_id(monkeypatch)
|
|
117
129
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
130
|
+
monkeypatch.delenv(SESSION_ID_ENVIRONMENT_VARIABLE, raising=False)
|
|
118
131
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
119
132
|
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
120
133
|
payload_without_session = {"cwd": WORKING_DIRECTORY}
|
|
@@ -125,10 +138,12 @@ def test_missing_session_id_signal_returns_false(
|
|
|
125
138
|
)
|
|
126
139
|
|
|
127
140
|
|
|
128
|
-
def
|
|
141
|
+
def test_exempt_without_working_directory_signal(
|
|
129
142
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
130
143
|
) -> None:
|
|
131
|
-
|
|
144
|
+
"""The platform-safe predicate keys on the session id and the temp-directory path
|
|
145
|
+
shape, so a payload carrying the session id alone still exempts a real scratchpad
|
|
146
|
+
target even when it omits the working directory."""
|
|
132
147
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
133
148
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
134
149
|
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
@@ -136,27 +151,28 @@ def test_missing_working_directory_signal_returns_false(
|
|
|
136
151
|
|
|
137
152
|
assert (
|
|
138
153
|
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_cwd)
|
|
139
|
-
is
|
|
154
|
+
is True
|
|
140
155
|
)
|
|
141
156
|
|
|
142
157
|
|
|
143
|
-
def
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
158
|
+
def test_exempt_on_windows_without_getuid(
|
|
159
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
160
|
+
) -> None:
|
|
161
|
+
"""On Windows the os.getuid attribute is absent, and a real scratchpad target still
|
|
162
|
+
resolves as exempt through its temp-directory path shape and session id."""
|
|
163
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
164
|
+
scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
|
|
147
165
|
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
148
|
-
monkeypatch.delattr(os, "getuid", raising=False)
|
|
149
166
|
|
|
150
167
|
assert (
|
|
151
168
|
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
|
|
152
|
-
is
|
|
169
|
+
is True
|
|
153
170
|
)
|
|
154
171
|
|
|
155
172
|
|
|
156
173
|
def test_nonexistent_scratchpad_directory_returns_false(
|
|
157
174
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
158
175
|
) -> None:
|
|
159
|
-
_install_fixed_user_id(monkeypatch)
|
|
160
176
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
161
177
|
mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
|
|
162
178
|
unbuilt_target = (
|
|
@@ -174,8 +190,92 @@ def test_nonexistent_scratchpad_directory_returns_false(
|
|
|
174
190
|
|
|
175
191
|
|
|
176
192
|
def test_empty_file_path_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
177
|
-
_install_fixed_user_id(monkeypatch)
|
|
178
193
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
179
194
|
_build_scratchpad_directory(tmp_path)
|
|
180
195
|
|
|
181
196
|
assert _SHARED_MODULE.is_under_session_scratchpad("", _session_payload()) is False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_windows_shape_repository_file_is_not_exempt(
|
|
200
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
201
|
+
) -> None:
|
|
202
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
203
|
+
_build_windows_scratchpad_directory(tmp_path)
|
|
204
|
+
repository_module = tmp_path / "repository" / "src" / "orders.py"
|
|
205
|
+
repository_module.parent.mkdir(parents=True)
|
|
206
|
+
|
|
207
|
+
assert (
|
|
208
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(repository_module), _session_payload())
|
|
209
|
+
is False
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_temp_path_outside_scratchpad_shape_is_not_exempt(
|
|
214
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
215
|
+
) -> None:
|
|
216
|
+
"""A path under the harness user directory but off the session/scratchpad shape
|
|
217
|
+
keeps full enforcement."""
|
|
218
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
219
|
+
_build_windows_scratchpad_directory(tmp_path)
|
|
220
|
+
off_shape_target = tmp_path / HARNESS_USER_DIRECTORY_WINDOWS / "unrelated" / "notes.py"
|
|
221
|
+
off_shape_target.parent.mkdir(parents=True)
|
|
222
|
+
|
|
223
|
+
assert (
|
|
224
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(off_shape_target), _session_payload())
|
|
225
|
+
is False
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_repository_path_containing_scratchpad_word_is_not_exempt(
|
|
230
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
231
|
+
) -> None:
|
|
232
|
+
"""A repository path outside the temp directory that merely carries a scratchpad
|
|
233
|
+
segment keeps full enforcement."""
|
|
234
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
235
|
+
_build_windows_scratchpad_directory(tmp_path)
|
|
236
|
+
repository_lookalike = tmp_path.parent / "project_repo" / "scratchpad" / "tool.py"
|
|
237
|
+
|
|
238
|
+
assert (
|
|
239
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(repository_lookalike), _session_payload())
|
|
240
|
+
is False
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_is_ephemeral_path_true_for_windows_scratchpad(
|
|
245
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
246
|
+
) -> None:
|
|
247
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
248
|
+
scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
|
|
249
|
+
throwaway_script = scratchpad_directory / "probe.py"
|
|
250
|
+
|
|
251
|
+
assert _SHARED_MODULE.is_ephemeral_path(str(throwaway_script), _session_payload()) is True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_is_ephemeral_path_true_for_root_anchored_tmp(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
255
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
256
|
+
|
|
257
|
+
assert _SHARED_MODULE.is_ephemeral_path("/tmp/scratch.py") is True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_is_ephemeral_path_false_for_repository_file(
|
|
261
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
262
|
+
) -> None:
|
|
263
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
264
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
265
|
+
repository_file = tmp_path / "repository" / "orders.py"
|
|
266
|
+
repository_file.parent.mkdir(parents=True)
|
|
267
|
+
|
|
268
|
+
assert _SHARED_MODULE.is_ephemeral_path(str(repository_file), _session_payload()) is False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_is_ephemeral_path_reads_session_id_from_environment(
|
|
272
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
273
|
+
) -> None:
|
|
274
|
+
"""With no payload, the session scratchpad match reads the session id from the
|
|
275
|
+
harness environment variable, so a caller that holds no payload still matches."""
|
|
276
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
277
|
+
monkeypatch.setenv(SESSION_ID_ENVIRONMENT_VARIABLE, SESSION_ID)
|
|
278
|
+
scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
|
|
279
|
+
throwaway_script = scratchpad_directory / "probe.py"
|
|
280
|
+
|
|
281
|
+
assert _SHARED_MODULE.is_ephemeral_path(str(throwaway_script)) is True
|
|
@@ -132,3 +132,149 @@ def test_main_reads_cwd_from_top_level_payload(
|
|
|
132
132
|
hook_module.main()
|
|
133
133
|
|
|
134
134
|
assert captured_cwd == [worktree_path]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _capture_convergence_identity(
|
|
138
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
139
|
+
tmp_path: pathlib.Path,
|
|
140
|
+
command: str,
|
|
141
|
+
cwd_owner_repo: tuple[str, str],
|
|
142
|
+
) -> tuple[str, str, int]:
|
|
143
|
+
convergence_script = (
|
|
144
|
+
tmp_path / ".claude" / "skills" / "pr-converge" / "scripts" / "check_convergence.py"
|
|
145
|
+
)
|
|
146
|
+
convergence_script.parent.mkdir(parents=True)
|
|
147
|
+
convergence_script.write_text("")
|
|
148
|
+
monkeypatch.setattr(hook_module.Path, "home", classmethod(lambda _cls: tmp_path))
|
|
149
|
+
monkeypatch.setattr(hook_module, "_resolve_owner_repo", lambda _cwd: cwd_owner_repo)
|
|
150
|
+
|
|
151
|
+
captured_identity: list[tuple[str, str, int]] = []
|
|
152
|
+
|
|
153
|
+
def fake_check(
|
|
154
|
+
_script: str, owner: str, repo: str, pr_number: int, _cwd: str | None
|
|
155
|
+
) -> subprocess.CompletedProcess[str]:
|
|
156
|
+
captured_identity.append((owner, repo, pr_number))
|
|
157
|
+
return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
158
|
+
|
|
159
|
+
monkeypatch.setattr(hook_module, "_run_convergence_check", fake_check)
|
|
160
|
+
|
|
161
|
+
payload = {
|
|
162
|
+
"tool_name": "Bash",
|
|
163
|
+
"cwd": str(tmp_path / "worktree"),
|
|
164
|
+
"tool_input": {"command": command},
|
|
165
|
+
}
|
|
166
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
167
|
+
with pytest.raises(SystemExit):
|
|
168
|
+
hook_module.main()
|
|
169
|
+
return captured_identity[0]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_repo_flag_overrides_cwd_repo(
|
|
173
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
174
|
+
) -> None:
|
|
175
|
+
identity = _capture_convergence_identity(
|
|
176
|
+
monkeypatch,
|
|
177
|
+
tmp_path,
|
|
178
|
+
"gh pr ready 161 --repo sample-owner/target-repo",
|
|
179
|
+
("cwd-owner", "cwd-repo"),
|
|
180
|
+
)
|
|
181
|
+
assert identity == ("sample-owner", "target-repo", 161)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_pr_url_yields_repo_and_number(
|
|
185
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
186
|
+
) -> None:
|
|
187
|
+
identity = _capture_convergence_identity(
|
|
188
|
+
monkeypatch,
|
|
189
|
+
tmp_path,
|
|
190
|
+
"gh pr ready https://github.com/sample-owner/target-repo/pull/161",
|
|
191
|
+
("cwd-owner", "cwd-repo"),
|
|
192
|
+
)
|
|
193
|
+
assert identity == ("sample-owner", "target-repo", 161)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_bare_pr_ready_resolves_repo_from_cwd(
|
|
197
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
198
|
+
) -> None:
|
|
199
|
+
identity = _capture_convergence_identity(
|
|
200
|
+
monkeypatch,
|
|
201
|
+
tmp_path,
|
|
202
|
+
"gh pr ready 418",
|
|
203
|
+
("cwd-owner", "cwd-repo"),
|
|
204
|
+
)
|
|
205
|
+
assert identity == ("cwd-owner", "cwd-repo", 418)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_parse_repo_flag_space_form() -> None:
|
|
209
|
+
assert hook_module._parse_repo_flag("gh pr ready 161 --repo sample-owner/target-repo") == (
|
|
210
|
+
"sample-owner",
|
|
211
|
+
"target-repo",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_parse_repo_flag_equals_form() -> None:
|
|
216
|
+
assert hook_module._parse_repo_flag("gh pr ready 161 --repo=sample-owner/target-repo") == (
|
|
217
|
+
"sample-owner",
|
|
218
|
+
"target-repo",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_parse_repo_flag_short_alias() -> None:
|
|
223
|
+
assert hook_module._parse_repo_flag("gh pr ready 161 -R sample-owner/target-repo") == (
|
|
224
|
+
"sample-owner",
|
|
225
|
+
"target-repo",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_parse_repo_flag_absent_returns_none() -> None:
|
|
230
|
+
assert hook_module._parse_repo_flag("gh pr ready 418") is None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_parse_pr_url_yields_owner_repo_number() -> None:
|
|
234
|
+
assert hook_module._parse_pr_url(
|
|
235
|
+
"gh pr ready https://github.com/sample-owner/target-repo/pull/161"
|
|
236
|
+
) == ("sample-owner", "target-repo", 161)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_parse_pr_url_absent_returns_none() -> None:
|
|
240
|
+
assert hook_module._parse_pr_url("gh pr ready 418") is None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_ready_segment_clips_at_command_separator() -> None:
|
|
244
|
+
assert (
|
|
245
|
+
hook_module._ready_command_segment(
|
|
246
|
+
"gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo"
|
|
247
|
+
)
|
|
248
|
+
== "gh pr ready 161 "
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_chained_repo_flag_does_not_misbind_the_gate(
|
|
253
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
254
|
+
) -> None:
|
|
255
|
+
identity = _capture_convergence_identity(
|
|
256
|
+
monkeypatch,
|
|
257
|
+
tmp_path,
|
|
258
|
+
"gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo --body-file note.md",
|
|
259
|
+
("cwd-owner", "cwd-repo"),
|
|
260
|
+
)
|
|
261
|
+
assert identity == ("cwd-owner", "cwd-repo", 161)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_chained_pr_url_does_not_misbind_the_gate(
|
|
265
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
266
|
+
) -> None:
|
|
267
|
+
identity = _capture_convergence_identity(
|
|
268
|
+
monkeypatch,
|
|
269
|
+
tmp_path,
|
|
270
|
+
"gh pr ready 161 && echo https://github.com/other-owner/other-repo/pull/5",
|
|
271
|
+
("cwd-owner", "cwd-repo"),
|
|
272
|
+
)
|
|
273
|
+
assert identity == ("cwd-owner", "cwd-repo", 161)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_ready_segment_skips_a_leading_undo_invocation() -> None:
|
|
277
|
+
assert (
|
|
278
|
+
hook_module._ready_command_segment("gh pr ready --undo && gh pr ready 161")
|
|
279
|
+
== "gh pr ready 161"
|
|
280
|
+
)
|
|
@@ -14,6 +14,8 @@ import sys
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from unittest.mock import patch
|
|
16
16
|
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
17
19
|
HOOK_SCRIPT_PATH = Path(__file__).parent / "plain_language_blocker.py"
|
|
18
20
|
_HOOKS_DIR = str(Path(__file__).resolve().parent)
|
|
19
21
|
_HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
|
|
@@ -185,6 +187,19 @@ def test_write_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
|
|
|
185
187
|
assert _decision_from(completed) == "deny"
|
|
186
188
|
|
|
187
189
|
|
|
190
|
+
def test_write_markdown_at_ephemeral_path_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
191
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
192
|
+
payload = {
|
|
193
|
+
"tool_name": "Write",
|
|
194
|
+
"tool_input": {
|
|
195
|
+
"file_path": "/tmp/notes.md",
|
|
196
|
+
"content": "This guide explains how to utilize the new cache layer.",
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
completed = _run_hook_with_payload(payload)
|
|
200
|
+
assert _decision_from(completed) is None
|
|
201
|
+
|
|
202
|
+
|
|
188
203
|
def test_write_non_markdown_is_ignored(tmp_path: Path) -> None:
|
|
189
204
|
target = tmp_path / "notes.txt"
|
|
190
205
|
payload = {
|
|
@@ -7,6 +7,8 @@ import sys
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from unittest.mock import patch
|
|
9
9
|
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
10
12
|
_BLOCKING_DIR = str(Path(__file__).resolve().parent)
|
|
11
13
|
_HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
|
|
12
14
|
if _BLOCKING_DIR not in sys.path:
|
|
@@ -207,6 +209,19 @@ def test_detects_now_uses_in_markdown():
|
|
|
207
209
|
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
208
210
|
|
|
209
211
|
|
|
212
|
+
def test_markdown_at_ephemeral_path_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
213
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
214
|
+
result = _run_hook(
|
|
215
|
+
"Write",
|
|
216
|
+
{
|
|
217
|
+
"file_path": "/tmp/api.md",
|
|
218
|
+
"content": VIOLATION_MD_INSTEAD,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
assert result.returncode == 0
|
|
222
|
+
assert result.stdout == ""
|
|
223
|
+
|
|
224
|
+
|
|
210
225
|
def test_detects_edit_new_string():
|
|
211
226
|
result = _run_hook(
|
|
212
227
|
"Edit",
|
|
@@ -31,6 +31,24 @@ def test_register_binds_dotted_name_to_sibling_constants_file(
|
|
|
31
31
|
assert detached_head_line == "HEAD"
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def test_register_binds_gate_output_constants_module(
|
|
35
|
+
run_under_config_shadow: RunUnderConfigShadow,
|
|
36
|
+
) -> None:
|
|
37
|
+
completed_probe = run_under_config_shadow(
|
|
38
|
+
"import verified_commit_config_bootstrap as bootstrap\n"
|
|
39
|
+
"bootstrap.register_verified_commit_constants()\n"
|
|
40
|
+
"import config.verified_commit_gate_output_constants as loaded\n"
|
|
41
|
+
"print(loaded.__file__)\n"
|
|
42
|
+
"print(loaded.DENY_PERMISSION_DECISION)\n"
|
|
43
|
+
)
|
|
44
|
+
assert completed_probe.returncode == 0, completed_probe.stderr
|
|
45
|
+
resolved_file_line, deny_decision_line = completed_probe.stdout.splitlines()[:2]
|
|
46
|
+
assert resolved_file_line.replace("\\", "/").endswith(
|
|
47
|
+
"blocking/config/verified_commit_gate_output_constants.py"
|
|
48
|
+
)
|
|
49
|
+
assert deny_decision_line == "deny"
|
|
50
|
+
|
|
51
|
+
|
|
34
52
|
def test_register_leaves_an_already_cached_module_untouched(
|
|
35
53
|
run_under_config_shadow: RunUnderConfigShadow,
|
|
36
54
|
) -> None:
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The verified-commit gate's deny payload teaches the verify-skip policy.
|
|
2
|
+
|
|
3
|
+
A blocked commit prints a PreToolUse ``hookSpecificOutput`` deny whose
|
|
4
|
+
``additionalContext`` carries the ``# verify-skip`` usage rule, so the agent
|
|
5
|
+
learns at the moment of the block when the marker is legitimate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
18
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
20
|
+
|
|
21
|
+
gate_spec = importlib.util.spec_from_file_location(
|
|
22
|
+
"verified_commit_gate",
|
|
23
|
+
_HOOK_DIR / "verified_commit_gate.py",
|
|
24
|
+
)
|
|
25
|
+
assert gate_spec is not None
|
|
26
|
+
assert gate_spec.loader is not None
|
|
27
|
+
gate_module = importlib.util.module_from_spec(gate_spec)
|
|
28
|
+
gate_spec.loader.exec_module(gate_module)
|
|
29
|
+
gate_main = gate_module.main
|
|
30
|
+
|
|
31
|
+
PRODUCTION_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
|
|
32
|
+
CHANGED_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_git(work_dir: pathlib.Path, *git_arguments: str) -> None:
|
|
36
|
+
subprocess.run(
|
|
37
|
+
["git", "-C", str(work_dir), *git_arguments],
|
|
38
|
+
check=True,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _make_gated_repo(tmp_path: pathlib.Path) -> pathlib.Path:
|
|
45
|
+
origin_dir = tmp_path / "origin.git"
|
|
46
|
+
work_dir = tmp_path / "work"
|
|
47
|
+
work_dir.mkdir()
|
|
48
|
+
subprocess.run(
|
|
49
|
+
["git", "init", "--bare", "--initial-branch=main", str(origin_dir)],
|
|
50
|
+
check=True,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
)
|
|
54
|
+
_run_git(work_dir, "init", "--initial-branch=main")
|
|
55
|
+
_run_git(work_dir, "config", "user.email", "tests@example.com")
|
|
56
|
+
_run_git(work_dir, "config", "user.name", "Gate Tests")
|
|
57
|
+
(work_dir / "app.py").write_text(PRODUCTION_SOURCE, encoding="utf-8")
|
|
58
|
+
_run_git(work_dir, "add", "-A")
|
|
59
|
+
_run_git(work_dir, "commit", "-m", "base")
|
|
60
|
+
_run_git(work_dir, "remote", "add", "origin", str(origin_dir))
|
|
61
|
+
_run_git(work_dir, "push", "-u", "origin", "main")
|
|
62
|
+
(work_dir / "app.py").write_text(CHANGED_SOURCE, encoding="utf-8")
|
|
63
|
+
return work_dir
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: pathlib.Path) -> None:
|
|
67
|
+
home_text = str(fake_home)
|
|
68
|
+
monkeypatch.setenv("HOME", home_text)
|
|
69
|
+
monkeypatch.setenv("USERPROFILE", home_text)
|
|
70
|
+
monkeypatch.delenv("HOMEDRIVE", raising=False)
|
|
71
|
+
monkeypatch.delenv("HOMEPATH", raising=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run_gate_main(
|
|
75
|
+
monkeypatch: pytest.MonkeyPatch, command_text: str, work_dir: pathlib.Path
|
|
76
|
+
) -> None:
|
|
77
|
+
payload_text = json.dumps(
|
|
78
|
+
{
|
|
79
|
+
"tool_name": "Bash",
|
|
80
|
+
"tool_input": {"command": command_text},
|
|
81
|
+
"cwd": str(work_dir),
|
|
82
|
+
"transcript_path": "",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(payload_text))
|
|
86
|
+
gate_main()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_deny_payload_carries_verify_skip_additional_context(
|
|
90
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
91
|
+
capsys: pytest.CaptureFixture[str],
|
|
92
|
+
tmp_path: pathlib.Path,
|
|
93
|
+
) -> None:
|
|
94
|
+
fake_home = tmp_path / "home"
|
|
95
|
+
fake_home.mkdir()
|
|
96
|
+
_isolate_home(monkeypatch, fake_home)
|
|
97
|
+
work_dir = _make_gated_repo(tmp_path)
|
|
98
|
+
_run_gate_main(monkeypatch, "git commit -m x", work_dir)
|
|
99
|
+
deny_payload = json.loads(capsys.readouterr().out)
|
|
100
|
+
hook_specific_output = deny_payload["hookSpecificOutput"]
|
|
101
|
+
assert hook_specific_output["permissionDecision"] == "deny"
|
|
102
|
+
additional_context = hook_specific_output["additionalContext"]
|
|
103
|
+
assert "# verify-skip" in additional_context
|
|
104
|
+
assert "already passed clean" in additional_context
|
|
105
|
+
assert "fresh verification" in additional_context
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_marker_inside_a_quoted_message_still_denies(
|
|
109
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
110
|
+
capsys: pytest.CaptureFixture[str],
|
|
111
|
+
tmp_path: pathlib.Path,
|
|
112
|
+
) -> None:
|
|
113
|
+
fake_home = tmp_path / "home"
|
|
114
|
+
fake_home.mkdir()
|
|
115
|
+
_isolate_home(monkeypatch, fake_home)
|
|
116
|
+
work_dir = _make_gated_repo(tmp_path)
|
|
117
|
+
quoted_marker_command = 'git commit -m "docs: explain the # verify-skip escape hatch"'
|
|
118
|
+
_run_gate_main(monkeypatch, quoted_marker_command, work_dir)
|
|
119
|
+
deny_payload = json.loads(capsys.readouterr().out)
|
|
120
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_marker_as_a_trailing_comment_bypasses(
|
|
124
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
125
|
+
capsys: pytest.CaptureFixture[str],
|
|
126
|
+
tmp_path: pathlib.Path,
|
|
127
|
+
) -> None:
|
|
128
|
+
fake_home = tmp_path / "home"
|
|
129
|
+
fake_home.mkdir()
|
|
130
|
+
_isolate_home(monkeypatch, fake_home)
|
|
131
|
+
work_dir = _make_gated_repo(tmp_path)
|
|
132
|
+
trailing_marker_command = 'git commit -m "wire up feature" # verify-skip'
|
|
133
|
+
_run_gate_main(monkeypatch, trailing_marker_command, work_dir)
|
|
134
|
+
assert capsys.readouterr().out == ""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Entry-boundary behavior of the verified_commit_gate hook process.
|
|
2
|
+
|
|
3
|
+
Runs the hook as a subprocess the way Claude Code does and pins the two
|
|
4
|
+
silent paths: a tool the gate does not cover, and a gated command carrying
|
|
5
|
+
the bypass marker.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import pathlib
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
_BLOCKING_DIRECTORY = pathlib.Path(__file__).resolve().parent.parent
|
|
14
|
+
_GATE_SCRIPT_PATH = _BLOCKING_DIRECTORY / "verified_commit_gate.py"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_gate_process(tool_name: str, command_text: str) -> str:
|
|
18
|
+
payload_text = json.dumps(
|
|
19
|
+
{
|
|
20
|
+
"tool_name": tool_name,
|
|
21
|
+
"tool_input": {"command": command_text},
|
|
22
|
+
"cwd": str(_BLOCKING_DIRECTORY),
|
|
23
|
+
"transcript_path": "",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
completed_process = subprocess.run(
|
|
27
|
+
[sys.executable, str(_GATE_SCRIPT_PATH)],
|
|
28
|
+
input=payload_text,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
check=True,
|
|
32
|
+
)
|
|
33
|
+
return completed_process.stdout
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_uncovered_tool_name_stays_silent() -> None:
|
|
37
|
+
assert _run_gate_process("Read", "git commit -m x") == ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_bypass_marker_stays_silent_at_the_process_boundary() -> None:
|
|
41
|
+
assert _run_gate_process("Bash", "git commit -m x # verify-skip") == ""
|