claude-dev-env 7.0.0 → 7.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.
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 +213 -0
  6. package/.agents/skills/session-continuity/SKILL.md +125 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +447 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +373 -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 +88 -0
  20. package/bin/install-session-continuity.test.mjs +55 -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,1222 +0,0 @@
1
- """Behavior tests for the PII prevention PreToolUse hook."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import subprocess
7
- import sys
8
- from pathlib import Path
9
- from typing import Callable
10
-
11
- import pytest
12
-
13
- _HOOK_DIR = Path(__file__).parent
14
- _HOOKS_DIR = _HOOK_DIR.parent
15
- if str(_HOOK_DIR) not in sys.path:
16
- sys.path.insert(0, str(_HOOK_DIR))
17
- if str(_HOOKS_DIR) not in sys.path:
18
- sys.path.insert(0, str(_HOOKS_DIR))
19
-
20
- from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
21
- ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
22
- ALL_BASH_HOSTED_HOOK_ENTRIES,
23
- )
24
- from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
25
- ALL_HOSTED_HOOK_ENTRIES,
26
- ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
27
- )
28
- import pii_prevention_blocker as blocker_module # noqa: E402
29
- from pii_prevention_blocker import ( # noqa: E402
30
- evaluate,
31
- evaluate_bash_command,
32
- evaluate_post_body_texts,
33
- evaluate_staged_commit,
34
- evaluate_write_edit_payload,
35
- extract_git_commit_working_directory,
36
- is_git_commit_shell_command,
37
- )
38
-
39
- HOOK_PATH = _HOOK_DIR / "pii_prevention_blocker.py"
40
-
41
- SYNTHETIC_REAL_EMAIL = "owner.fixture@acme-corp.example.io"
42
- SYNTHETIC_HOME_PATH = r"C:\Users\fixture_commit_user\secret.txt"
43
- SYNTHETIC_PRIVATE_IP = "10.44.12.9"
44
- SYNTHETIC_SAFE_BODY = "All checks pass. Contact user@example.com for docs."
45
- HASH_MARKER = "#"
46
-
47
-
48
- def test_post_body_extraction_has_a_neutral_module_owner() -> None:
49
- assert (
50
- blocker_module.extract_gh_post_body_texts_for_privacy_gate.__module__
51
- == "gh_post_body_texts"
52
- )
53
-
54
-
55
- def _run_hook(payload: dict[str, object]) -> tuple[int, str]:
56
- completed = subprocess.run(
57
- [sys.executable, str(HOOK_PATH)],
58
- input=json.dumps(payload),
59
- capture_output=True,
60
- text=True,
61
- timeout=30,
62
- check=False,
63
- )
64
- return completed.returncode, completed.stdout
65
-
66
-
67
- def test_write_with_real_email_is_denied() -> None:
68
- deny_reason = evaluate_write_edit_payload(
69
- "Write",
70
- {
71
- "file_path": "docs/notes.md",
72
- "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
73
- },
74
- )
75
- assert deny_reason is not None
76
- assert "email" in deny_reason
77
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
78
-
79
-
80
- def test_write_with_example_email_is_allowed() -> None:
81
- deny_reason = evaluate_write_edit_payload(
82
- "Write",
83
- {
84
- "file_path": "docs/notes.md",
85
- "content": "Reach me at user@example.com",
86
- },
87
- )
88
- assert deny_reason is None
89
-
90
-
91
- def test_edit_introducing_home_path_is_denied() -> None:
92
- deny_reason = evaluate_write_edit_payload(
93
- "Edit",
94
- {
95
- "file_path": "README.md",
96
- "old_string": "path here",
97
- "new_string": f"path is {SYNTHETIC_HOME_PATH}",
98
- },
99
- )
100
- assert deny_reason is not None
101
- assert "home-path" in deny_reason
102
-
103
-
104
- def test_write_to_test_file_is_exempt() -> None:
105
- deny_reason = evaluate_write_edit_payload(
106
- "Write",
107
- {
108
- "file_path": "packages/hooks/blocking/test_fixture_helper.py",
109
- "content": f"EMAIL = '{SYNTHETIC_REAL_EMAIL}'",
110
- },
111
- )
112
- assert deny_reason is None
113
-
114
-
115
- def test_write_to_test_prefixed_markdown_is_not_exempt() -> None:
116
- deny_reason = evaluate_write_edit_payload(
117
- "Write",
118
- {
119
- "file_path": "test_notes.md",
120
- "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
121
- },
122
- )
123
- assert deny_reason is not None
124
- assert "email" in deny_reason
125
-
126
-
127
- def test_write_with_empty_path_still_scans_content() -> None:
128
- deny_reason = evaluate_write_edit_payload(
129
- "Write",
130
- {
131
- "file_path": "",
132
- "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
133
- },
134
- )
135
- assert deny_reason is not None
136
- assert "email" in deny_reason
137
-
138
-
139
- def test_write_to_basename_only_self_module_name_is_not_exempt() -> None:
140
- deny_reason = evaluate_write_edit_payload(
141
- "Write",
142
- {
143
- "file_path": "vendor/pii_scanner.py",
144
- "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
145
- },
146
- )
147
- assert deny_reason is not None
148
- assert "email" in deny_reason
149
-
150
-
151
- def test_write_to_license_prefix_filename_is_not_exempt() -> None:
152
- deny_reason = evaluate_write_edit_payload(
153
- "Write",
154
- {
155
- "file_path": "LICENSE_leak.env",
156
- "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
157
- },
158
- )
159
- assert deny_reason is not None
160
- assert "email" in deny_reason
161
-
162
-
163
- def test_gh_body_file_fails_closed_when_unreadable(tmp_path: Path) -> None:
164
- missing_body_file = tmp_path / "missing_pr_body.md"
165
- command = f'gh pr comment 12 --body-file "{missing_body_file}"'
166
- deny_reason = evaluate_bash_command(command, working_directory=str(tmp_path))
167
- assert deny_reason is not None
168
- assert "body-file" in deny_reason
169
-
170
-
171
- def test_gh_body_file_reads_relative_path_from_working_directory(
172
- tmp_path: Path,
173
- ) -> None:
174
- body_file = tmp_path / "pr.md"
175
- body_file.write_text(f"Reach me at {SYNTHETIC_REAL_EMAIL}\n", encoding="utf-8")
176
- deny_reason = evaluate_bash_command(
177
- "gh pr comment 12 --body-file pr.md",
178
- working_directory=str(tmp_path),
179
- )
180
- assert deny_reason is not None
181
- assert "email" in deny_reason
182
-
183
-
184
- def test_gh_post_body_with_secret_is_denied(synthetic_github_token: str) -> None:
185
- command = f'gh pr comment 12 --body "auth material {synthetic_github_token}"'
186
- deny_reason = evaluate_bash_command(command, working_directory=None)
187
- assert deny_reason is not None
188
- assert "secret" in deny_reason
189
- assert synthetic_github_token not in deny_reason
190
-
191
-
192
- def test_gh_post_clean_body_is_allowed() -> None:
193
- command = f'gh pr comment 12 --body "{SYNTHETIC_SAFE_BODY}"'
194
- assert evaluate_bash_command(command, working_directory=None) is None
195
-
196
-
197
- def test_mcp_github_body_with_private_ip_is_denied() -> None:
198
- deny_reason = evaluate(
199
- {
200
- "tool_name": "mcp__plugin_github_github__add_issue_comment",
201
- "tool_input": {
202
- "body": f"Service is at {SYNTHETIC_PRIVATE_IP}",
203
- },
204
- }
205
- )
206
- assert deny_reason is not None
207
- assert "private-ip" in deny_reason
208
-
209
-
210
- def test_subprocess_hook_denies_write_with_token(synthetic_github_token: str) -> None:
211
- payload = {
212
- "tool_name": "Write",
213
- "tool_input": {
214
- "file_path": "src/config.env.example.md",
215
- "content": f"TOKEN={synthetic_github_token}\n",
216
- },
217
- }
218
- exit_code, stdout_text = _run_hook(payload)
219
- assert exit_code == 0
220
- assert stdout_text.strip()
221
- payload_out = json.loads(stdout_text)
222
- decision = payload_out["hookSpecificOutput"]["permissionDecision"]
223
- reason = payload_out["hookSpecificOutput"]["permissionDecisionReason"]
224
- assert decision == "deny"
225
- assert "secret" in reason
226
- assert synthetic_github_token not in reason
227
-
228
-
229
- def test_subprocess_hook_allows_clean_write() -> None:
230
- payload = {
231
- "tool_name": "Write",
232
- "tool_input": {
233
- "file_path": "docs/guide.md",
234
- "content": "Use user@example.com and C:/Users/<you>/ for samples.\n",
235
- },
236
- }
237
- exit_code, stdout_text = _run_hook(payload)
238
- assert exit_code == 0
239
- assert stdout_text.strip() == ""
240
-
241
-
242
- def test_staged_commit_with_pii_is_denied(tmp_path: Path) -> None:
243
- repository_root = tmp_path / "repo"
244
- repository_root.mkdir()
245
- subprocess.run(
246
- ["git", "init"],
247
- cwd=repository_root,
248
- check=True,
249
- capture_output=True,
250
- text=True,
251
- )
252
- subprocess.run(
253
- ["git", "config", "user.email", "dev@example.com"],
254
- cwd=repository_root,
255
- check=True,
256
- capture_output=True,
257
- text=True,
258
- )
259
- subprocess.run(
260
- ["git", "config", "user.name", "Fixture Dev"],
261
- cwd=repository_root,
262
- check=True,
263
- capture_output=True,
264
- text=True,
265
- )
266
- tracked_file = repository_root / "notes.md"
267
- tracked_file.write_text(
268
- f"owner email {SYNTHETIC_REAL_EMAIL}\n",
269
- encoding="utf-8",
270
- )
271
- subprocess.run(
272
- ["git", "add", "notes.md"],
273
- cwd=repository_root,
274
- check=True,
275
- capture_output=True,
276
- text=True,
277
- )
278
- deny_reason = evaluate_bash_command(
279
- "git commit -m test",
280
- working_directory=str(repository_root),
281
- )
282
- assert deny_reason is not None
283
- assert "email" in deny_reason
284
- assert "staged commit" in deny_reason
285
-
286
-
287
- def test_staged_commit_with_clean_content_is_allowed(tmp_path: Path) -> None:
288
- repository_root = tmp_path / "repo"
289
- repository_root.mkdir()
290
- subprocess.run(
291
- ["git", "init"],
292
- cwd=repository_root,
293
- check=True,
294
- capture_output=True,
295
- text=True,
296
- )
297
- subprocess.run(
298
- ["git", "config", "user.email", "dev@example.com"],
299
- cwd=repository_root,
300
- check=True,
301
- capture_output=True,
302
- text=True,
303
- )
304
- subprocess.run(
305
- ["git", "config", "user.name", "Fixture Dev"],
306
- cwd=repository_root,
307
- check=True,
308
- capture_output=True,
309
- text=True,
310
- )
311
- tracked_file = repository_root / "notes.md"
312
- tracked_file.write_text(
313
- "Use user@example.com in docs only.\n",
314
- encoding="utf-8",
315
- )
316
- subprocess.run(
317
- ["git", "add", "notes.md"],
318
- cwd=repository_root,
319
- check=True,
320
- capture_output=True,
321
- text=True,
322
- )
323
- deny_reason = evaluate_bash_command(
324
- "git commit -m test",
325
- working_directory=str(repository_root),
326
- )
327
- assert deny_reason is None
328
-
329
-
330
- def test_staged_commit_fails_closed_when_git_list_fails(
331
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
332
- ) -> None:
333
- monkeypatch.setattr(
334
- blocker_module,
335
- "list_staged_file_paths",
336
- lambda _repository_root: (None, "BLOCKED list failure"),
337
- )
338
- deny_reason = evaluate_staged_commit(tmp_path)
339
- assert deny_reason == "BLOCKED list failure"
340
-
341
-
342
- def test_staged_commit_fails_closed_when_blob_unscannable(
343
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
344
- ) -> None:
345
- monkeypatch.setattr(
346
- blocker_module,
347
- "list_staged_file_paths",
348
- lambda _repository_root: (["binary.bin"], None),
349
- )
350
- monkeypatch.setattr(
351
- blocker_module,
352
- "read_staged_file_text",
353
- lambda _repository_root, relative_path: (
354
- None,
355
- f"BLOCKED unscannable {relative_path}",
356
- ),
357
- )
358
- deny_reason = evaluate_staged_commit(tmp_path)
359
- assert deny_reason is not None
360
- assert "unscannable" in deny_reason
361
- assert "binary.bin" in deny_reason
362
-
363
-
364
- def test_dispatcher_roster_hosts_pii_blocker() -> None:
365
- matching_write_entries = [
366
- each_entry
367
- for each_entry in ALL_HOSTED_HOOK_ENTRIES
368
- if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
369
- ]
370
- assert len(matching_write_entries) == 1
371
- assert matching_write_entries[0].applicable_tool_names == (
372
- ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES | {"apply_patch"}
373
- )
374
- assert matching_write_entries[0].is_blocking is True
375
- matching_bash_entries = [
376
- each_entry
377
- for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
378
- if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
379
- ]
380
- assert len(matching_bash_entries) == 1
381
- assert matching_bash_entries[0].applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
382
-
383
-
384
- def test_is_git_commit_shell_command_covers_windows_and_flag_forms() -> None:
385
- assert is_git_commit_shell_command("git commit -m test")
386
- assert is_git_commit_shell_command("git.exe commit -m test")
387
- assert is_git_commit_shell_command("git --no-verify commit -m test")
388
- assert is_git_commit_shell_command("git -c commit.gpgsign=false commit -m x")
389
- assert is_git_commit_shell_command('git -C "C:/repo" commit -m x')
390
- assert is_git_commit_shell_command(r'& "C:\Program Files\Git\cmd\git.exe" commit -m x')
391
- assert not is_git_commit_shell_command("git status")
392
- assert not is_git_commit_shell_command("git log --grep commit")
393
- assert not is_git_commit_shell_command('echo "please git commit"')
394
- assert not is_git_commit_shell_command("gh pr comment 1 --body 'git commit'")
395
-
396
-
397
- def _init_repo_with_staged_email(repository_root: Path) -> None:
398
- repository_root.mkdir()
399
- subprocess.run(
400
- ["git", "init"],
401
- cwd=repository_root,
402
- check=True,
403
- capture_output=True,
404
- text=True,
405
- )
406
- subprocess.run(
407
- ["git", "config", "user.email", "dev@example.com"],
408
- cwd=repository_root,
409
- check=True,
410
- capture_output=True,
411
- text=True,
412
- )
413
- subprocess.run(
414
- ["git", "config", "user.name", "Fixture Dev"],
415
- cwd=repository_root,
416
- check=True,
417
- capture_output=True,
418
- text=True,
419
- )
420
- tracked_file = repository_root / "notes.md"
421
- tracked_file.write_text(
422
- f"owner email {SYNTHETIC_REAL_EMAIL}\n",
423
- encoding="utf-8",
424
- )
425
- subprocess.run(
426
- ["git", "add", "notes.md"],
427
- cwd=repository_root,
428
- check=True,
429
- capture_output=True,
430
- text=True,
431
- )
432
-
433
-
434
- def _init_repo_with_clean_notes(repository_root: Path) -> None:
435
- repository_root.mkdir()
436
- subprocess.run(
437
- ["git", "init"],
438
- cwd=repository_root,
439
- check=True,
440
- capture_output=True,
441
- text=True,
442
- )
443
- subprocess.run(
444
- ["git", "config", "user.email", "dev@example.com"],
445
- cwd=repository_root,
446
- check=True,
447
- capture_output=True,
448
- text=True,
449
- )
450
- subprocess.run(
451
- ["git", "config", "user.name", "Fixture Dev"],
452
- cwd=repository_root,
453
- check=True,
454
- capture_output=True,
455
- text=True,
456
- )
457
- tracked_file = repository_root / "notes.md"
458
- tracked_file.write_text(
459
- "Use user@example.com in docs only.\n",
460
- encoding="utf-8",
461
- )
462
- subprocess.run(
463
- ["git", "add", "notes.md"],
464
- cwd=repository_root,
465
- check=True,
466
- capture_output=True,
467
- text=True,
468
- )
469
-
470
-
471
- def test_git_exe_commit_scans_staged_pii(tmp_path: Path) -> None:
472
- repository_root = tmp_path / "repo"
473
- _init_repo_with_staged_email(repository_root)
474
- deny_reason = evaluate_bash_command(
475
- "git.exe commit -m test",
476
- working_directory=str(repository_root),
477
- )
478
- assert deny_reason is not None
479
- assert "email" in deny_reason
480
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
481
-
482
-
483
- def test_git_config_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
484
- repository_root = tmp_path / "repo"
485
- _init_repo_with_staged_email(repository_root)
486
- deny_reason = evaluate_bash_command(
487
- "git -c commit.gpgsign=false commit -m test",
488
- working_directory=str(repository_root),
489
- )
490
- assert deny_reason is not None
491
- assert "email" in deny_reason
492
-
493
-
494
- def test_git_no_verify_commit_scans_staged_pii(tmp_path: Path) -> None:
495
- repository_root = tmp_path / "repo"
496
- _init_repo_with_staged_email(repository_root)
497
- deny_reason = evaluate_bash_command(
498
- "git --no-verify commit -m test",
499
- working_directory=str(repository_root),
500
- )
501
- assert deny_reason is not None
502
- assert "email" in deny_reason
503
-
504
-
505
- def test_repository_root_unresolved_fails_closed(
506
- monkeypatch: pytest.MonkeyPatch,
507
- ) -> None:
508
- monkeypatch.setattr(blocker_module, "resolve_repository_root", lambda _cwd: None)
509
- deny_reason = evaluate_bash_command(
510
- "git commit -m test",
511
- working_directory=str(Path.cwd()),
512
- )
513
- assert deny_reason is not None
514
- assert "repository root" in deny_reason
515
-
516
-
517
- def test_powershell_tool_commit_with_staged_email_is_denied(tmp_path: Path) -> None:
518
- repository_root = tmp_path / "repo"
519
- _init_repo_with_staged_email(repository_root)
520
- deny_reason = evaluate(
521
- {
522
- "tool_name": "PowerShell",
523
- "tool_input": {
524
- "command": "git commit -m test",
525
- "working_directory": str(repository_root),
526
- },
527
- }
528
- )
529
- assert deny_reason is not None
530
- assert "email" in deny_reason
531
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
532
-
533
-
534
- def test_is_git_commit_shell_command_detects_glued_and_newline_separators() -> None:
535
- assert is_git_commit_shell_command("git add .;git commit -m x")
536
- assert is_git_commit_shell_command("cd repo&&git commit -m x")
537
- assert is_git_commit_shell_command("git add notes.md\ngit commit -m update")
538
- assert is_git_commit_shell_command("git status\ngit commit -m x")
539
- assert not is_git_commit_shell_command("gh pr comment 1 --body 'notes\ngit commit -m x'")
540
-
541
-
542
- def test_is_git_commit_shell_command_detects_backgrounded_commit() -> None:
543
- assert is_git_commit_shell_command("npm run build & git commit -am wip")
544
- assert is_git_commit_shell_command("sleep 1 & git commit -m x")
545
- assert is_git_commit_shell_command("& git commit -m x")
546
- assert is_git_commit_shell_command("git commit -m x &")
547
- assert not is_git_commit_shell_command("sleep 1 & git status")
548
-
549
-
550
- def test_backgrounded_commit_scans_staged_pii(tmp_path: Path) -> None:
551
- repository_root = tmp_path / "repo"
552
- _init_repo_with_staged_email(repository_root)
553
- deny_reason = evaluate_bash_command(
554
- "npm run build & git commit -m test",
555
- working_directory=str(repository_root),
556
- )
557
- assert deny_reason is not None
558
- assert "email" in deny_reason
559
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
560
-
561
-
562
- def test_newline_separated_commit_scans_staged_pii(tmp_path: Path) -> None:
563
- repository_root = tmp_path / "repo"
564
- _init_repo_with_staged_email(repository_root)
565
- deny_reason = evaluate_bash_command(
566
- "git add notes.md\ngit commit -m update",
567
- working_directory=str(repository_root),
568
- )
569
- assert deny_reason is not None
570
- assert "email" in deny_reason
571
- assert "staged commit" in deny_reason
572
-
573
-
574
- def test_glued_semicolon_commit_scans_staged_pii(tmp_path: Path) -> None:
575
- repository_root = tmp_path / "repo"
576
- _init_repo_with_staged_email(repository_root)
577
- deny_reason = evaluate_bash_command(
578
- "git add notes.md;git commit -m update",
579
- working_directory=str(repository_root),
580
- )
581
- assert deny_reason is not None
582
- assert "email" in deny_reason
583
-
584
-
585
- def test_is_git_commit_shell_command_detects_shell_keyword_wrapped_commits() -> None:
586
- assert is_git_commit_shell_command("if true; then git commit -m x; fi")
587
- assert is_git_commit_shell_command("for f in a b; do git commit -m x; done")
588
- assert is_git_commit_shell_command("bash -c 'git commit -m x'")
589
- assert is_git_commit_shell_command('sh -c "git commit -m x"')
590
-
591
-
592
- def test_is_git_commit_shell_command_detects_wrapper_prefixed_commits() -> None:
593
- assert is_git_commit_shell_command("time git commit -m x")
594
- assert is_git_commit_shell_command("sudo git commit -m x")
595
- assert is_git_commit_shell_command("env GIT_AUTHOR_NAME=a git commit")
596
- assert is_git_commit_shell_command("nice git commit -m y")
597
- assert is_git_commit_shell_command("nice -n 10 git commit")
598
- assert is_git_commit_shell_command("timeout 30 git commit")
599
- assert is_git_commit_shell_command("env FOO=bar git commit")
600
- assert not is_git_commit_shell_command("sudo cat notes.md")
601
-
602
-
603
- def test_wrapper_prefix_stops_at_intervening_command_word() -> None:
604
- assert not is_git_commit_shell_command("time echo git commit")
605
- assert not is_git_commit_shell_command("sudo docker run git commit")
606
-
607
-
608
- def test_wrapper_prefixed_commit_scans_staged_pii(tmp_path: Path) -> None:
609
- repository_root = tmp_path / "repo"
610
- _init_repo_with_staged_email(repository_root)
611
- deny_reason = evaluate_bash_command(
612
- "env GIT_AUTHOR_NAME=a git commit -m test",
613
- working_directory=str(repository_root),
614
- )
615
- assert deny_reason is not None
616
- assert "email" in deny_reason
617
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
618
-
619
-
620
- def test_shell_keyword_wrapped_commit_scans_staged_pii(tmp_path: Path) -> None:
621
- repository_root = tmp_path / "repo"
622
- _init_repo_with_staged_email(repository_root)
623
- deny_reason = evaluate_bash_command(
624
- "time git commit -m test",
625
- working_directory=str(repository_root),
626
- )
627
- assert deny_reason is not None
628
- assert "email" in deny_reason
629
-
630
-
631
- def test_is_git_commit_shell_command_detects_powershell_command_flag() -> None:
632
- assert is_git_commit_shell_command('pwsh -NoProfile -Command "git commit -m x"')
633
- assert is_git_commit_shell_command('powershell -Command "git commit -m x"')
634
- assert is_git_commit_shell_command("pwsh.exe -Command 'git commit -m x'")
635
- assert is_git_commit_shell_command('pwsh -c "git commit -m x"')
636
- assert not is_git_commit_shell_command('pwsh -NoProfile -Command "git status"')
637
-
638
-
639
- def test_is_git_commit_shell_command_detects_powershell_command_abbreviations() -> None:
640
- assert is_git_commit_shell_command('pwsh -Com "git commit -m x"')
641
- assert is_git_commit_shell_command('pwsh -Comm "git commit -m x"')
642
- assert is_git_commit_shell_command('pwsh -Comma "git commit -m x"')
643
- assert is_git_commit_shell_command('pwsh -Comman "git commit -m x"')
644
- assert not is_git_commit_shell_command('pwsh -NoProfile "git commit -m x"')
645
-
646
-
647
- def test_is_git_commit_shell_command_detects_flag_carrying_wrappers() -> None:
648
- assert is_git_commit_shell_command("nice -n 10 git commit -m x")
649
- assert is_git_commit_shell_command("stdbuf -oL git commit -m x")
650
- assert is_git_commit_shell_command("env -i git commit -m x")
651
- assert is_git_commit_shell_command("env -u NAME git commit -m x")
652
- assert not is_git_commit_shell_command("nice -n 10 cat notes.md")
653
-
654
-
655
- def test_is_git_commit_shell_command_detects_wrapper_flag_value_matching_binary() -> None:
656
- assert is_git_commit_shell_command("sudo -u git git commit -m x")
657
- assert is_git_commit_shell_command("sudo -u alice git commit -m x")
658
- assert is_git_commit_shell_command("sudo -u sh git commit -m x")
659
- assert is_git_commit_shell_command("doas -u git git commit -m x")
660
- assert is_git_commit_shell_command("sudo -u git git commit")
661
- assert not is_git_commit_shell_command("sudo -u git git status")
662
-
663
-
664
- def test_is_git_commit_shell_command_detects_argument_bearing_wrappers() -> None:
665
- assert is_git_commit_shell_command("timeout 30 git commit -m x")
666
- assert is_git_commit_shell_command("nohup git commit -m x")
667
- assert is_git_commit_shell_command("flock /tmp/lock git commit -m x")
668
- assert is_git_commit_shell_command("ionice git commit -m x")
669
- assert is_git_commit_shell_command("doas git commit -m x")
670
- assert is_git_commit_shell_command("setsid git commit -m x")
671
- assert not is_git_commit_shell_command("timeout 30 cat notes.md")
672
-
673
-
674
- def test_argument_bearing_wrapper_commit_scans_staged_pii(tmp_path: Path) -> None:
675
- repository_root = tmp_path / "repo"
676
- _init_repo_with_staged_email(repository_root)
677
- deny_reason = evaluate_bash_command(
678
- "timeout 30 git commit -m test",
679
- working_directory=str(repository_root),
680
- )
681
- assert deny_reason is not None
682
- assert "email" in deny_reason
683
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
684
-
685
-
686
- def test_is_git_commit_shell_command_detects_commit_after_hash_bearing_token() -> None:
687
- command = f"curl https://example.com/page{HASH_MARKER}section && git commit -am x"
688
- assert is_git_commit_shell_command(command)
689
- assert is_git_commit_shell_command("echo done#note && git commit -m x")
690
-
691
-
692
- def test_hash_bearing_token_before_commit_scans_staged_pii(tmp_path: Path) -> None:
693
- repository_root = tmp_path / "repo"
694
- _init_repo_with_staged_email(repository_root)
695
- deny_reason = evaluate_bash_command(
696
- "curl https://example.com/page#section && git commit -m test",
697
- working_directory=str(repository_root),
698
- )
699
- assert deny_reason is not None
700
- assert "email" in deny_reason
701
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
702
-
703
-
704
- def test_is_git_commit_shell_command_detects_combined_interpreter_flags() -> None:
705
- assert is_git_commit_shell_command('bash -lc "git commit -m x"')
706
- assert is_git_commit_shell_command("sh -ec 'git commit -m x'")
707
- assert is_git_commit_shell_command('bash -l -c "git commit -m x"')
708
- assert not is_git_commit_shell_command('bash -lc "git status"')
709
-
710
-
711
- def test_is_git_commit_shell_command_detects_c_first_interpreter_clusters() -> None:
712
- assert is_git_commit_shell_command('bash -cx "git commit -m x"')
713
- assert is_git_commit_shell_command('bash -cv "git commit -m x"')
714
- assert is_git_commit_shell_command("sh -cx 'git commit -m x'")
715
- assert not is_git_commit_shell_command("bash -x 'git status'")
716
-
717
-
718
- def test_is_git_commit_shell_command_detects_unquoted_powershell_command() -> None:
719
- assert is_git_commit_shell_command("pwsh -Command git commit -m x")
720
- assert is_git_commit_shell_command("powershell -Command git commit -m wip")
721
- assert not is_git_commit_shell_command("pwsh -Command git status")
722
-
723
-
724
- def test_is_git_commit_shell_command_detects_powershell_preflags_before_command() -> None:
725
- assert is_git_commit_shell_command('pwsh -ExecutionPolicy Bypass -Command "git commit -m x"')
726
- assert is_git_commit_shell_command('pwsh -NonInteractive -Command "git commit -m x"')
727
- assert is_git_commit_shell_command(
728
- 'powershell -ExecutionPolicy Bypass -Command "git commit -m x"'
729
- )
730
- assert is_git_commit_shell_command('pwsh -NoProfile -NonInteractive -Command "git commit -m x"')
731
- assert not is_git_commit_shell_command('pwsh -NonInteractive -Command "git status"')
732
-
733
-
734
- def test_is_git_commit_shell_command_detects_subshell_grouped_commit() -> None:
735
- assert is_git_commit_shell_command("(git commit -m x)")
736
- assert is_git_commit_shell_command("(cd repo && git commit -m x)")
737
- assert not is_git_commit_shell_command("(git status)")
738
-
739
-
740
- def test_powershell_preflag_command_commit_scans_staged_pii(tmp_path: Path) -> None:
741
- repository_root = tmp_path / "repo"
742
- _init_repo_with_staged_email(repository_root)
743
- deny_reason = evaluate_bash_command(
744
- 'pwsh -ExecutionPolicy Bypass -Command "git commit -m test"',
745
- working_directory=str(repository_root),
746
- )
747
- assert deny_reason is not None
748
- assert "email" in deny_reason
749
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
750
-
751
-
752
- def test_subshell_grouped_commit_scans_staged_pii(tmp_path: Path) -> None:
753
- repository_root = tmp_path / "repo"
754
- _init_repo_with_staged_email(repository_root)
755
- deny_reason = evaluate_bash_command(
756
- "(git commit -m test)",
757
- working_directory=str(repository_root),
758
- )
759
- assert deny_reason is not None
760
- assert "email" in deny_reason
761
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
762
-
763
-
764
- def test_powershell_command_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
765
- repository_root = tmp_path / "repo"
766
- _init_repo_with_staged_email(repository_root)
767
- deny_reason = evaluate_bash_command(
768
- 'pwsh -NoProfile -Command "git commit -m test"',
769
- working_directory=str(repository_root),
770
- )
771
- assert deny_reason is not None
772
- assert "email" in deny_reason
773
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
774
-
775
-
776
- def test_flag_carrying_wrapper_commit_scans_staged_pii(tmp_path: Path) -> None:
777
- repository_root = tmp_path / "repo"
778
- _init_repo_with_staged_email(repository_root)
779
- deny_reason = evaluate_bash_command(
780
- "nice -n 10 git commit -m test",
781
- working_directory=str(repository_root),
782
- )
783
- assert deny_reason is not None
784
- assert "email" in deny_reason
785
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
786
-
787
-
788
- def test_combined_interpreter_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
789
- repository_root = tmp_path / "repo"
790
- _init_repo_with_staged_email(repository_root)
791
- deny_reason = evaluate_bash_command(
792
- 'bash -lc "git commit -m test"',
793
- working_directory=str(repository_root),
794
- )
795
- assert deny_reason is not None
796
- assert "email" in deny_reason
797
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
798
-
799
-
800
- def test_c_first_interpreter_cluster_commit_scans_staged_pii(tmp_path: Path) -> None:
801
- repository_root = tmp_path / "repo"
802
- _init_repo_with_staged_email(repository_root)
803
- deny_reason = evaluate_bash_command(
804
- 'bash -cx "git commit -m test"',
805
- working_directory=str(repository_root),
806
- )
807
- assert deny_reason is not None
808
- assert "email" in deny_reason
809
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
810
-
811
-
812
- def test_unquoted_powershell_command_commit_scans_staged_pii(tmp_path: Path) -> None:
813
- repository_root = tmp_path / "repo"
814
- _init_repo_with_staged_email(repository_root)
815
- deny_reason = evaluate_bash_command(
816
- "pwsh -Command git commit -m test",
817
- working_directory=str(repository_root),
818
- )
819
- assert deny_reason is not None
820
- assert "email" in deny_reason
821
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
822
-
823
-
824
- def test_is_git_commit_shell_command_detects_commit_after_apostrophe_line() -> None:
825
- assert is_git_commit_shell_command("echo Don't forget\ngit commit -am x")
826
- assert is_git_commit_shell_command("echo can't stop\ngit commit -am 'msg'")
827
- assert is_git_commit_shell_command("printf Bob's file\ngit commit -m note")
828
-
829
-
830
- def test_is_git_commit_shell_command_ignores_commit_inside_quoted_body() -> None:
831
- assert not is_git_commit_shell_command("gh pr comment 1 --body 'notes\ngit commit -m x'")
832
-
833
-
834
- def test_apostrophe_line_before_commit_scans_staged_pii(tmp_path: Path) -> None:
835
- repository_root = tmp_path / "repo"
836
- _init_repo_with_staged_email(repository_root)
837
- deny_reason = evaluate_bash_command(
838
- "echo can't stop now\ngit commit -m test",
839
- working_directory=str(repository_root),
840
- )
841
- assert deny_reason is not None
842
- assert "email" in deny_reason
843
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
844
-
845
-
846
- def test_is_git_commit_shell_command_detects_powershell_backtick_continuation() -> None:
847
- assert is_git_commit_shell_command("git `\n commit -m x")
848
- assert is_git_commit_shell_command("git commit `\n -m x")
849
- assert not is_git_commit_shell_command("git `\n status")
850
-
851
-
852
- def test_powershell_backtick_continuation_commit_scans_staged_pii(
853
- tmp_path: Path,
854
- ) -> None:
855
- repository_root = tmp_path / "repo"
856
- _init_repo_with_staged_email(repository_root)
857
- deny_reason = evaluate_bash_command(
858
- "git `\n commit -m test",
859
- working_directory=str(repository_root),
860
- )
861
- assert deny_reason is not None
862
- assert "email" in deny_reason
863
- assert SYNTHETIC_REAL_EMAIL not in deny_reason
864
-
865
-
866
- def test_extract_git_commit_working_directory_resolves_dash_c_forms() -> None:
867
- assert extract_git_commit_working_directory(r"git.exe -C /repoA commit -m x") == "/repoA"
868
- assert extract_git_commit_working_directory(r"git -C /repoA commit -m x") == "/repoA"
869
- assert extract_git_commit_working_directory(r'git -C "C:/repo" commit -m x') == "C:/repo"
870
- assert extract_git_commit_working_directory("git commit -m x") is None
871
-
872
-
873
- def test_git_exe_dash_c_commit_scans_named_repo_not_cwd(tmp_path: Path) -> None:
874
- repository_with_pii = tmp_path / "repo_with_pii"
875
- _init_repo_with_staged_email(repository_with_pii)
876
- clean_repository = tmp_path / "clean_repo"
877
- _init_repo_with_clean_notes(clean_repository)
878
- cwd_only_scan = evaluate_bash_command(
879
- "git.exe commit -m test",
880
- working_directory=str(clean_repository),
881
- )
882
- assert cwd_only_scan is None
883
- dash_c_scan = evaluate_bash_command(
884
- f"git.exe -C {repository_with_pii} commit -m test",
885
- working_directory=str(clean_repository),
886
- )
887
- assert dash_c_scan is not None
888
- assert "email" in dash_c_scan
889
- assert SYNTHETIC_REAL_EMAIL not in dash_c_scan
890
-
891
-
892
- def _add_repository_origin(repository_root: Path, origin_url: str) -> None:
893
- subprocess.run(
894
- ["git", "remote", "add", "origin", origin_url],
895
- cwd=repository_root,
896
- check=True,
897
- capture_output=True,
898
- text=True,
899
- )
900
-
901
-
902
- def test_exempt_repository_commit_skips_staged_pii_scan(
903
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
904
- ) -> None:
905
- repository_root = tmp_path / "repo"
906
- _init_repo_with_staged_email(repository_root)
907
- _add_repository_origin(repository_root, "https://github.com/ExemptOwner/exempt-repo.git")
908
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
909
- deny_reason = evaluate_bash_command(
910
- "git commit -m test",
911
- working_directory=str(repository_root),
912
- )
913
- assert deny_reason is None
914
-
915
-
916
- def test_exempt_repository_matches_ssh_origin_url(
917
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
918
- ) -> None:
919
- repository_root = tmp_path / "repo"
920
- _init_repo_with_staged_email(repository_root)
921
- _add_repository_origin(repository_root, "git@github.com:ExemptOwner/exempt-repo.git")
922
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "exemptowner/EXEMPT-REPO")
923
- deny_reason = evaluate_bash_command(
924
- "git commit -m test",
925
- working_directory=str(repository_root),
926
- )
927
- assert deny_reason is None
928
-
929
-
930
- def test_non_exempt_repository_commit_still_scans_staged_pii(
931
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
932
- ) -> None:
933
- repository_root = tmp_path / "repo"
934
- _init_repo_with_staged_email(repository_root)
935
- _add_repository_origin(repository_root, "https://github.com/OtherOwner/other-repo.git")
936
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
937
- deny_reason = evaluate_bash_command(
938
- "git commit -m test",
939
- working_directory=str(repository_root),
940
- )
941
- assert deny_reason is not None
942
- assert "email" in deny_reason
943
-
944
-
945
- def test_repository_without_origin_is_not_exempt(
946
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
947
- ) -> None:
948
- repository_root = tmp_path / "repo"
949
- _init_repo_with_staged_email(repository_root)
950
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
951
- deny_reason = evaluate_bash_command(
952
- "git commit -m test",
953
- working_directory=str(repository_root),
954
- )
955
- assert deny_reason is not None
956
- assert "email" in deny_reason
957
-
958
-
959
- def test_owner_repo_slug_from_https_origin_with_git_suffix() -> None:
960
- assert (
961
- blocker_module._owner_repo_slug_from_origin_url("https://github.com/Owner/repo.git")
962
- == "owner/repo"
963
- )
964
-
965
-
966
- def test_owner_repo_slug_from_https_origin_without_git_suffix() -> None:
967
- assert (
968
- blocker_module._owner_repo_slug_from_origin_url("https://github.com/Owner/repo")
969
- == "owner/repo"
970
- )
971
-
972
-
973
- def test_owner_repo_slug_from_scp_ssh_origin() -> None:
974
- assert (
975
- blocker_module._owner_repo_slug_from_origin_url("git@github.com:Owner/repo.git")
976
- == "owner/repo"
977
- )
978
-
979
-
980
- def test_owner_repo_slug_from_ssh_scheme_origin() -> None:
981
- assert (
982
- blocker_module._owner_repo_slug_from_origin_url("ssh://git@github.com/Owner/repo.git")
983
- == "owner/repo"
984
- )
985
-
986
-
987
- def test_owner_repo_slug_from_trailing_slash_git_suffix() -> None:
988
- assert (
989
- blocker_module._owner_repo_slug_from_origin_url("https://github.com/Owner/repo.git/")
990
- == "owner/repo"
991
- )
992
-
993
-
994
- def test_owner_repo_slug_rejects_spoofed_evil_host() -> None:
995
- assert (
996
- blocker_module._owner_repo_slug_from_origin_url(
997
- "https://evil.test/ExemptOwner/exempt-repo.git"
998
- )
999
- is None
1000
- )
1001
-
1002
-
1003
- def test_owner_repo_slug_rejects_notgithub_com_host() -> None:
1004
- assert (
1005
- blocker_module._owner_repo_slug_from_origin_url("https://notgithub.com/Owner/repo.git")
1006
- is None
1007
- )
1008
-
1009
-
1010
- def test_owner_repo_slug_rejects_github_com_suffix_host() -> None:
1011
- assert (
1012
- blocker_module._owner_repo_slug_from_origin_url(
1013
- "https://github.com.evil.test/Owner/repo.git"
1014
- )
1015
- is None
1016
- )
1017
-
1018
-
1019
- def test_owner_repo_slug_rejects_invalid_https_port_authority() -> None:
1020
- assert (
1021
- blocker_module._owner_repo_slug_from_origin_url(
1022
- "https://github.com:443.evil.test/Owner/repo.git"
1023
- )
1024
- is None
1025
- )
1026
-
1027
-
1028
- def test_owner_repo_slug_rejects_invalid_ssh_port_authority() -> None:
1029
- assert (
1030
- blocker_module._owner_repo_slug_from_origin_url(
1031
- "ssh://git@github.com:22.evil.test/Owner/repo.git"
1032
- )
1033
- is None
1034
- )
1035
-
1036
-
1037
- def test_owner_repo_slug_from_https_origin_with_valid_port() -> None:
1038
- assert (
1039
- blocker_module._owner_repo_slug_from_origin_url("https://github.com:443/Owner/repo.git")
1040
- == "owner/repo"
1041
- )
1042
-
1043
-
1044
- def test_owner_repo_slug_from_ssh_origin_with_valid_port() -> None:
1045
- assert (
1046
- blocker_module._owner_repo_slug_from_origin_url("ssh://git@github.com:22/Owner/repo.git")
1047
- == "owner/repo"
1048
- )
1049
-
1050
-
1051
- def test_owner_repo_slug_rejects_path_shaped_origin() -> None:
1052
- assert (
1053
- blocker_module._owner_repo_slug_from_origin_url("C:/repos/ExemptOwner/exempt-repo") is None
1054
- )
1055
-
1056
-
1057
- def test_spoofed_origin_host_still_denies_staged_email(
1058
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1059
- ) -> None:
1060
- repository_root = tmp_path / "repo"
1061
- _init_repo_with_staged_email(repository_root)
1062
- _add_repository_origin(repository_root, "https://evil.test/ExemptOwner/exempt-repo.git")
1063
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1064
- deny_reason = evaluate_bash_command(
1065
- "git commit -m test",
1066
- working_directory=str(repository_root),
1067
- )
1068
- assert deny_reason is not None
1069
- assert "email" in deny_reason
1070
-
1071
-
1072
- def test_invalid_port_authority_origin_still_denies_staged_email(
1073
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1074
- ) -> None:
1075
- repository_root = tmp_path / "repo"
1076
- _init_repo_with_staged_email(repository_root)
1077
- _add_repository_origin(
1078
- repository_root,
1079
- "https://github.com:443.evil.test/ExemptOwner/exempt-repo.git",
1080
- )
1081
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1082
- deny_reason = evaluate_bash_command(
1083
- "git commit -m test",
1084
- working_directory=str(repository_root),
1085
- )
1086
- assert deny_reason is not None
1087
- assert "email" in deny_reason
1088
-
1089
-
1090
- def test_path_shaped_origin_still_denies_staged_email(
1091
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1092
- ) -> None:
1093
- repository_root = tmp_path / "repo"
1094
- _init_repo_with_staged_email(repository_root)
1095
- _add_repository_origin(repository_root, "C:/repos/ExemptOwner/exempt-repo")
1096
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1097
- deny_reason = evaluate_bash_command(
1098
- "git commit -m test",
1099
- working_directory=str(repository_root),
1100
- )
1101
- assert deny_reason is not None
1102
- assert "email" in deny_reason
1103
-
1104
-
1105
- def test_trailing_slash_git_suffix_origin_still_exempts(
1106
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1107
- ) -> None:
1108
- repository_root = tmp_path / "repo"
1109
- _init_repo_with_staged_email(repository_root)
1110
- _add_repository_origin(repository_root, "https://github.com/ExemptOwner/exempt-repo.git/")
1111
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1112
- deny_reason = evaluate_bash_command(
1113
- "git commit -m test",
1114
- working_directory=str(repository_root),
1115
- )
1116
- assert deny_reason is None
1117
-
1118
-
1119
- def test_ssh_scheme_origin_still_exempts(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
1120
- repository_root = tmp_path / "repo"
1121
- _init_repo_with_staged_email(repository_root)
1122
- _add_repository_origin(repository_root, "ssh://git@github.com/ExemptOwner/exempt-repo.git")
1123
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1124
- deny_reason = evaluate_bash_command(
1125
- "git commit -m test",
1126
- working_directory=str(repository_root),
1127
- )
1128
- assert deny_reason is None
1129
-
1130
-
1131
- def test_write_still_scans_when_repository_is_exempt(
1132
- monkeypatch: pytest.MonkeyPatch,
1133
- ) -> None:
1134
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1135
- deny_reason = evaluate_write_edit_payload(
1136
- "Write",
1137
- {
1138
- "file_path": "notes.md",
1139
- "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
1140
- },
1141
- )
1142
- assert deny_reason is not None
1143
- assert "email" in deny_reason
1144
-
1145
-
1146
- def test_post_body_still_scans_when_repository_is_exempt(
1147
- monkeypatch: pytest.MonkeyPatch,
1148
- ) -> None:
1149
- monkeypatch.setenv("CLAUDE_PII_EXEMPT_REPOS", "ExemptOwner/exempt-repo")
1150
- deny_reason = evaluate_post_body_texts([f"Reach me at {SYNTHETIC_REAL_EMAIL}"])
1151
- assert deny_reason is not None
1152
- assert "email" in deny_reason
1153
-
1154
-
1155
- def test_apply_patch_add_with_real_email_is_denied(
1156
- tmp_path: Path, init_bare_git_repo: Callable[[Path], None]
1157
- ) -> None:
1158
- repository_root = tmp_path / "repo"
1159
- init_bare_git_repo(repository_root)
1160
- deny_reason = evaluate(
1161
- {
1162
- "tool_name": "apply_patch",
1163
- "cwd": str(repository_root),
1164
- "tool_input": {
1165
- "command": (
1166
- "*** Begin Patch\n"
1167
- "*** Add File: notes.md\n"
1168
- f"+Reach me at {SYNTHETIC_REAL_EMAIL}\n"
1169
- "*** End Patch"
1170
- )
1171
- },
1172
- }
1173
- )
1174
- assert deny_reason is not None
1175
- assert "email" in deny_reason
1176
-
1177
-
1178
- def test_apply_patch_add_with_clean_content_is_allowed(
1179
- tmp_path: Path, init_bare_git_repo: Callable[[Path], None]
1180
- ) -> None:
1181
- repository_root = tmp_path / "repo"
1182
- init_bare_git_repo(repository_root)
1183
- deny_reason = evaluate(
1184
- {
1185
- "tool_name": "apply_patch",
1186
- "cwd": str(repository_root),
1187
- "tool_input": {
1188
- "command": (
1189
- "*** Begin Patch\n"
1190
- "*** Add File: notes.md\n"
1191
- "+Reach me at user@example.com\n"
1192
- "*** End Patch"
1193
- )
1194
- },
1195
- }
1196
- )
1197
- assert deny_reason is None
1198
-
1199
-
1200
- def test_subprocess_hook_denies_apply_patch_add_with_token(
1201
- tmp_path: Path,
1202
- init_bare_git_repo: Callable[[Path], None],
1203
- synthetic_github_token: str,
1204
- ) -> None:
1205
- """The standalone hook process denies an apply_patch payload carrying a token."""
1206
- repository_root = tmp_path / "repo"
1207
- init_bare_git_repo(repository_root)
1208
- payload = {
1209
- "tool_name": "apply_patch",
1210
- "cwd": str(repository_root),
1211
- "tool_input": {
1212
- "command": (
1213
- "*** Begin Patch\n"
1214
- "*** Add File: notes.md\n"
1215
- f"+token is {synthetic_github_token}\n"
1216
- "*** End Patch"
1217
- )
1218
- },
1219
- }
1220
- returncode, stdout = _run_hook(payload)
1221
- assert returncode == 0
1222
- assert json.loads(stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"