claude-dev-env 8.97.0 → 8.98.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.
Files changed (35) hide show
  1. package/bin/install.prompt-context.test.mjs +3 -2
  2. package/bin/install.settings-defaults.test.mjs +4 -3
  3. package/docs/agents-guidance/asks.md +6 -1
  4. package/hooks/blocking/open_tasks_stop_gate.py +128 -34
  5. package/hooks/blocking/open_tasks_transcript.py +112 -5
  6. package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
  7. package/hooks/blocking/python_windows_invocation_gate.py +249 -0
  8. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  9. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  10. package/hooks/blocking/test_open_tasks_stop_gate.py +202 -0
  11. package/hooks/blocking/test_open_tasks_transcript.py +89 -0
  12. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
  13. package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
  14. package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
  15. package/hooks/blocking/verify_before_commit_gate.py +278 -0
  16. package/hooks/features/bash-dispatchers.md +3 -0
  17. package/hooks/features/conduct-gates.md +10 -2
  18. package/hooks/hooks.json +15 -0
  19. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  20. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
  21. package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
  22. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  23. package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
  24. package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
  25. package/hooks/open_task_asks.py +163 -0
  26. package/hooks/session/open_task_ask_recorder.py +38 -0
  27. package/hooks/session/test_open_task_ask_recorder.py +57 -0
  28. package/hooks/test_open_task_asks.py +52 -0
  29. package/hooks/test_transcript_skill_scan.py +24 -0
  30. package/hooks/transcript_skill_scan.py +54 -3
  31. package/package.json +1 -1
  32. package/scripts/policy_lint/adapter_configuration.py +5 -0
  33. package/scripts/policy_lint/config/constants.py +1 -0
  34. package/scripts/tests/test_adapter_configuration.py +8 -0
  35. package/settings.json +2 -1
@@ -0,0 +1,181 @@
1
+ """Tests for python_windows_invocation_gate, which stops two Windows Python failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from io import StringIO
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ import python_windows_invocation_gate as gate
12
+
13
+
14
+ def _stdout_from_main(payload: dict[str, object]) -> str:
15
+ """Return what main() wrote to stdout for one hook payload."""
16
+ captured_stdout = StringIO()
17
+ with (
18
+ patch("sys.stdin", StringIO(json.dumps(payload))),
19
+ patch("sys.stdout", captured_stdout),
20
+ patch.object(gate, "log_hook_block"),
21
+ ):
22
+ gate.main()
23
+ return captured_stdout.getvalue()
24
+
25
+
26
+ @pytest.fixture
27
+ def windows_host(monkeypatch: pytest.MonkeyPatch) -> None:
28
+ """Make the gate read the host as Windows."""
29
+ monkeypatch.setattr(gate, "is_windows_host", lambda: True)
30
+
31
+
32
+ @pytest.fixture
33
+ def linux_host(monkeypatch: pytest.MonkeyPatch) -> None:
34
+ """Make the gate read the host as Linux."""
35
+ monkeypatch.setattr(gate, "is_windows_host", lambda: False)
36
+
37
+
38
+ @pytest.mark.parametrize(
39
+ "command",
40
+ [
41
+ "python -I script.py",
42
+ "python3 -I -c pass",
43
+ "py -3 -I script.py",
44
+ "python3.12.exe -I -m json.tool data.json",
45
+ "python -Iu script.py",
46
+ "python -Ic pass",
47
+ "python -I -X dev script.py",
48
+ "python -I -X utf8=0 script.py",
49
+ "cd repo && python -I script.py",
50
+ "git status; python -I script.py | tee out.txt",
51
+ "bash -c 'python -I script.py'",
52
+ "python --check-hash-based-pycs always -I script.py",
53
+ ],
54
+ )
55
+ def test_should_flag_isolated_python_without_utf8_mode(command: str) -> None:
56
+ assert gate.runs_isolated_python_without_utf8_mode(command) is True
57
+
58
+
59
+ @pytest.mark.parametrize(
60
+ "command",
61
+ [
62
+ "python -I -X utf8 script.py",
63
+ "python -IX utf8 script.py",
64
+ "python -IXutf8 script.py",
65
+ "python -X utf8 -I script.py",
66
+ "python -I -X utf8=1 -c pass",
67
+ "py -3 -I -X utf8 script.py",
68
+ "python -I --check-hash-based-pycs always -X utf8 script.py",
69
+ "python script.py -I",
70
+ "python -c pass -I",
71
+ "python -m pytest -I",
72
+ "python script.py",
73
+ "pip install -I package",
74
+ 'echo "python -I script.py"',
75
+ ],
76
+ )
77
+ def test_should_pass_python_with_utf8_mode_or_without_isolated_mode(
78
+ command: str,
79
+ ) -> None:
80
+ assert gate.runs_isolated_python_without_utf8_mode(command) is False
81
+
82
+
83
+ @pytest.mark.parametrize(
84
+ "command",
85
+ [
86
+ 'python -X utf8 -c "import json; print(\\"select 1\\")"',
87
+ 'python -c "print(\\"x\\")"',
88
+ 'py -3 -c "print(\\"x\\")"',
89
+ 'python3.exe -I -X utf8 -c "print(\\"x\\")"',
90
+ '& python -c "print(\\"x\\")"',
91
+ 'Set-Location repo; python -c "print(\\"x\\")"',
92
+ ],
93
+ )
94
+ def test_should_flag_a_backslash_quote_inside_a_python_code_argument(
95
+ command: str,
96
+ ) -> None:
97
+ assert gate.has_backslash_quote_in_python_code_argument(command) is True
98
+
99
+
100
+ @pytest.mark.parametrize(
101
+ "command",
102
+ [
103
+ "python -c \"print('x')\"",
104
+ "python -c 'print(\"x\")'",
105
+ 'python script.py "a\\"b"',
106
+ 'python -c "print(1)"; Write-Output "a\\"b"',
107
+ 'Write-Output "python -c"',
108
+ 'copy -c "a\\"b"',
109
+ ],
110
+ )
111
+ def test_should_pass_a_python_code_argument_without_a_backslash_quote(
112
+ command: str,
113
+ ) -> None:
114
+ assert gate.has_backslash_quote_in_python_code_argument(command) is False
115
+
116
+
117
+ def test_should_deny_isolated_python_on_windows_and_name_the_utf8_flag(
118
+ windows_host: None,
119
+ ) -> None:
120
+ decision = json.loads(
121
+ _stdout_from_main(
122
+ {"tool_name": "Bash", "tool_input": {"command": "python -I s.py"}}
123
+ )
124
+ )
125
+ specific_output = decision["hookSpecificOutput"]
126
+ assert specific_output["permissionDecision"] == "deny"
127
+ assert "python -I -X utf8" in specific_output["permissionDecisionReason"]
128
+
129
+
130
+ def test_should_stay_quiet_for_isolated_python_on_linux(linux_host: None) -> None:
131
+ assert (
132
+ _stdout_from_main(
133
+ {"tool_name": "Bash", "tool_input": {"command": "python -I s.py"}}
134
+ )
135
+ == ""
136
+ )
137
+
138
+
139
+ def test_should_deny_a_powershell_backslash_quote_and_name_the_here_string(
140
+ linux_host: None,
141
+ ) -> None:
142
+ command = 'python -X utf8 -c "print(\\"select 1\\")"'
143
+ decision = json.loads(
144
+ _stdout_from_main(
145
+ {"tool_name": "PowerShell", "tool_input": {"command": command}}
146
+ )
147
+ )
148
+ specific_output = decision["hookSpecificOutput"]
149
+ assert specific_output["permissionDecision"] == "deny"
150
+ assert "here-string" in specific_output["permissionDecisionReason"]
151
+
152
+
153
+ def test_should_pass_a_backslash_quote_under_bash(windows_host: None) -> None:
154
+ command = 'python -X utf8 -c "print(\\"select 1\\")"'
155
+ assert (
156
+ _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": command}})
157
+ == ""
158
+ )
159
+
160
+
161
+ def test_should_name_both_fixes_when_a_powershell_command_has_both_failures(
162
+ windows_host: None,
163
+ ) -> None:
164
+ command = 'python -I -c "print(\\"x\\")"'
165
+ decision = json.loads(
166
+ _stdout_from_main(
167
+ {"tool_name": "PowerShell", "tool_input": {"command": command}}
168
+ )
169
+ )
170
+ reason = decision["hookSpecificOutput"]["permissionDecisionReason"]
171
+ assert "python -I -X utf8" in reason
172
+ assert "here-string" in reason
173
+
174
+
175
+ def test_should_stay_quiet_for_another_tool(windows_host: None) -> None:
176
+ assert (
177
+ _stdout_from_main(
178
+ {"tool_name": "Write", "tool_input": {"command": "python -I s.py"}}
179
+ )
180
+ == ""
181
+ )
@@ -0,0 +1,334 @@
1
+ """Behavior tests for the verify-before-commit PreToolUse gate."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from io import BytesIO, StringIO, TextIOWrapper
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ import pytest
11
+
12
+ HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
13
+ if str(HOOKS_DIRECTORY) not in sys.path:
14
+ sys.path.insert(0, str(HOOKS_DIRECTORY))
15
+
16
+ from blocking import verify_before_commit_gate as gate
17
+ from hooks_constants.verify_before_commit_gate_constants import DENY_REASON
18
+
19
+ PROMPT_ENTRY = {"type": "user", "message": {"content": "Fix the parser and commit it."}}
20
+ SLASH_PROMPT_ENTRY = {
21
+ "type": "user",
22
+ "message": {
23
+ "content": "<command-message>verify</command-message>\n<command-name>/verify</command-name>"
24
+ },
25
+ }
26
+ SKILL_BODY_ENTRY = {
27
+ "type": "user",
28
+ "isMeta": True,
29
+ "message": {
30
+ "content": [{"type": "text", "text": "Base directory for this skill: verify"}]
31
+ },
32
+ }
33
+ TOOL_RESULT_ENTRY = {
34
+ "type": "user",
35
+ "message": {
36
+ "content": [
37
+ {
38
+ "type": "tool_result",
39
+ "tool_use_id": "toolu_1",
40
+ "content": "Launching skill: verify",
41
+ }
42
+ ]
43
+ },
44
+ }
45
+
46
+
47
+ def _skill_entry(skill_name: str) -> dict[str, object]:
48
+ return {
49
+ "type": "assistant",
50
+ "message": {
51
+ "content": [
52
+ {"type": "tool_use", "name": "Skill", "input": {"skill": skill_name}}
53
+ ]
54
+ },
55
+ }
56
+
57
+
58
+ def _git(repository: Path, *all_arguments: str) -> None:
59
+ subprocess.run(
60
+ ["git", "-C", str(repository), *all_arguments], check=True, capture_output=True
61
+ )
62
+
63
+
64
+ @pytest.fixture
65
+ def repository(tmp_path: Path) -> Path:
66
+ repository_root = tmp_path / "repo"
67
+ repository_root.mkdir()
68
+ _git(repository_root, "init", "-q")
69
+ _git(repository_root, "config", "user.email", "gate@example.com")
70
+ _git(repository_root, "config", "user.name", "Gate Test")
71
+ (repository_root / ".agents" / "skills" / "verify").mkdir(parents=True)
72
+ (repository_root / ".agents" / "skills" / "verify" / "SKILL.md").write_text(
73
+ "verify\n", encoding="utf-8"
74
+ )
75
+ (repository_root / "app.py").write_text("VALUE = 1\n", encoding="utf-8")
76
+ (repository_root / "README.md").write_text("Readme\n", encoding="utf-8")
77
+ _git(repository_root, "add", "-A")
78
+ _git(repository_root, "commit", "-q", "-m", "base")
79
+ return repository_root
80
+
81
+
82
+ def _transcript(tmp_path: Path, *all_entries: dict[str, object]) -> Path:
83
+ transcript_path = tmp_path / "session.jsonl"
84
+ transcript_path.write_text(
85
+ "".join(json.dumps(each_entry) + "\n" for each_entry in all_entries),
86
+ encoding="utf-8",
87
+ )
88
+ return transcript_path
89
+
90
+
91
+ def _payload(
92
+ command: str, cwd: Path, transcript_path: Path, tool_name: str = "Bash"
93
+ ) -> dict[str, object]:
94
+ return {
95
+ "tool_name": tool_name,
96
+ "tool_input": {"command": command},
97
+ "cwd": str(cwd),
98
+ "transcript_path": str(transcript_path),
99
+ }
100
+
101
+
102
+ def _unverified(tmp_path: Path) -> Path:
103
+ return _transcript(tmp_path, PROMPT_ENTRY)
104
+
105
+
106
+ def _is_denied(payload: dict[str, object]) -> bool:
107
+ decision = gate.decision_for(payload)
108
+ if decision is None:
109
+ return False
110
+ assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
111
+ assert decision["hookSpecificOutput"]["permissionDecisionReason"] == DENY_REASON
112
+ return True
113
+
114
+
115
+ def _run_main(payload: dict[str, object]) -> tuple[int, str]:
116
+ output = StringIO()
117
+ input_stream = TextIOWrapper(
118
+ BytesIO(json.dumps(payload).encode("utf-8")), encoding="utf-8"
119
+ )
120
+ with patch("sys.stdin", input_stream), patch("sys.stdout", output):
121
+ exit_code = gate.main()
122
+ return exit_code, output.getvalue()
123
+
124
+
125
+ def test_staged_code_commit_without_verify_is_denied(
126
+ repository: Path, tmp_path: Path
127
+ ) -> None:
128
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
129
+ _git(repository, "add", "app.py")
130
+ exit_code, output = _run_main(
131
+ _payload("git commit -m change", repository, _unverified(tmp_path))
132
+ )
133
+ assert exit_code == 0
134
+ decision = json.loads(output)["hookSpecificOutput"]
135
+ assert decision["permissionDecision"] == "deny"
136
+ assert "verify" in decision["permissionDecisionReason"]
137
+
138
+
139
+ def test_same_command_git_add_of_code_is_denied_with_an_empty_index(
140
+ repository: Path, tmp_path: Path
141
+ ) -> None:
142
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
143
+ assert _is_denied(
144
+ _payload(
145
+ "git add app.py && git commit -m change", repository, _unverified(tmp_path)
146
+ )
147
+ )
148
+
149
+
150
+ def test_same_command_git_add_all_picks_up_a_new_code_file(
151
+ repository: Path, tmp_path: Path
152
+ ) -> None:
153
+ (repository / "new_module.py").write_text("NAME = 'new'\n", encoding="utf-8")
154
+ assert _is_denied(
155
+ _payload("git add -A; git commit -m add", repository, _unverified(tmp_path))
156
+ )
157
+
158
+
159
+ def test_same_command_git_add_of_docs_passes_while_code_stays_unstaged(
160
+ repository: Path, tmp_path: Path
161
+ ) -> None:
162
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
163
+ (repository / "README.md").write_text("Readme two\n", encoding="utf-8")
164
+ assert not _is_denied(
165
+ _payload(
166
+ "git add README.md; git commit -m docs", repository, _unverified(tmp_path)
167
+ )
168
+ )
169
+
170
+
171
+ def test_commit_all_flag_counts_modified_code(repository: Path, tmp_path: Path) -> None:
172
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
173
+ assert _is_denied(
174
+ _payload("git commit -am change", repository, _unverified(tmp_path))
175
+ )
176
+ assert _is_denied(
177
+ _payload("git commit --all -m change", repository, _unverified(tmp_path))
178
+ )
179
+
180
+
181
+ def test_commit_all_flag_with_only_docs_modified_passes(
182
+ repository: Path, tmp_path: Path
183
+ ) -> None:
184
+ (repository / "README.md").write_text("Readme two\n", encoding="utf-8")
185
+ assert not _is_denied(
186
+ _payload("git commit -am docs", repository, _unverified(tmp_path))
187
+ )
188
+
189
+
190
+ def test_docs_only_commit_passes(repository: Path, tmp_path: Path) -> None:
191
+ (repository / "README.md").write_text("Readme two\n", encoding="utf-8")
192
+ (repository / "docs").mkdir()
193
+ (repository / "docs" / "guide.md").write_text("Guide\n", encoding="utf-8")
194
+ _git(repository, "add", "-A")
195
+ assert not _is_denied(
196
+ _payload("git commit -m docs", repository, _unverified(tmp_path))
197
+ )
198
+
199
+
200
+ def test_tests_only_commit_passes(repository: Path, tmp_path: Path) -> None:
201
+ (repository / "test_app.py").write_text(
202
+ "def test_value() -> None:\n pass\n", encoding="utf-8"
203
+ )
204
+ (repository / "tests").mkdir()
205
+ (repository / "tests" / "helpers.py").write_text("HELPER = 1\n", encoding="utf-8")
206
+ (repository / "install.test.mjs").write_text("export {};\n", encoding="utf-8")
207
+ _git(repository, "add", "-A")
208
+ assert not _is_denied(
209
+ _payload("git commit -m tests", repository, _unverified(tmp_path))
210
+ )
211
+
212
+
213
+ def test_docs_plus_code_commit_is_denied(repository: Path, tmp_path: Path) -> None:
214
+ (repository / "README.md").write_text("Readme two\n", encoding="utf-8")
215
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
216
+ _git(repository, "add", "-A")
217
+ assert _is_denied(
218
+ _payload("git commit -m mixed", repository, _unverified(tmp_path))
219
+ )
220
+
221
+
222
+ def test_verify_skill_call_after_the_last_prompt_passes(
223
+ repository: Path, tmp_path: Path
224
+ ) -> None:
225
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
226
+ _git(repository, "add", "app.py")
227
+ for each_name in ("verify", "plugin:verify"):
228
+ transcript_path = _transcript(
229
+ tmp_path,
230
+ PROMPT_ENTRY,
231
+ _skill_entry(each_name),
232
+ TOOL_RESULT_ENTRY,
233
+ SKILL_BODY_ENTRY,
234
+ )
235
+ assert not _is_denied(
236
+ _payload("git commit -m change", repository, transcript_path)
237
+ )
238
+
239
+
240
+ def test_verify_slash_command_in_the_last_prompt_passes(
241
+ repository: Path, tmp_path: Path
242
+ ) -> None:
243
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
244
+ _git(repository, "add", "app.py")
245
+ transcript_path = _transcript(
246
+ tmp_path, PROMPT_ENTRY, SLASH_PROMPT_ENTRY, SKILL_BODY_ENTRY
247
+ )
248
+ exit_code, output = _run_main(
249
+ _payload("git commit -m change", repository, transcript_path)
250
+ )
251
+ assert (exit_code, output) == (0, "")
252
+
253
+
254
+ def test_verify_loaded_in_a_previous_turn_does_not_count(
255
+ repository: Path, tmp_path: Path
256
+ ) -> None:
257
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
258
+ _git(repository, "add", "app.py")
259
+ transcript_path = _transcript(
260
+ tmp_path, PROMPT_ENTRY, _skill_entry("verify"), SKILL_BODY_ENTRY, PROMPT_ENTRY
261
+ )
262
+ assert _is_denied(_payload("git commit -m change", repository, transcript_path))
263
+
264
+
265
+ def test_another_skill_does_not_count(repository: Path, tmp_path: Path) -> None:
266
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
267
+ _git(repository, "add", "app.py")
268
+ transcript_path = _transcript(tmp_path, PROMPT_ENTRY, _skill_entry("verify-rdp"))
269
+ assert _is_denied(_payload("git commit -m change", repository, transcript_path))
270
+
271
+
272
+ def test_repository_without_a_verify_skill_passes(
273
+ repository: Path, tmp_path: Path
274
+ ) -> None:
275
+ _git(repository, "rm", "-q", "-r", ".agents")
276
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
277
+ _git(repository, "add", "-A")
278
+ assert not _is_denied(
279
+ _payload("git commit -m change", repository, _unverified(tmp_path))
280
+ )
281
+
282
+
283
+ def test_claude_skills_verify_directory_activates_the_gate(
284
+ repository: Path, tmp_path: Path
285
+ ) -> None:
286
+ _git(repository, "rm", "-q", "-r", ".agents")
287
+ (repository / ".claude" / "skills" / "verify").mkdir(parents=True)
288
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
289
+ assert _is_denied(
290
+ _payload(
291
+ "git add app.py; git commit -m change", repository, _unverified(tmp_path)
292
+ )
293
+ )
294
+
295
+
296
+ def test_powershell_and_git_directory_option_reach_the_repository(
297
+ repository: Path, tmp_path: Path
298
+ ) -> None:
299
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
300
+ command = (
301
+ f'git -C "{repository}" add app.py; git -C "{repository}" commit -m change'
302
+ )
303
+ assert _is_denied(
304
+ _payload(command, tmp_path, _unverified(tmp_path), tool_name="PowerShell")
305
+ )
306
+
307
+
308
+ def test_commands_other_than_commit_pass(repository: Path, tmp_path: Path) -> None:
309
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
310
+ _git(repository, "add", "app.py")
311
+ for each_command in ("git status", "git push origin HEAD", "echo git commit"):
312
+ assert not _is_denied(_payload(each_command, repository, _unverified(tmp_path)))
313
+ assert (
314
+ gate.decision_for(
315
+ {"tool_name": "Write", "tool_input": {"command": "git commit"}}
316
+ )
317
+ is None
318
+ )
319
+
320
+
321
+ def test_unreadable_transcript_passes(repository: Path, tmp_path: Path) -> None:
322
+ (repository / "app.py").write_text("VALUE = 2\n", encoding="utf-8")
323
+ _git(repository, "add", "app.py")
324
+ assert not _is_denied(
325
+ _payload("git commit -m change", repository, tmp_path / "missing.jsonl")
326
+ )
327
+
328
+
329
+ def test_directory_outside_a_repository_passes(tmp_path: Path) -> None:
330
+ outside = tmp_path / "outside"
331
+ outside.mkdir()
332
+ assert not _is_denied(
333
+ _payload("git commit -m change", outside, _unverified(tmp_path))
334
+ )