claude-dev-env 1.92.1 → 1.93.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 (214) hide show
  1. package/CLAUDE.md +6 -2
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +340 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  79. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  81. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  82. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  84. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  85. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  86. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  87. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  88. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  89. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  90. package/hooks/blocking/test_pii_scanner.py +165 -0
  91. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  92. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  93. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  94. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  95. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  96. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  97. package/hooks/diagnostic/migrations/README.md +25 -26
  98. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  99. package/hooks/hooks.json +10 -120
  100. package/hooks/hooks_constants/CLAUDE.md +12 -3
  101. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  102. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  103. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  104. package/hooks/hooks_constants/local_identity.py +182 -0
  105. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  106. package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
  107. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  108. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  109. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  110. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  111. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  112. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  113. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  114. package/hooks/hooks_constants/test_local_identity.py +88 -0
  115. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  116. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  117. package/hooks/validators/README.md +5 -6
  118. package/hooks/validators/__init__.py +2 -2
  119. package/hooks/validators/python_style_checks.py +298 -243
  120. package/hooks/validators/run_all_validators.py +6 -0
  121. package/hooks/validators/test_python_style_checks.py +278 -163
  122. package/package.json +2 -2
  123. package/rules/CLAUDE.md +2 -1
  124. package/rules/bdd.md +1 -1
  125. package/rules/nas-ssh-invocation.md +6 -4
  126. package/rules/no-justification-noise.md +61 -0
  127. package/scripts/test_setup_project_paths.py +1 -1
  128. package/skills/CLAUDE.md +6 -3
  129. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  130. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  131. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  133. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  134. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  135. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  136. package/skills/autoconverge/CLAUDE.md +3 -3
  137. package/skills/autoconverge/SKILL.md +113 -35
  138. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  139. package/skills/autoconverge/reference/convergence.md +44 -22
  140. package/skills/autoconverge/reference/gotchas.md +11 -0
  141. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  142. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  143. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  144. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  145. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  146. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  147. package/skills/autoconverge/workflow/converge.mjs +329 -68
  148. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  149. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  150. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  151. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  152. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  153. package/skills/bdd-protocol/SKILL.md +4 -5
  154. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  155. package/skills/bugteam/SKILL.md +8 -0
  156. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  157. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  158. package/skills/bugteam/reference/team-setup.md +7 -5
  159. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  160. package/skills/copilot-finding-triage/SKILL.md +124 -0
  161. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  162. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  163. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  166. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  167. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  168. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  169. package/skills/copilot-review/SKILL.md +8 -6
  170. package/skills/findbugs/SKILL.md +4 -0
  171. package/skills/fixbugs/SKILL.md +8 -7
  172. package/skills/gotcha/CLAUDE.md +2 -2
  173. package/skills/gotcha/SKILL.md +4 -4
  174. package/skills/log-audit/SKILL.md +4 -6
  175. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  176. package/skills/monitor-open-prs/SKILL.md +6 -1
  177. package/skills/orchestrator/SKILL.md +74 -78
  178. package/skills/orchestrator-refresh/SKILL.md +24 -12
  179. package/skills/post-audit-findings/SKILL.md +5 -9
  180. package/skills/pr-consistency-audit/SKILL.md +5 -1
  181. package/skills/pr-converge/CLAUDE.md +1 -1
  182. package/skills/pr-converge/SKILL.md +86 -47
  183. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  184. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  185. package/skills/pr-converge/reference/examples.md +63 -47
  186. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  187. package/skills/pr-converge/reference/ground-rules.md +11 -7
  188. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  189. package/skills/pr-converge/reference/per-tick.md +129 -107
  190. package/skills/pr-converge/reference/state-schema.md +15 -10
  191. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  192. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  193. package/skills/pr-fix-protocol/SKILL.md +3 -8
  194. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  195. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  196. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  197. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  198. package/skills/pr-scope-resolve/SKILL.md +3 -6
  199. package/skills/privacy-hygiene/SKILL.md +114 -0
  200. package/skills/qbug/SKILL.md +8 -8
  201. package/skills/rebase/SKILL.md +5 -1
  202. package/skills/refine/SKILL.md +4 -5
  203. package/skills/reviewer-gates/SKILL.md +7 -11
  204. package/skills/session-log/SKILL.md +4 -1
  205. package/skills/skill-builder/SKILL.md +3 -6
  206. package/skills/structure-prompt/SKILL.md +4 -5
  207. package/skills/team-advisor/SKILL.md +56 -0
  208. package/skills/test_markdown_link_integrity.py +10 -6
  209. package/skills/update/SKILL.md +5 -1
  210. package/skills/usage-pause/SKILL.md +14 -5
  211. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  212. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  213. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  214. package/skills/verified-build/SKILL.md +4 -9
@@ -0,0 +1,905 @@
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
+
10
+ import pytest
11
+
12
+ _HOOK_DIR = Path(__file__).parent
13
+ _HOOKS_DIR = _HOOK_DIR.parent
14
+ if str(_HOOK_DIR) not in sys.path:
15
+ sys.path.insert(0, str(_HOOK_DIR))
16
+ if str(_HOOKS_DIR) not in sys.path:
17
+ sys.path.insert(0, str(_HOOKS_DIR))
18
+
19
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
20
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
21
+ ALL_BASH_HOSTED_HOOK_ENTRIES,
22
+ )
23
+ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
24
+ ALL_HOSTED_HOOK_ENTRIES,
25
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
26
+ )
27
+ import pii_prevention_blocker as blocker_module # noqa: E402
28
+ from pii_prevention_blocker import ( # noqa: E402
29
+ evaluate,
30
+ evaluate_bash_command,
31
+ evaluate_staged_commit,
32
+ evaluate_write_edit_payload,
33
+ extract_git_commit_working_directory,
34
+ is_git_commit_shell_command,
35
+ )
36
+
37
+ HOOK_PATH = _HOOK_DIR / "pii_prevention_blocker.py"
38
+
39
+ SYNTHETIC_GITHUB_TOKEN = "ghp_" + ("C" * 36)
40
+ SYNTHETIC_REAL_EMAIL = "owner.fixture@acme-corp.example.io"
41
+ SYNTHETIC_HOME_PATH = r"C:\Users\fixture_commit_user\secret.txt"
42
+ SYNTHETIC_PRIVATE_IP = "10.44.12.9"
43
+ SYNTHETIC_SAFE_BODY = "All checks pass. Contact user@example.com for docs."
44
+
45
+
46
+ def _run_hook(payload: dict[str, object]) -> tuple[int, str]:
47
+ completed = subprocess.run(
48
+ [sys.executable, str(HOOK_PATH)],
49
+ input=json.dumps(payload),
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=30,
53
+ check=False,
54
+ )
55
+ return completed.returncode, completed.stdout
56
+
57
+
58
+ def test_write_with_real_email_is_denied() -> None:
59
+ deny_reason = evaluate_write_edit_payload(
60
+ "Write",
61
+ {
62
+ "file_path": "docs/notes.md",
63
+ "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
64
+ },
65
+ )
66
+ assert deny_reason is not None
67
+ assert "email" in deny_reason
68
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
69
+
70
+
71
+ def test_write_with_example_email_is_allowed() -> None:
72
+ deny_reason = evaluate_write_edit_payload(
73
+ "Write",
74
+ {
75
+ "file_path": "docs/notes.md",
76
+ "content": "Reach me at user@example.com",
77
+ },
78
+ )
79
+ assert deny_reason is None
80
+
81
+
82
+ def test_edit_introducing_home_path_is_denied() -> None:
83
+ deny_reason = evaluate_write_edit_payload(
84
+ "Edit",
85
+ {
86
+ "file_path": "README.md",
87
+ "old_string": "path here",
88
+ "new_string": f"path is {SYNTHETIC_HOME_PATH}",
89
+ },
90
+ )
91
+ assert deny_reason is not None
92
+ assert "home-path" in deny_reason
93
+
94
+
95
+ def test_write_to_test_file_is_exempt() -> None:
96
+ deny_reason = evaluate_write_edit_payload(
97
+ "Write",
98
+ {
99
+ "file_path": "packages/hooks/blocking/test_fixture_helper.py",
100
+ "content": f"EMAIL = '{SYNTHETIC_REAL_EMAIL}'",
101
+ },
102
+ )
103
+ assert deny_reason is None
104
+
105
+
106
+ def test_write_to_test_prefixed_markdown_is_not_exempt() -> None:
107
+ deny_reason = evaluate_write_edit_payload(
108
+ "Write",
109
+ {
110
+ "file_path": "test_notes.md",
111
+ "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
112
+ },
113
+ )
114
+ assert deny_reason is not None
115
+ assert "email" in deny_reason
116
+
117
+
118
+ def test_write_with_empty_path_still_scans_content() -> None:
119
+ deny_reason = evaluate_write_edit_payload(
120
+ "Write",
121
+ {
122
+ "file_path": "",
123
+ "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
124
+ },
125
+ )
126
+ assert deny_reason is not None
127
+ assert "email" in deny_reason
128
+
129
+
130
+ def test_write_to_basename_only_self_module_name_is_not_exempt() -> None:
131
+ deny_reason = evaluate_write_edit_payload(
132
+ "Write",
133
+ {
134
+ "file_path": "vendor/pii_scanner.py",
135
+ "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
136
+ },
137
+ )
138
+ assert deny_reason is not None
139
+ assert "email" in deny_reason
140
+
141
+
142
+ def test_write_to_license_prefix_filename_is_not_exempt() -> None:
143
+ deny_reason = evaluate_write_edit_payload(
144
+ "Write",
145
+ {
146
+ "file_path": "LICENSE_leak.env",
147
+ "content": f"Reach me at {SYNTHETIC_REAL_EMAIL}",
148
+ },
149
+ )
150
+ assert deny_reason is not None
151
+ assert "email" in deny_reason
152
+
153
+
154
+ def test_gh_body_file_fails_closed_when_unreadable(tmp_path: Path) -> None:
155
+ missing_body_file = tmp_path / "missing_pr_body.md"
156
+ command = f'gh pr comment 12 --body-file "{missing_body_file}"'
157
+ deny_reason = evaluate_bash_command(command, working_directory=str(tmp_path))
158
+ assert deny_reason is not None
159
+ assert "body-file" in deny_reason
160
+
161
+
162
+ def test_gh_body_file_reads_relative_path_from_working_directory(
163
+ tmp_path: Path,
164
+ ) -> None:
165
+ body_file = tmp_path / "pr.md"
166
+ body_file.write_text(f"Reach me at {SYNTHETIC_REAL_EMAIL}\n", encoding="utf-8")
167
+ deny_reason = evaluate_bash_command(
168
+ "gh pr comment 12 --body-file pr.md",
169
+ working_directory=str(tmp_path),
170
+ )
171
+ assert deny_reason is not None
172
+ assert "email" in deny_reason
173
+
174
+
175
+ def test_gh_post_body_with_secret_is_denied() -> None:
176
+ command = (
177
+ f'gh pr comment 12 --body "auth material {SYNTHETIC_GITHUB_TOKEN}"'
178
+ )
179
+ deny_reason = evaluate_bash_command(command, working_directory=None)
180
+ assert deny_reason is not None
181
+ assert "secret" in deny_reason
182
+ assert SYNTHETIC_GITHUB_TOKEN not in deny_reason
183
+
184
+
185
+ def test_gh_post_clean_body_is_allowed() -> None:
186
+ command = f'gh pr comment 12 --body "{SYNTHETIC_SAFE_BODY}"'
187
+ assert evaluate_bash_command(command, working_directory=None) is None
188
+
189
+
190
+ def test_mcp_github_body_with_private_ip_is_denied() -> None:
191
+ deny_reason = evaluate(
192
+ {
193
+ "tool_name": "mcp__plugin_github_github__add_issue_comment",
194
+ "tool_input": {
195
+ "body": f"Service is at {SYNTHETIC_PRIVATE_IP}",
196
+ },
197
+ }
198
+ )
199
+ assert deny_reason is not None
200
+ assert "private-ip" in deny_reason
201
+
202
+
203
+ def test_subprocess_hook_denies_write_with_token() -> None:
204
+ payload = {
205
+ "tool_name": "Write",
206
+ "tool_input": {
207
+ "file_path": "src/config.env.example.md",
208
+ "content": f"TOKEN={SYNTHETIC_GITHUB_TOKEN}\n",
209
+ },
210
+ }
211
+ exit_code, stdout_text = _run_hook(payload)
212
+ assert exit_code == 0
213
+ assert stdout_text.strip()
214
+ payload_out = json.loads(stdout_text)
215
+ decision = payload_out["hookSpecificOutput"]["permissionDecision"]
216
+ reason = payload_out["hookSpecificOutput"]["permissionDecisionReason"]
217
+ assert decision == "deny"
218
+ assert "secret" in reason
219
+ assert SYNTHETIC_GITHUB_TOKEN not in reason
220
+
221
+
222
+ def test_subprocess_hook_allows_clean_write() -> None:
223
+ payload = {
224
+ "tool_name": "Write",
225
+ "tool_input": {
226
+ "file_path": "docs/guide.md",
227
+ "content": "Use user@example.com and C:/Users/<you>/ for samples.\n",
228
+ },
229
+ }
230
+ exit_code, stdout_text = _run_hook(payload)
231
+ assert exit_code == 0
232
+ assert stdout_text.strip() == ""
233
+
234
+
235
+ def test_staged_commit_with_pii_is_denied(tmp_path: Path) -> None:
236
+ repository_root = tmp_path / "repo"
237
+ repository_root.mkdir()
238
+ subprocess.run(
239
+ ["git", "init"],
240
+ cwd=repository_root,
241
+ check=True,
242
+ capture_output=True,
243
+ text=True,
244
+ )
245
+ subprocess.run(
246
+ ["git", "config", "user.email", "dev@example.com"],
247
+ cwd=repository_root,
248
+ check=True,
249
+ capture_output=True,
250
+ text=True,
251
+ )
252
+ subprocess.run(
253
+ ["git", "config", "user.name", "Fixture Dev"],
254
+ cwd=repository_root,
255
+ check=True,
256
+ capture_output=True,
257
+ text=True,
258
+ )
259
+ tracked_file = repository_root / "notes.md"
260
+ tracked_file.write_text(
261
+ f"owner email {SYNTHETIC_REAL_EMAIL}\n",
262
+ encoding="utf-8",
263
+ )
264
+ subprocess.run(
265
+ ["git", "add", "notes.md"],
266
+ cwd=repository_root,
267
+ check=True,
268
+ capture_output=True,
269
+ text=True,
270
+ )
271
+ deny_reason = evaluate_bash_command(
272
+ "git commit -m test",
273
+ working_directory=str(repository_root),
274
+ )
275
+ assert deny_reason is not None
276
+ assert "email" in deny_reason
277
+ assert "staged commit" in deny_reason
278
+
279
+
280
+ def test_staged_commit_with_clean_content_is_allowed(tmp_path: Path) -> None:
281
+ repository_root = tmp_path / "repo"
282
+ repository_root.mkdir()
283
+ subprocess.run(
284
+ ["git", "init"],
285
+ cwd=repository_root,
286
+ check=True,
287
+ capture_output=True,
288
+ text=True,
289
+ )
290
+ subprocess.run(
291
+ ["git", "config", "user.email", "dev@example.com"],
292
+ cwd=repository_root,
293
+ check=True,
294
+ capture_output=True,
295
+ text=True,
296
+ )
297
+ subprocess.run(
298
+ ["git", "config", "user.name", "Fixture Dev"],
299
+ cwd=repository_root,
300
+ check=True,
301
+ capture_output=True,
302
+ text=True,
303
+ )
304
+ tracked_file = repository_root / "notes.md"
305
+ tracked_file.write_text(
306
+ "Use user@example.com in docs only.\n",
307
+ encoding="utf-8",
308
+ )
309
+ subprocess.run(
310
+ ["git", "add", "notes.md"],
311
+ cwd=repository_root,
312
+ check=True,
313
+ capture_output=True,
314
+ text=True,
315
+ )
316
+ deny_reason = evaluate_bash_command(
317
+ "git commit -m test",
318
+ working_directory=str(repository_root),
319
+ )
320
+ assert deny_reason is None
321
+
322
+
323
+ def test_staged_commit_fails_closed_when_git_list_fails(
324
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
325
+ ) -> None:
326
+ monkeypatch.setattr(
327
+ blocker_module,
328
+ "list_staged_file_paths",
329
+ lambda _repository_root: (None, "BLOCKED list failure"),
330
+ )
331
+ deny_reason = evaluate_staged_commit(tmp_path)
332
+ assert deny_reason == "BLOCKED list failure"
333
+
334
+
335
+ def test_staged_commit_fails_closed_when_blob_unscannable(
336
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
337
+ ) -> None:
338
+ monkeypatch.setattr(
339
+ blocker_module,
340
+ "list_staged_file_paths",
341
+ lambda _repository_root: (["binary.bin"], None),
342
+ )
343
+ monkeypatch.setattr(
344
+ blocker_module,
345
+ "read_staged_file_text",
346
+ lambda _repository_root, relative_path: (
347
+ None,
348
+ f"BLOCKED unscannable {relative_path}",
349
+ ),
350
+ )
351
+ deny_reason = evaluate_staged_commit(tmp_path)
352
+ assert deny_reason is not None
353
+ assert "unscannable" in deny_reason
354
+ assert "binary.bin" in deny_reason
355
+
356
+
357
+ def test_dispatcher_roster_hosts_pii_blocker() -> None:
358
+ matching_write_entries = [
359
+ each_entry
360
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
361
+ if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
362
+ ]
363
+ assert len(matching_write_entries) == 1
364
+ assert matching_write_entries[0].applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES
365
+ assert matching_write_entries[0].is_blocking is True
366
+ matching_bash_entries = [
367
+ each_entry
368
+ for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
369
+ if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
370
+ ]
371
+ assert len(matching_bash_entries) == 1
372
+ assert (
373
+ matching_bash_entries[0].applicable_tool_names
374
+ == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
375
+ )
376
+
377
+
378
+ def test_is_git_commit_shell_command_covers_windows_and_flag_forms() -> None:
379
+ assert is_git_commit_shell_command("git commit -m test")
380
+ assert is_git_commit_shell_command("git.exe commit -m test")
381
+ assert is_git_commit_shell_command("git --no-verify commit -m test")
382
+ assert is_git_commit_shell_command("git -c commit.gpgsign=false commit -m x")
383
+ assert is_git_commit_shell_command('git -C "C:/repo" commit -m x')
384
+ assert is_git_commit_shell_command(r'& "C:\Program Files\Git\cmd\git.exe" commit -m x')
385
+ assert not is_git_commit_shell_command("git status")
386
+ assert not is_git_commit_shell_command("git log --grep commit")
387
+ assert not is_git_commit_shell_command('echo "please git commit"')
388
+ assert not is_git_commit_shell_command("gh pr comment 1 --body 'git commit'")
389
+
390
+
391
+ def _init_repo_with_staged_email(repository_root: Path) -> None:
392
+ repository_root.mkdir()
393
+ subprocess.run(
394
+ ["git", "init"],
395
+ cwd=repository_root,
396
+ check=True,
397
+ capture_output=True,
398
+ text=True,
399
+ )
400
+ subprocess.run(
401
+ ["git", "config", "user.email", "dev@example.com"],
402
+ cwd=repository_root,
403
+ check=True,
404
+ capture_output=True,
405
+ text=True,
406
+ )
407
+ subprocess.run(
408
+ ["git", "config", "user.name", "Fixture Dev"],
409
+ cwd=repository_root,
410
+ check=True,
411
+ capture_output=True,
412
+ text=True,
413
+ )
414
+ tracked_file = repository_root / "notes.md"
415
+ tracked_file.write_text(
416
+ f"owner email {SYNTHETIC_REAL_EMAIL}\n",
417
+ encoding="utf-8",
418
+ )
419
+ subprocess.run(
420
+ ["git", "add", "notes.md"],
421
+ cwd=repository_root,
422
+ check=True,
423
+ capture_output=True,
424
+ text=True,
425
+ )
426
+
427
+
428
+ def _init_repo_with_clean_notes(repository_root: Path) -> None:
429
+ repository_root.mkdir()
430
+ subprocess.run(
431
+ ["git", "init"],
432
+ cwd=repository_root,
433
+ check=True,
434
+ capture_output=True,
435
+ text=True,
436
+ )
437
+ subprocess.run(
438
+ ["git", "config", "user.email", "dev@example.com"],
439
+ cwd=repository_root,
440
+ check=True,
441
+ capture_output=True,
442
+ text=True,
443
+ )
444
+ subprocess.run(
445
+ ["git", "config", "user.name", "Fixture Dev"],
446
+ cwd=repository_root,
447
+ check=True,
448
+ capture_output=True,
449
+ text=True,
450
+ )
451
+ tracked_file = repository_root / "notes.md"
452
+ tracked_file.write_text(
453
+ "Use user@example.com in docs only.\n",
454
+ encoding="utf-8",
455
+ )
456
+ subprocess.run(
457
+ ["git", "add", "notes.md"],
458
+ cwd=repository_root,
459
+ check=True,
460
+ capture_output=True,
461
+ text=True,
462
+ )
463
+
464
+
465
+ def test_git_exe_commit_scans_staged_pii(tmp_path: Path) -> None:
466
+ repository_root = tmp_path / "repo"
467
+ _init_repo_with_staged_email(repository_root)
468
+ deny_reason = evaluate_bash_command(
469
+ "git.exe commit -m test",
470
+ working_directory=str(repository_root),
471
+ )
472
+ assert deny_reason is not None
473
+ assert "email" in deny_reason
474
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
475
+
476
+
477
+ def test_git_config_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
478
+ repository_root = tmp_path / "repo"
479
+ _init_repo_with_staged_email(repository_root)
480
+ deny_reason = evaluate_bash_command(
481
+ "git -c commit.gpgsign=false commit -m test",
482
+ working_directory=str(repository_root),
483
+ )
484
+ assert deny_reason is not None
485
+ assert "email" in deny_reason
486
+
487
+
488
+ def test_git_no_verify_commit_scans_staged_pii(tmp_path: Path) -> None:
489
+ repository_root = tmp_path / "repo"
490
+ _init_repo_with_staged_email(repository_root)
491
+ deny_reason = evaluate_bash_command(
492
+ "git --no-verify commit -m test",
493
+ working_directory=str(repository_root),
494
+ )
495
+ assert deny_reason is not None
496
+ assert "email" in deny_reason
497
+
498
+
499
+ def test_repository_root_unresolved_fails_closed(
500
+ monkeypatch: pytest.MonkeyPatch,
501
+ ) -> None:
502
+ monkeypatch.setattr(blocker_module, "resolve_repository_root", lambda _cwd: None)
503
+ deny_reason = evaluate_bash_command(
504
+ "git commit -m test",
505
+ working_directory=str(Path.cwd()),
506
+ )
507
+ assert deny_reason is not None
508
+ assert "repository root" in deny_reason
509
+
510
+
511
+ def test_powershell_tool_commit_with_staged_email_is_denied(tmp_path: Path) -> None:
512
+ repository_root = tmp_path / "repo"
513
+ _init_repo_with_staged_email(repository_root)
514
+ deny_reason = evaluate(
515
+ {
516
+ "tool_name": "PowerShell",
517
+ "tool_input": {
518
+ "command": "git commit -m test",
519
+ "working_directory": str(repository_root),
520
+ },
521
+ }
522
+ )
523
+ assert deny_reason is not None
524
+ assert "email" in deny_reason
525
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
526
+
527
+
528
+ def test_is_git_commit_shell_command_detects_glued_and_newline_separators() -> None:
529
+ assert is_git_commit_shell_command("git add .;git commit -m x")
530
+ assert is_git_commit_shell_command("cd repo&&git commit -m x")
531
+ assert is_git_commit_shell_command("git add notes.md\ngit commit -m update")
532
+ assert is_git_commit_shell_command("git status\ngit commit -m x")
533
+ assert not is_git_commit_shell_command(
534
+ "gh pr comment 1 --body 'notes\ngit commit -m x'"
535
+ )
536
+
537
+
538
+ def test_is_git_commit_shell_command_detects_backgrounded_commit() -> None:
539
+ assert is_git_commit_shell_command("npm run build & git commit -am wip")
540
+ assert is_git_commit_shell_command("sleep 1 & git commit -m x")
541
+ assert is_git_commit_shell_command("& git commit -m x")
542
+ assert is_git_commit_shell_command("git commit -m x &")
543
+ assert not is_git_commit_shell_command("sleep 1 & git status")
544
+
545
+
546
+ def test_backgrounded_commit_scans_staged_pii(tmp_path: Path) -> None:
547
+ repository_root = tmp_path / "repo"
548
+ _init_repo_with_staged_email(repository_root)
549
+ deny_reason = evaluate_bash_command(
550
+ "npm run build & git commit -m test",
551
+ working_directory=str(repository_root),
552
+ )
553
+ assert deny_reason is not None
554
+ assert "email" in deny_reason
555
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
556
+
557
+
558
+ def test_newline_separated_commit_scans_staged_pii(tmp_path: Path) -> None:
559
+ repository_root = tmp_path / "repo"
560
+ _init_repo_with_staged_email(repository_root)
561
+ deny_reason = evaluate_bash_command(
562
+ "git add notes.md\ngit commit -m update",
563
+ working_directory=str(repository_root),
564
+ )
565
+ assert deny_reason is not None
566
+ assert "email" in deny_reason
567
+ assert "staged commit" in deny_reason
568
+
569
+
570
+ def test_glued_semicolon_commit_scans_staged_pii(tmp_path: Path) -> None:
571
+ repository_root = tmp_path / "repo"
572
+ _init_repo_with_staged_email(repository_root)
573
+ deny_reason = evaluate_bash_command(
574
+ "git add notes.md;git commit -m update",
575
+ working_directory=str(repository_root),
576
+ )
577
+ assert deny_reason is not None
578
+ assert "email" in deny_reason
579
+
580
+
581
+ def test_is_git_commit_shell_command_detects_shell_keyword_wrapped_commits() -> None:
582
+ assert is_git_commit_shell_command("if true; then git commit -m x; fi")
583
+ assert is_git_commit_shell_command("for f in a b; do git commit -m x; done")
584
+ assert is_git_commit_shell_command("bash -c 'git commit -m x'")
585
+ assert is_git_commit_shell_command('sh -c "git commit -m x"')
586
+
587
+
588
+ def test_is_git_commit_shell_command_detects_wrapper_prefixed_commits() -> None:
589
+ assert is_git_commit_shell_command("time git commit -m x")
590
+ assert is_git_commit_shell_command("sudo git commit -m x")
591
+ assert is_git_commit_shell_command("env GIT_AUTHOR_NAME=a git commit")
592
+ assert is_git_commit_shell_command("nice git commit -m y")
593
+ assert is_git_commit_shell_command("nice -n 10 git commit")
594
+ assert is_git_commit_shell_command("timeout 30 git commit")
595
+ assert is_git_commit_shell_command("env FOO=bar git commit")
596
+ assert not is_git_commit_shell_command("sudo cat notes.md")
597
+
598
+
599
+ def test_wrapper_prefix_stops_at_intervening_command_word() -> None:
600
+ assert not is_git_commit_shell_command("time echo git commit")
601
+ assert not is_git_commit_shell_command("sudo docker run git commit")
602
+
603
+
604
+ def test_wrapper_prefixed_commit_scans_staged_pii(tmp_path: Path) -> None:
605
+ repository_root = tmp_path / "repo"
606
+ _init_repo_with_staged_email(repository_root)
607
+ deny_reason = evaluate_bash_command(
608
+ "env GIT_AUTHOR_NAME=a git commit -m test",
609
+ working_directory=str(repository_root),
610
+ )
611
+ assert deny_reason is not None
612
+ assert "email" in deny_reason
613
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
614
+
615
+
616
+ def test_shell_keyword_wrapped_commit_scans_staged_pii(tmp_path: Path) -> None:
617
+ repository_root = tmp_path / "repo"
618
+ _init_repo_with_staged_email(repository_root)
619
+ deny_reason = evaluate_bash_command(
620
+ "time git commit -m test",
621
+ working_directory=str(repository_root),
622
+ )
623
+ assert deny_reason is not None
624
+ assert "email" in deny_reason
625
+
626
+
627
+ def test_is_git_commit_shell_command_detects_powershell_command_flag() -> None:
628
+ assert is_git_commit_shell_command('pwsh -NoProfile -Command "git commit -m x"')
629
+ assert is_git_commit_shell_command('powershell -Command "git commit -m x"')
630
+ assert is_git_commit_shell_command("pwsh.exe -Command 'git commit -m x'")
631
+ assert is_git_commit_shell_command('pwsh -c "git commit -m x"')
632
+ assert not is_git_commit_shell_command('pwsh -NoProfile -Command "git status"')
633
+
634
+
635
+ def test_is_git_commit_shell_command_detects_powershell_command_abbreviations() -> None:
636
+ assert is_git_commit_shell_command('pwsh -Com "git commit -m x"')
637
+ assert is_git_commit_shell_command('pwsh -Comm "git commit -m x"')
638
+ assert is_git_commit_shell_command('pwsh -Comma "git commit -m x"')
639
+ assert is_git_commit_shell_command('pwsh -Comman "git commit -m x"')
640
+ assert not is_git_commit_shell_command('pwsh -NoProfile "git commit -m x"')
641
+
642
+
643
+ def test_is_git_commit_shell_command_detects_flag_carrying_wrappers() -> None:
644
+ assert is_git_commit_shell_command("nice -n 10 git commit -m x")
645
+ assert is_git_commit_shell_command("stdbuf -oL git commit -m x")
646
+ assert is_git_commit_shell_command("env -i git commit -m x")
647
+ assert is_git_commit_shell_command("env -u NAME git commit -m x")
648
+ assert not is_git_commit_shell_command("nice -n 10 cat notes.md")
649
+
650
+
651
+ def test_is_git_commit_shell_command_detects_wrapper_flag_value_matching_binary() -> None:
652
+ assert is_git_commit_shell_command("sudo -u git git commit -m x")
653
+ assert is_git_commit_shell_command("sudo -u alice git commit -m x")
654
+ assert is_git_commit_shell_command("sudo -u sh git commit -m x")
655
+ assert is_git_commit_shell_command("doas -u git git commit -m x")
656
+ assert is_git_commit_shell_command("sudo -u git git commit")
657
+ assert not is_git_commit_shell_command("sudo -u git git status")
658
+
659
+
660
+ def test_is_git_commit_shell_command_detects_argument_bearing_wrappers() -> None:
661
+ assert is_git_commit_shell_command("timeout 30 git commit -m x")
662
+ assert is_git_commit_shell_command("nohup git commit -m x")
663
+ assert is_git_commit_shell_command("flock /tmp/lock git commit -m x")
664
+ assert is_git_commit_shell_command("ionice git commit -m x")
665
+ assert is_git_commit_shell_command("doas git commit -m x")
666
+ assert is_git_commit_shell_command("setsid git commit -m x")
667
+ assert not is_git_commit_shell_command("timeout 30 cat notes.md")
668
+
669
+
670
+ def test_argument_bearing_wrapper_commit_scans_staged_pii(tmp_path: Path) -> None:
671
+ repository_root = tmp_path / "repo"
672
+ _init_repo_with_staged_email(repository_root)
673
+ deny_reason = evaluate_bash_command(
674
+ "timeout 30 git commit -m test",
675
+ working_directory=str(repository_root),
676
+ )
677
+ assert deny_reason is not None
678
+ assert "email" in deny_reason
679
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
680
+
681
+
682
+ def test_is_git_commit_shell_command_detects_commit_after_hash_bearing_token() -> None:
683
+ assert is_git_commit_shell_command(
684
+ "curl https://example.com/page#section && git commit -am x"
685
+ )
686
+ assert is_git_commit_shell_command("echo done#note && git commit -m x")
687
+ assert is_git_commit_shell_command('git commit -m "fix #123"')
688
+
689
+
690
+ def test_hash_bearing_token_before_commit_scans_staged_pii(tmp_path: Path) -> None:
691
+ repository_root = tmp_path / "repo"
692
+ _init_repo_with_staged_email(repository_root)
693
+ deny_reason = evaluate_bash_command(
694
+ "curl https://example.com/page#section && git commit -m test",
695
+ working_directory=str(repository_root),
696
+ )
697
+ assert deny_reason is not None
698
+ assert "email" in deny_reason
699
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
700
+
701
+
702
+ def test_is_git_commit_shell_command_detects_combined_interpreter_flags() -> None:
703
+ assert is_git_commit_shell_command('bash -lc "git commit -m x"')
704
+ assert is_git_commit_shell_command("sh -ec 'git commit -m x'")
705
+ assert is_git_commit_shell_command('bash -l -c "git commit -m x"')
706
+ assert not is_git_commit_shell_command('bash -lc "git status"')
707
+
708
+
709
+ def test_is_git_commit_shell_command_detects_c_first_interpreter_clusters() -> None:
710
+ assert is_git_commit_shell_command('bash -cx "git commit -m x"')
711
+ assert is_git_commit_shell_command('bash -cv "git commit -m x"')
712
+ assert is_git_commit_shell_command("sh -cx 'git commit -m x'")
713
+ assert not is_git_commit_shell_command("bash -x 'git status'")
714
+
715
+
716
+ def test_is_git_commit_shell_command_detects_unquoted_powershell_command() -> None:
717
+ assert is_git_commit_shell_command("pwsh -Command git commit -m x")
718
+ assert is_git_commit_shell_command("powershell -Command git commit -m wip")
719
+ assert not is_git_commit_shell_command("pwsh -Command git status")
720
+
721
+
722
+ def test_is_git_commit_shell_command_detects_powershell_preflags_before_command() -> None:
723
+ assert is_git_commit_shell_command(
724
+ 'pwsh -ExecutionPolicy Bypass -Command "git commit -m x"'
725
+ )
726
+ assert is_git_commit_shell_command(
727
+ 'pwsh -NonInteractive -Command "git commit -m x"'
728
+ )
729
+ assert is_git_commit_shell_command(
730
+ 'powershell -ExecutionPolicy Bypass -Command "git commit -m x"'
731
+ )
732
+ assert is_git_commit_shell_command(
733
+ 'pwsh -NoProfile -NonInteractive -Command "git commit -m x"'
734
+ )
735
+ assert not is_git_commit_shell_command(
736
+ 'pwsh -NonInteractive -Command "git status"'
737
+ )
738
+
739
+
740
+ def test_is_git_commit_shell_command_detects_subshell_grouped_commit() -> None:
741
+ assert is_git_commit_shell_command("(git commit -m x)")
742
+ assert is_git_commit_shell_command("(cd repo && git commit -m x)")
743
+ assert not is_git_commit_shell_command("(git status)")
744
+
745
+
746
+ def test_powershell_preflag_command_commit_scans_staged_pii(tmp_path: Path) -> None:
747
+ repository_root = tmp_path / "repo"
748
+ _init_repo_with_staged_email(repository_root)
749
+ deny_reason = evaluate_bash_command(
750
+ 'pwsh -ExecutionPolicy Bypass -Command "git commit -m test"',
751
+ working_directory=str(repository_root),
752
+ )
753
+ assert deny_reason is not None
754
+ assert "email" in deny_reason
755
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
756
+
757
+
758
+ def test_subshell_grouped_commit_scans_staged_pii(tmp_path: Path) -> None:
759
+ repository_root = tmp_path / "repo"
760
+ _init_repo_with_staged_email(repository_root)
761
+ deny_reason = evaluate_bash_command(
762
+ "(git commit -m test)",
763
+ working_directory=str(repository_root),
764
+ )
765
+ assert deny_reason is not None
766
+ assert "email" in deny_reason
767
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
768
+
769
+
770
+ def test_powershell_command_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
771
+ repository_root = tmp_path / "repo"
772
+ _init_repo_with_staged_email(repository_root)
773
+ deny_reason = evaluate_bash_command(
774
+ 'pwsh -NoProfile -Command "git commit -m test"',
775
+ working_directory=str(repository_root),
776
+ )
777
+ assert deny_reason is not None
778
+ assert "email" in deny_reason
779
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
780
+
781
+
782
+ def test_flag_carrying_wrapper_commit_scans_staged_pii(tmp_path: Path) -> None:
783
+ repository_root = tmp_path / "repo"
784
+ _init_repo_with_staged_email(repository_root)
785
+ deny_reason = evaluate_bash_command(
786
+ "nice -n 10 git commit -m test",
787
+ working_directory=str(repository_root),
788
+ )
789
+ assert deny_reason is not None
790
+ assert "email" in deny_reason
791
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
792
+
793
+
794
+ def test_combined_interpreter_flag_commit_scans_staged_pii(tmp_path: Path) -> None:
795
+ repository_root = tmp_path / "repo"
796
+ _init_repo_with_staged_email(repository_root)
797
+ deny_reason = evaluate_bash_command(
798
+ 'bash -lc "git commit -m test"',
799
+ working_directory=str(repository_root),
800
+ )
801
+ assert deny_reason is not None
802
+ assert "email" in deny_reason
803
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
804
+
805
+
806
+ def test_c_first_interpreter_cluster_commit_scans_staged_pii(tmp_path: Path) -> None:
807
+ repository_root = tmp_path / "repo"
808
+ _init_repo_with_staged_email(repository_root)
809
+ deny_reason = evaluate_bash_command(
810
+ 'bash -cx "git commit -m test"',
811
+ working_directory=str(repository_root),
812
+ )
813
+ assert deny_reason is not None
814
+ assert "email" in deny_reason
815
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
816
+
817
+
818
+ def test_unquoted_powershell_command_commit_scans_staged_pii(tmp_path: Path) -> None:
819
+ repository_root = tmp_path / "repo"
820
+ _init_repo_with_staged_email(repository_root)
821
+ deny_reason = evaluate_bash_command(
822
+ "pwsh -Command git commit -m test",
823
+ working_directory=str(repository_root),
824
+ )
825
+ assert deny_reason is not None
826
+ assert "email" in deny_reason
827
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
828
+
829
+
830
+ def test_is_git_commit_shell_command_detects_commit_after_apostrophe_line() -> None:
831
+ assert is_git_commit_shell_command("echo Don't forget\ngit commit -am x")
832
+ assert is_git_commit_shell_command("echo can't stop\ngit commit -am 'msg'")
833
+ assert is_git_commit_shell_command("printf Bob's file\ngit commit -m note")
834
+
835
+
836
+ def test_is_git_commit_shell_command_ignores_commit_inside_quoted_body() -> None:
837
+ assert not is_git_commit_shell_command(
838
+ "gh pr comment 1 --body 'notes\ngit commit -m x'"
839
+ )
840
+
841
+
842
+ def test_apostrophe_line_before_commit_scans_staged_pii(tmp_path: Path) -> None:
843
+ repository_root = tmp_path / "repo"
844
+ _init_repo_with_staged_email(repository_root)
845
+ deny_reason = evaluate_bash_command(
846
+ "echo can't stop now\ngit commit -m test",
847
+ working_directory=str(repository_root),
848
+ )
849
+ assert deny_reason is not None
850
+ assert "email" in deny_reason
851
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
852
+
853
+
854
+ def test_is_git_commit_shell_command_detects_powershell_backtick_continuation() -> None:
855
+ assert is_git_commit_shell_command("git `\n commit -m x")
856
+ assert is_git_commit_shell_command("git commit `\n -m x")
857
+ assert not is_git_commit_shell_command("git `\n status")
858
+
859
+
860
+ def test_powershell_backtick_continuation_commit_scans_staged_pii(
861
+ tmp_path: Path,
862
+ ) -> None:
863
+ repository_root = tmp_path / "repo"
864
+ _init_repo_with_staged_email(repository_root)
865
+ deny_reason = evaluate_bash_command(
866
+ "git `\n commit -m test",
867
+ working_directory=str(repository_root),
868
+ )
869
+ assert deny_reason is not None
870
+ assert "email" in deny_reason
871
+ assert SYNTHETIC_REAL_EMAIL not in deny_reason
872
+
873
+
874
+ def test_extract_git_commit_working_directory_resolves_dash_c_forms() -> None:
875
+ assert (
876
+ extract_git_commit_working_directory(r"git.exe -C /repoA commit -m x")
877
+ == "/repoA"
878
+ )
879
+ assert (
880
+ extract_git_commit_working_directory(r"git -C /repoA commit -m x") == "/repoA"
881
+ )
882
+ assert (
883
+ extract_git_commit_working_directory(r'git -C "C:/repo" commit -m x')
884
+ == "C:/repo"
885
+ )
886
+ assert extract_git_commit_working_directory("git commit -m x") is None
887
+
888
+
889
+ def test_git_exe_dash_c_commit_scans_named_repo_not_cwd(tmp_path: Path) -> None:
890
+ repository_with_pii = tmp_path / "repo_with_pii"
891
+ _init_repo_with_staged_email(repository_with_pii)
892
+ clean_repository = tmp_path / "clean_repo"
893
+ _init_repo_with_clean_notes(clean_repository)
894
+ cwd_only_scan = evaluate_bash_command(
895
+ "git.exe commit -m test",
896
+ working_directory=str(clean_repository),
897
+ )
898
+ assert cwd_only_scan is None
899
+ dash_c_scan = evaluate_bash_command(
900
+ f"git.exe -C {repository_with_pii} commit -m test",
901
+ working_directory=str(clean_repository),
902
+ )
903
+ assert dash_c_scan is not None
904
+ assert "email" in dash_c_scan
905
+ assert SYNTHETIC_REAL_EMAIL not in dash_c_scan