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,365 +0,0 @@
1
- """Decision tests for the sensitive-file filename guard.
2
-
3
- Each case drives the real hook script through its production ``__main__``
4
- stdin path, feeding it the PreToolUse JSON payload Claude Code sends and
5
- reading the permission decision back off stdout.
6
-
7
- The guard denies a filename that names a live secret or a lock file, and
8
- steps aside for a placeholders-only committed template (``.env.example``
9
- and its ``.sample`` / ``.template`` spellings). The template exemption is
10
- narrow: ``.env.local`` is a ``.env.*`` filename that is not a template, and
11
- it stays denied.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import json
17
- import shlex
18
- import subprocess
19
- import sys
20
- from functools import cache
21
- from pathlib import Path
22
-
23
- import pytest
24
-
25
- BLOCKING_DIRECTORY = Path(__file__).resolve().parent
26
- HOOKS_DIRECTORY = BLOCKING_DIRECTORY.parent
27
- CLAUDE_DEV_ENV_DIRECTORY = BLOCKING_DIRECTORY.parent.parent
28
- HOOK_SCRIPT_PATH = BLOCKING_DIRECTORY / "sensitive_file_protector.py"
29
- DISPATCHER_SCRIPT_PATH = BLOCKING_DIRECTORY / "pre_tool_use_dispatcher.py"
30
- HOOKS_JSON_PATH = HOOKS_DIRECTORY / "hooks.json"
31
- DIRECT_HOOK_TIMEOUT_SECONDS = 10
32
-
33
- READ_TOOL_NAME = "Read"
34
-
35
- if str(HOOKS_DIRECTORY) not in sys.path:
36
- sys.path.insert(0, str(HOOKS_DIRECTORY))
37
-
38
- from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
39
- DENY_DECISION,
40
- EDIT_TOOL_NAME,
41
- MULTI_EDIT_TOOL_NAME,
42
- WRITE_TOOL_NAME,
43
- )
44
-
45
- PLACEHOLDER_TEMPLATE_BODY = "API_TOKEN=your-token-here\nDATABASE_HOST=localhost\n"
46
- ORDINARY_SOURCE_BODY = "def add(left: int, right: int) -> int:\n return left + right\n"
47
-
48
- ALL_TEMPLATE_FILENAMES = (
49
- ".env.example",
50
- ".env.sample",
51
- ".env.template",
52
- ".ENV.Example",
53
- "settings.json.template",
54
- )
55
-
56
- ALL_DENIED_FILENAMES = (
57
- ".env",
58
- ".env.local",
59
- ".env.production",
60
- "credentials.json",
61
- "secrets.json",
62
- "id_rsa",
63
- "id_ed25519",
64
- "server.pem",
65
- "signing.key",
66
- "bundle.p12",
67
- "bundle.pfx",
68
- "package-lock.json",
69
- "yarn.lock",
70
- "Pipfile.lock",
71
- "poetry.lock",
72
- "pnpm-lock.yaml",
73
- "composer.lock",
74
- )
75
-
76
- ALL_UPPERCASE_DENIED_FILENAMES = (".ENV", "ID_RSA", "CREDENTIALS.JSON", "SERVER.PEM")
77
-
78
- ALL_ORDINARY_FILENAMES = ("main.py", "README.md", "settings.json")
79
-
80
-
81
- @cache
82
- def _registered_dispatcher_details(
83
- hooks_json_path: Path = HOOKS_JSON_PATH,
84
- ) -> tuple[list[str], int]:
85
- hooks_configuration = json.loads(hooks_json_path.read_text(encoding="utf-8"))
86
- pre_tool_use_registrations = hooks_configuration["hooks"]["PreToolUse"]
87
- dispatcher_registrations = []
88
- for each_registration in pre_tool_use_registrations:
89
- for each_hook in each_registration["hooks"]:
90
- command_parts = shlex.split(each_hook["command"])
91
- if Path(command_parts[-1]).name != "pre_tool_use_dispatcher.py":
92
- continue
93
- dispatcher_registrations.append(
94
- (each_registration, each_hook, command_parts)
95
- )
96
- assert len(dispatcher_registrations) == 1, (
97
- "hooks.json must contain exactly one pre_tool_use_dispatcher.py registration; "
98
- f"found {len(dispatcher_registrations)}"
99
- )
100
- each_registration, each_hook, command_parts = dispatcher_registrations[0]
101
- assert each_registration["matcher"] == "Write|Edit|MultiEdit|apply_patch", (
102
- "dispatcher registration must cover Write, Edit, MultiEdit, and apply_patch; "
103
- f"got matcher {each_registration['matcher']!r}"
104
- )
105
- assert command_parts[0] == "python3"
106
- dispatcher_script_path = Path(
107
- command_parts[-1].replace(
108
- "${CLAUDE_PLUGIN_ROOT}", str(CLAUDE_DEV_ENV_DIRECTORY)
109
- )
110
- )
111
- assert dispatcher_script_path.resolve() == DISPATCHER_SCRIPT_PATH.resolve()
112
- registered_timeout_seconds = each_hook["timeout"]
113
- assert isinstance(registered_timeout_seconds, int)
114
- return (
115
- [sys.executable, str(dispatcher_script_path)],
116
- registered_timeout_seconds,
117
- )
118
-
119
-
120
- def _build_file_tool_input(
121
- tool_name: str, file_path: Path, replacement_content: str
122
- ) -> dict[str, str | list[dict[str, str]]]:
123
- file_path_text = str(file_path)
124
- if tool_name == EDIT_TOOL_NAME:
125
- return {
126
- "file_path": file_path_text,
127
- "old_string": "API_TOKEN=old-token\n",
128
- "new_string": replacement_content,
129
- }
130
- if tool_name == MULTI_EDIT_TOOL_NAME:
131
- return {
132
- "file_path": file_path_text,
133
- "edits": [
134
- {
135
- "old_string": "API_TOKEN=old-token\n",
136
- "new_string": replacement_content,
137
- }
138
- ],
139
- }
140
- return {"file_path": file_path_text, "content": replacement_content}
141
-
142
-
143
- def _run_hook(
144
- tool_name: str, file_path: Path, replacement_content: str
145
- ) -> subprocess.CompletedProcess:
146
- payload = json.dumps(
147
- {
148
- "tool_name": tool_name,
149
- "tool_input": _build_file_tool_input(
150
- tool_name, file_path, replacement_content
151
- ),
152
- }
153
- )
154
- return _run_hook_with_stdin(payload)
155
-
156
-
157
- def _run_hook_with_stdin(stdin_text: str) -> subprocess.CompletedProcess:
158
- return subprocess.run(
159
- [sys.executable, str(HOOK_SCRIPT_PATH)],
160
- input=stdin_text,
161
- capture_output=True,
162
- text=True,
163
- check=False,
164
- timeout=DIRECT_HOOK_TIMEOUT_SECONDS,
165
- )
166
-
167
-
168
- def _run_dispatcher(
169
- tool_name: str, tool_input: dict[str, str | list[dict[str, str]]]
170
- ) -> subprocess.CompletedProcess[str]:
171
- payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
172
- dispatcher_command, dispatcher_timeout_seconds = _registered_dispatcher_details()
173
- return subprocess.run(
174
- dispatcher_command,
175
- input=payload,
176
- capture_output=True,
177
- text=True,
178
- check=False,
179
- timeout=dispatcher_timeout_seconds,
180
- )
181
-
182
-
183
- def _permission_decision(completed: subprocess.CompletedProcess) -> str | None:
184
- if not completed.stdout.strip():
185
- return None
186
- parsed_decision = json.loads(completed.stdout)
187
- return parsed_decision["hookSpecificOutput"]["permissionDecision"]
188
-
189
-
190
- def _deny_reason(completed: subprocess.CompletedProcess) -> str:
191
- parsed_decision = json.loads(completed.stdout)
192
- return parsed_decision["hookSpecificOutput"]["permissionDecisionReason"]
193
-
194
-
195
- @pytest.mark.parametrize("template_filename", ALL_TEMPLATE_FILENAMES)
196
- def test_template_filename_is_allowed(tmp_path: Path, template_filename: str) -> None:
197
- completed = _run_hook(WRITE_TOOL_NAME, tmp_path / template_filename, PLACEHOLDER_TEMPLATE_BODY)
198
- assert completed.returncode == 0, completed.stderr
199
- assert _permission_decision(completed) is None, (
200
- f"{template_filename} is a committed template and must not be denied by filename; "
201
- f"got stdout {completed.stdout!r}"
202
- )
203
-
204
-
205
- @pytest.mark.parametrize("sensitive_filename", ALL_DENIED_FILENAMES)
206
- def test_sensitive_filename_is_denied(tmp_path: Path, sensitive_filename: str) -> None:
207
- completed = _run_hook(WRITE_TOOL_NAME, tmp_path / sensitive_filename, PLACEHOLDER_TEMPLATE_BODY)
208
- assert completed.returncode == 0, completed.stderr
209
- assert _permission_decision(completed) == DENY_DECISION, (
210
- f"{sensitive_filename} must stay denied; got stdout {completed.stdout!r}"
211
- )
212
-
213
-
214
- @pytest.mark.parametrize("uppercase_filename", ALL_UPPERCASE_DENIED_FILENAMES)
215
- def test_uppercase_sensitive_filename_is_denied(tmp_path: Path, uppercase_filename: str) -> None:
216
- completed = _run_hook(WRITE_TOOL_NAME, tmp_path / uppercase_filename, PLACEHOLDER_TEMPLATE_BODY)
217
- assert completed.returncode == 0, completed.stderr
218
- assert _permission_decision(completed) == DENY_DECISION, (
219
- f"{uppercase_filename} names a secret in an uppercase spelling and must stay denied like "
220
- f"its lowercase form; got stdout {completed.stdout!r}"
221
- )
222
-
223
-
224
- @pytest.mark.parametrize("ordinary_filename", ALL_ORDINARY_FILENAMES)
225
- def test_ordinary_source_file_is_allowed(tmp_path: Path, ordinary_filename: str) -> None:
226
- completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ordinary_filename, ORDINARY_SOURCE_BODY)
227
- assert completed.returncode == 0, completed.stderr
228
- assert _permission_decision(completed) is None
229
-
230
-
231
- def test_dot_env_local_deny_reason_names_the_file_and_the_pattern(tmp_path: Path) -> None:
232
- completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ".env.local", PLACEHOLDER_TEMPLATE_BODY)
233
- assert _permission_decision(completed) == DENY_DECISION
234
- deny_reason = _deny_reason(completed)
235
- assert ".env.local" in deny_reason
236
- assert ".env.*" in deny_reason
237
-
238
-
239
- def test_edit_to_a_template_is_allowed(tmp_path: Path) -> None:
240
- completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env.example", PLACEHOLDER_TEMPLATE_BODY)
241
- assert completed.returncode == 0, completed.stderr
242
- assert _permission_decision(completed) is None
243
-
244
-
245
- def test_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
246
- completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
247
- assert completed.returncode == 0, completed.stderr
248
- assert _permission_decision(completed) == DENY_DECISION
249
-
250
-
251
- def test_multi_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
252
- completed = _run_hook(
253
- MULTI_EDIT_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY
254
- )
255
- assert completed.returncode == 0, completed.stderr
256
- assert _permission_decision(completed) == DENY_DECISION
257
-
258
-
259
- def test_apply_patch_add_targeting_a_secrets_file_is_denied(tmp_path: Path) -> None:
260
- payload = json.dumps(
261
- {
262
- "tool_name": "apply_patch",
263
- "cwd": str(tmp_path),
264
- "tool_input": {
265
- "command": (
266
- "*** Begin Patch\n"
267
- "*** Add File: .env\n"
268
- "+API_TOKEN=your-token-here\n"
269
- "*** End Patch"
270
- )
271
- },
272
- }
273
- )
274
- completed = _run_hook_with_stdin(payload)
275
- assert completed.returncode == 0, completed.stderr
276
- assert _permission_decision(completed) == DENY_DECISION
277
-
278
-
279
- def test_apply_patch_add_targeting_an_ordinary_file_is_allowed(tmp_path: Path) -> None:
280
- payload = json.dumps(
281
- {
282
- "tool_name": "apply_patch",
283
- "cwd": str(tmp_path),
284
- "tool_input": {
285
- "command": (
286
- "*** Begin Patch\n"
287
- "*** Add File: main.py\n"
288
- "+x = 1\n"
289
- "*** End Patch"
290
- )
291
- },
292
- }
293
- )
294
- completed = _run_hook_with_stdin(payload)
295
- assert completed.returncode == 0, completed.stderr
296
- assert _permission_decision(completed) is None
297
-
298
-
299
- def test_hooks_json_registers_exact_dispatcher_matcher_and_timeout() -> None:
300
- dispatcher_command, dispatcher_timeout_seconds = _registered_dispatcher_details()
301
- assert dispatcher_command[1] == str(DISPATCHER_SCRIPT_PATH)
302
- assert dispatcher_timeout_seconds == 60
303
-
304
-
305
- @pytest.mark.parametrize(
306
- ("tool_name", "target_filename", "expected_permission_decision"),
307
- [
308
- pytest.param(WRITE_TOOL_NAME, ".env", DENY_DECISION, id="write-secret-denied"),
309
- pytest.param(EDIT_TOOL_NAME, ".env", DENY_DECISION, id="edit-secret-denied"),
310
- pytest.param(
311
- MULTI_EDIT_TOOL_NAME, ".env", DENY_DECISION, id="multi-edit-secret-denied"
312
- ),
313
- pytest.param(WRITE_TOOL_NAME, ".env.example", None, id="write-template-allowed"),
314
- pytest.param(EDIT_TOOL_NAME, ".env.example", None, id="edit-template-allowed"),
315
- ],
316
- )
317
- def test_dispatcher_applies_sensitive_filename_contract(
318
- tmp_path: Path,
319
- tool_name: str,
320
- target_filename: str,
321
- expected_permission_decision: str | None,
322
- ) -> None:
323
- completed = _run_dispatcher(
324
- tool_name,
325
- _build_file_tool_input(
326
- tool_name, tmp_path / target_filename, PLACEHOLDER_TEMPLATE_BODY
327
- ),
328
- )
329
- assert completed.returncode == 0, completed.stderr
330
- assert _permission_decision(completed) == expected_permission_decision, (
331
- f"{tool_name} to {target_filename} expected "
332
- f"{expected_permission_decision!r}, got stdout {completed.stdout!r}"
333
- )
334
-
335
-
336
- def test_read_tool_is_not_evaluated(tmp_path: Path) -> None:
337
- completed = _run_hook(READ_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
338
- assert completed.returncode == 0, completed.stderr
339
- assert _permission_decision(completed) is None
340
-
341
-
342
- def test_payload_without_a_file_path_is_allowed() -> None:
343
- completed = _run_hook_with_stdin(json.dumps({"tool_name": WRITE_TOOL_NAME, "tool_input": {}}))
344
- assert completed.returncode == 0, completed.stderr
345
- assert _permission_decision(completed) is None
346
-
347
-
348
- def test_malformed_payload_fails_open() -> None:
349
- completed = _run_hook_with_stdin("{not valid json")
350
- assert completed.returncode == 0, completed.stderr
351
- assert completed.stdout.strip() == ""
352
-
353
-
354
- @pytest.mark.parametrize("trailing_suffix_filename", (".env.example.bak", ".env.template.old", ".env.sample.orig"))
355
- def test_template_suffix_must_be_last_to_earn_the_exemption(
356
- tmp_path: Path, trailing_suffix_filename: str
357
- ) -> None:
358
- completed = _run_hook(
359
- WRITE_TOOL_NAME, tmp_path / trailing_suffix_filename, PLACEHOLDER_TEMPLATE_BODY
360
- )
361
- assert completed.returncode == 0, completed.stderr
362
- assert _permission_decision(completed) == DENY_DECISION, (
363
- f"{trailing_suffix_filename} is a copy of a live secrets file, not a template; "
364
- f"only a trailing template suffix earns the exemption, got {completed.stdout!r}"
365
- )