claude-dev-env 7.0.0 → 7.1.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 (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +189 -0
  6. package/.agents/skills/session-continuity/SKILL.md +114 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +375 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +78 -0
  20. package/bin/install-session-continuity.test.mjs +42 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -1,298 +0,0 @@
1
- """Tests for shell_substitution_blocker hook."""
2
-
3
- import json
4
- import subprocess
5
- import sys
6
- from pathlib import Path
7
-
8
-
9
- SCRIPT_PATH = Path(__file__).parent / "shell_substitution_blocker.py"
10
-
11
-
12
- def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
13
- return subprocess.run(
14
- [sys.executable, str(SCRIPT_PATH)],
15
- input=json.dumps(payload),
16
- text=True,
17
- capture_output=True,
18
- check=False,
19
- )
20
-
21
-
22
- def test_denies_dollar_paren_substitution() -> None:
23
- payload = {
24
- "tool_name": "Bash",
25
- "tool_input": {"command": 'echo "head: $(git rev-parse HEAD)"'},
26
- }
27
- response = json.loads(_run_hook(payload).stdout)
28
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
29
- assert (
30
- "shell-substitution"
31
- in response["hookSpecificOutput"]["permissionDecisionReason"]
32
- )
33
-
34
-
35
- def test_denies_unescaped_backtick_substitution() -> None:
36
- payload = {
37
- "tool_name": "Bash",
38
- "tool_input": {"command": "echo `date`"},
39
- }
40
- response = json.loads(_run_hook(payload).stdout)
41
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
42
-
43
-
44
- def test_allows_plain_command_without_substitution() -> None:
45
- payload = {
46
- "tool_name": "Bash",
47
- "tool_input": {"command": "git rev-parse HEAD"},
48
- }
49
- assert _run_hook(payload).stdout == ""
50
-
51
-
52
- def test_allows_escaped_backtick_in_prose() -> None:
53
- payload = {
54
- "tool_name": "Bash",
55
- "tool_input": {"command": r'echo "use \`foo\` like this"'},
56
- }
57
- assert _run_hook(payload).stdout == ""
58
-
59
-
60
- def test_ignores_non_bash_tool() -> None:
61
- payload = {
62
- "tool_name": "Edit",
63
- "tool_input": {"command": 'echo "$(date)"'},
64
- }
65
- assert _run_hook(payload).stdout == ""
66
-
67
-
68
- def test_denies_double_backslash_backtick_bypass() -> None:
69
- payload = {
70
- "tool_name": "Bash",
71
- "tool_input": {"command": r"echo \\`date`"},
72
- }
73
- response = json.loads(_run_hook(payload).stdout)
74
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
75
-
76
-
77
- def test_allows_dollar_paren_inside_single_quotes() -> None:
78
- payload = {
79
- "tool_name": "Bash",
80
- "tool_input": {"command": "echo '$(not-executed)'"},
81
- }
82
- assert _run_hook(payload).stdout == ""
83
-
84
-
85
- def test_allows_backtick_inside_single_quotes() -> None:
86
- payload = {
87
- "tool_name": "Bash",
88
- "tool_input": {"command": "echo '`not-executed`'"},
89
- }
90
- assert _run_hook(payload).stdout == ""
91
-
92
-
93
- def test_denies_input_process_substitution() -> None:
94
- payload = {
95
- "tool_name": "Bash",
96
- "tool_input": {"command": "diff <(cat a) <(cat b)"},
97
- }
98
- response = json.loads(_run_hook(payload).stdout)
99
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
100
-
101
-
102
- def test_denies_output_process_substitution() -> None:
103
- payload = {
104
- "tool_name": "Bash",
105
- "tool_input": {"command": "tee >(gzip > out.gz)"},
106
- }
107
- response = json.loads(_run_hook(payload).stdout)
108
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
109
-
110
-
111
- def test_allows_process_substitution_inside_single_quotes() -> None:
112
- payload = {
113
- "tool_name": "Bash",
114
- "tool_input": {"command": "echo '<(not-executed)'"},
115
- }
116
- assert _run_hook(payload).stdout == ""
117
-
118
-
119
- def test_allows_arithmetic_expansion() -> None:
120
- payload = {
121
- "tool_name": "Bash",
122
- "tool_input": {"command": "echo $((2+2))"},
123
- }
124
- assert _run_hook(payload).stdout == ""
125
-
126
-
127
- def test_allows_arithmetic_expansion_with_nested_parens() -> None:
128
- payload = {
129
- "tool_name": "Bash",
130
- "tool_input": {"command": "echo $(( (1+2) * 3 ))"},
131
- }
132
- assert _run_hook(payload).stdout == ""
133
-
134
-
135
- def test_allows_arithmetic_expansion_whose_body_bash_itself_rejects() -> None:
136
- payload = {
137
- "tool_name": "Bash",
138
- "tool_input": {"command": "echo $((echo hi))"},
139
- }
140
- assert _run_hook(payload).stdout == ""
141
-
142
-
143
- def test_allows_arithmetic_lookalike_inside_single_quotes() -> None:
144
- payload = {
145
- "tool_name": "Bash",
146
- "tool_input": {"command": "echo '$((cd /tmp) && pwd)'"},
147
- }
148
- assert _run_hook(payload).stdout == ""
149
-
150
-
151
- def test_denies_subshell_disguised_as_arithmetic_expansion() -> None:
152
- payload = {
153
- "tool_name": "Bash",
154
- "tool_input": {"command": "echo $((cd /tmp) && pwd)"},
155
- }
156
- response = json.loads(_run_hook(payload).stdout)
157
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
158
-
159
-
160
- def test_denies_parenthesized_command_word_disguised_as_arithmetic() -> None:
161
- payload = {
162
- "tool_name": "Bash",
163
- "tool_input": {"command": "echo $((1+2) )"},
164
- }
165
- response = json.loads(_run_hook(payload).stdout)
166
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
167
-
168
-
169
- def test_denies_command_substitution_nested_inside_arithmetic_expansion() -> None:
170
- payload = {
171
- "tool_name": "Bash",
172
- "tool_input": {"command": "echo $(( $(id -u) + 1 ))"},
173
- }
174
- response = json.loads(_run_hook(payload).stdout)
175
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
176
-
177
-
178
- def test_denies_backtick_substitution_nested_inside_arithmetic_expansion() -> None:
179
- payload = {
180
- "tool_name": "Bash",
181
- "tool_input": {"command": "echo $(( `id -u` + 1 ))"},
182
- }
183
- response = json.loads(_run_hook(payload).stdout)
184
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
185
-
186
-
187
- def test_denies_nested_command_substitution_without_arithmetic() -> None:
188
- payload = {
189
- "tool_name": "Bash",
190
- "tool_input": {"command": "echo $(echo $(whoami))"},
191
- }
192
- response = json.loads(_run_hook(payload).stdout)
193
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
194
-
195
-
196
- def test_allows_backtick_inside_single_quoted_heredoc_body() -> None:
197
- payload = {
198
- "tool_name": "Bash",
199
- "tool_input": {
200
- "command": (
201
- "cat > notes.md <<'EOF'\n"
202
- "reStructuredText markup: ``inert literal``\n"
203
- "EOF"
204
- )
205
- },
206
- }
207
- assert _run_hook(payload).stdout == ""
208
-
209
-
210
- def test_allows_backtick_inside_tab_stripped_quoted_heredoc_body() -> None:
211
- """A ``<<-`` opener closes on a tab-indented terminator, and stays inert.
212
-
213
- The tab-strip rule decides which line ends the body. Getting it wrong
214
- swallows the rest of the command or ends the body early, so the branch is
215
- pinned here as well as in the shared pipeline that owns the rule.
216
- """
217
- payload = {
218
- "tool_name": "Bash",
219
- "tool_input": {
220
- "command": (
221
- "cat > notes.md <<-'EOF'\n"
222
- "reStructuredText markup: ``inert literal``\n"
223
- "\tEOF\n"
224
- "echo done"
225
- )
226
- },
227
- }
228
- assert _run_hook(payload).stdout == ""
229
-
230
-
231
- def test_denies_backtick_inside_bare_heredoc_body() -> None:
232
- payload = {
233
- "tool_name": "Bash",
234
- "tool_input": {"command": "cat > notes.md <<EOF\nnow: `date`\nEOF"},
235
- }
236
- response = json.loads(_run_hook(payload).stdout)
237
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
238
-
239
-
240
- def test_denies_command_substitution_inside_bare_heredoc_body() -> None:
241
- payload = {
242
- "tool_name": "Bash",
243
- "tool_input": {
244
- "command": "cat > notes.md <<EOF\nhead: $(git rev-parse HEAD)\nEOF"
245
- },
246
- }
247
- response = json.loads(_run_hook(payload).stdout)
248
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
249
-
250
-
251
- def test_denies_live_substitution_outside_a_quoted_heredoc() -> None:
252
- payload = {
253
- "tool_name": "Bash",
254
- "tool_input": {
255
- "command": (
256
- "cat > notes.md <<'EOF'\n"
257
- "inert ``literal``\n"
258
- "EOF\n"
259
- 'echo "$(git rev-parse HEAD)"'
260
- )
261
- },
262
- }
263
- response = json.loads(_run_hook(payload).stdout)
264
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
265
-
266
-
267
- def test_allows_backslash_escaped_heredoc_delimiter_body() -> None:
268
- payload = {
269
- "tool_name": "Bash",
270
- "tool_input": {
271
- "command": "cat > notes.md <<\\EOF\ninert `text`\nEOF"
272
- },
273
- }
274
- assert _run_hook(payload).stdout == ""
275
-
276
-
277
- def test_denies_a_substitution_below_an_opener_inside_a_quoted_string() -> None:
278
- """An opener spelled inside a quoted string opens no heredoc.
279
-
280
- Bash runs the second line here. Reading the first line as an opener and
281
- dropping everything below it hides a live substitution from the scan.
282
- """
283
- payload = {
284
- "tool_name": "Bash",
285
- "tool_input": {"command": 'echo "<<\'EOF\'"\necho $(whoami)'},
286
- }
287
- response = json.loads(_run_hook(payload).stdout)
288
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
289
-
290
-
291
- def test_denies_that_bypass_even_when_a_terminator_line_follows() -> None:
292
- """A terminator line below the substitution does not make the strip safe."""
293
- payload = {
294
- "tool_name": "Bash",
295
- "tool_input": {"command": 'echo "<<\'EOF\'"\necho $(whoami)\nEOF'},
296
- }
297
- response = json.loads(_run_hook(payload).stdout)
298
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
@@ -1,102 +0,0 @@
1
- """Tests for unscoped_search_blocker hook."""
2
-
3
- import json
4
- import subprocess
5
- import sys
6
- from pathlib import Path
7
-
8
- import pytest
9
-
10
-
11
- SCRIPT_PATH = Path(__file__).parent / "unscoped_search_blocker.py"
12
-
13
- ALL_DENIED_COMMANDS = [
14
- "find / -iname code_rules_gate.py",
15
- "find /c -name '*.py'",
16
- "find ~ -name README.md",
17
- "find $HOME -type f",
18
- "ls -R /",
19
- "ls -laR /",
20
- "ls -Rl /",
21
- "find //server/share -name x",
22
- "find \\\\server\\share -name x",
23
- "bash -c 'find / -name x'",
24
- ]
25
-
26
- ALL_ALLOWED_COMMANDS = [
27
- "find . -iname '*.py'",
28
- "find packages/claude-dev-env -name code_rules_gate.py",
29
- "find /c/Users/example/repo -iname SKILL.md",
30
- "ls -r /",
31
- "ls -la /tmp/x",
32
- "find //server/share/project/src -name x",
33
- "es.exe path:C:\\dev\\repo ext:py gate",
34
- "git status --short",
35
- ]
36
-
37
-
38
- def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
39
- return subprocess.run(
40
- [sys.executable, str(SCRIPT_PATH)],
41
- input=json.dumps(payload),
42
- text=True,
43
- capture_output=True,
44
- check=False,
45
- )
46
-
47
-
48
- def _bash_payload(command: str) -> dict:
49
- return {"tool_name": "Bash", "tool_input": {"command": command}}
50
-
51
-
52
- def _powershell_payload(command: str) -> dict:
53
- return {"tool_name": "PowerShell", "tool_input": {"command": command}}
54
-
55
-
56
- @pytest.mark.parametrize("each_command", ALL_DENIED_COMMANDS)
57
- def test_denies_unscoped_root_walk(each_command: str) -> None:
58
- response = json.loads(_run_hook(_bash_payload(each_command)).stdout)
59
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
60
-
61
-
62
- @pytest.mark.parametrize("each_command", ALL_ALLOWED_COMMANDS)
63
- def test_allows_scoped_search(each_command: str) -> None:
64
- assert _run_hook(_bash_payload(each_command)).stdout == ""
65
-
66
-
67
- def test_denies_recursive_listing_on_windows_drive_root() -> None:
68
- response = json.loads(
69
- _run_hook(_powershell_payload("Get-ChildItem -Path C:\\ -Recurse")).stdout
70
- )
71
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
72
-
73
-
74
- def test_allows_recursive_listing_under_project_directory() -> None:
75
- assert _run_hook(_powershell_payload("Get-ChildItem -Path .\\src -Recurse")).stdout == ""
76
-
77
-
78
- def test_allows_recursive_listing_without_recurse_flag() -> None:
79
- assert _run_hook(_powershell_payload("Get-ChildItem -Path C:\\")).stdout == ""
80
-
81
-
82
- def test_denies_find_root_in_a_chained_segment() -> None:
83
- response = json.loads(
84
- _run_hook(_bash_payload("cd /tmp && find / -name x.py")).stdout
85
- )
86
- assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
87
-
88
-
89
- def test_ignores_unsupported_tool_name() -> None:
90
- payload = {"tool_name": "Edit", "tool_input": {"command": "find / -name x"}}
91
- assert _run_hook(payload).stdout == ""
92
-
93
-
94
- def test_ignores_payload_without_a_command() -> None:
95
- payload = {"tool_name": "Bash", "tool_input": {}}
96
- assert _run_hook(payload).stdout == ""
97
-
98
-
99
- def test_deny_message_names_the_scoped_alternative() -> None:
100
- response = json.loads(_run_hook(_bash_payload("find / -name x.py")).stdout)
101
- deny_reason = response["hookSpecificOutput"]["permissionDecisionReason"]
102
- assert "scope" in deny_reason.lower()
@@ -1,122 +0,0 @@
1
- """Tests for the write_existing_file_blocker hook.
2
-
3
- Each case drives the real hook's ``main()`` through its production stdin path,
4
- feeding it the PreToolUse JSON payload Claude Code sends and reading the
5
- permission decision back off stdout.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import contextlib
11
- import io
12
- import json
13
- from collections.abc import Mapping
14
- from pathlib import Path
15
-
16
- import pytest
17
-
18
- import write_existing_file_blocker
19
-
20
-
21
- def _run_payload(
22
- payload: Mapping[str, object],
23
- monkeypatch: pytest.MonkeyPatch,
24
- capsys: pytest.CaptureFixture[str],
25
- ) -> str:
26
- """Run the real hook entry point and return its stdout."""
27
- monkeypatch.setattr(write_existing_file_blocker.sys, "stdin", io.StringIO(json.dumps(payload)))
28
- with contextlib.suppress(SystemExit):
29
- write_existing_file_blocker.main()
30
- return capsys.readouterr().out
31
-
32
-
33
- def _is_deny(stdout_text: str) -> bool:
34
- if not stdout_text.strip():
35
- return False
36
- decision = json.loads(stdout_text)
37
- return decision["hookSpecificOutput"]["permissionDecision"] == "deny"
38
-
39
-
40
- def test_write_denies_an_existing_target(
41
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
42
- ) -> None:
43
- """A Write onto a path that already exists is denied."""
44
- existing_path = tmp_path / "already_here.py"
45
- existing_path.write_text("x = 1\n", encoding="utf-8")
46
- payload = {
47
- "tool_name": "Write",
48
- "tool_input": {"file_path": str(existing_path), "content": "x = 2\n"},
49
- }
50
-
51
- stdout = _run_payload(payload, monkeypatch, capsys)
52
-
53
- assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
54
-
55
-
56
- def test_apply_patch_add_denies_an_existing_target(
57
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
58
- ) -> None:
59
- """An apply_patch 'Add File' section targeting an existing path is denied."""
60
- existing_path = tmp_path / "already_here.py"
61
- existing_path.write_text("x = 1\n", encoding="utf-8")
62
- payload = {
63
- "tool_name": "apply_patch",
64
- "cwd": str(tmp_path),
65
- "tool_input": {
66
- "command": ("*** Begin Patch\n*** Add File: already_here.py\n+x = 2\n*** End Patch")
67
- },
68
- }
69
-
70
- stdout = _run_payload(payload, monkeypatch, capsys)
71
-
72
- assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
73
-
74
-
75
- def test_apply_patch_update_on_an_existing_target_is_allowed(
76
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
77
- ) -> None:
78
- """An 'Update File' section on an existing path is not an unread overwrite."""
79
- existing_path = tmp_path / "already_here.py"
80
- existing_path.write_text("x = 1\n", encoding="utf-8")
81
- payload = {
82
- "tool_name": "apply_patch",
83
- "cwd": str(tmp_path),
84
- "tool_input": {
85
- "command": (
86
- "*** Begin Patch\n"
87
- "*** Update File: already_here.py\n"
88
- "@@\n"
89
- "-x = 1\n"
90
- "+x = 2\n"
91
- "*** End Patch"
92
- )
93
- },
94
- }
95
-
96
- stdout = _run_payload(payload, monkeypatch, capsys)
97
-
98
- assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"
99
-
100
-
101
- def test_multi_edit_on_an_existing_target_is_allowed(
102
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
103
- ) -> None:
104
- """A MultiEdit always targets a file already on disk, so it is never denied here.
105
-
106
- Every MultiEdit edit must match an ``old_string`` already present in the
107
- file, so MultiEdit carries no blind create-or-clobber path the way a Write
108
- or an apply_patch 'Add File' does — this hook allows it unconditionally.
109
- """
110
- existing_path = tmp_path / "already_here.py"
111
- existing_path.write_text("x = 1\n", encoding="utf-8")
112
- payload = {
113
- "tool_name": "MultiEdit",
114
- "tool_input": {
115
- "file_path": str(existing_path),
116
- "edits": [{"old_string": "x = 1", "new_string": "x = 2"}],
117
- },
118
- }
119
-
120
- stdout = _run_payload(payload, monkeypatch, capsys)
121
-
122
- assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"