claude-dev-env 2.30.1 → 2.31.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 (118) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +11 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_enforcer.py +57 -31
  13. package/hooks/blocking/codex_apply_patch.py +136 -21
  14. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  15. package/hooks/blocking/conftest.py +70 -0
  16. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  17. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  18. package/hooks/blocking/pii_payload_scan.py +61 -0
  19. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  20. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  21. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  22. package/hooks/blocking/sensitive_file_protector.py +18 -3
  23. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  24. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  25. package/hooks/blocking/state_description_blocker.py +39 -26
  26. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  27. package/hooks/blocking/tdd_enforcer.py +57 -14
  28. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  29. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  30. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  31. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  32. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  33. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  36. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  38. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  40. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  41. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  42. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  45. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  46. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  48. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  49. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  50. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  51. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  52. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  53. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  54. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  55. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  56. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  57. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  58. package/hooks/blocking/test_state_description_blocker.py +68 -0
  59. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  60. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  61. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  62. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  63. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  64. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  65. package/hooks/conftest.py +47 -0
  66. package/hooks/hooks.json +7 -12
  67. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  68. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  70. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  71. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  73. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  74. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  75. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  76. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  77. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  78. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  79. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  80. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  81. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  83. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
  84. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  85. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  86. package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
  87. package/hooks/observability/session_file_edit_tracker.py +25 -4
  88. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  89. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  90. package/hooks/validation/hook_format_validator.py +23 -9
  91. package/hooks/validation/test_hook_format_validator.py +58 -0
  92. package/hooks/validators/README.md +1 -1
  93. package/hooks/validators/abbreviation_checks.py +3 -5
  94. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  95. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  96. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  97. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  98. package/hooks/validators/fast_save_validators.py +332 -0
  99. package/hooks/validators/hook_timing_harness.py +8 -4
  100. package/hooks/validators/python_style_checks.py +34 -60
  101. package/hooks/validators/python_style_helpers.py +16 -2
  102. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  103. package/hooks/validators/run_all_validators.py +42 -41
  104. package/hooks/validators/test_abbreviation_checks.py +20 -0
  105. package/hooks/validators/test_fast_save_validators.py +172 -0
  106. package/hooks/validators/test_python_style_checks.py +9 -0
  107. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  108. package/hooks/validators/test_python_style_helpers.py +35 -0
  109. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  110. package/hooks/validators/test_run_all_validators.py +46 -0
  111. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  112. package/package.json +1 -1
  113. package/rules/docstring-prose-matches-implementation.md +1 -1
  114. package/rules/git-workflow.md +2 -0
  115. package/rules/testing.md +4 -0
  116. package/scripts/codex_compat_materializer.py +5 -0
  117. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  118. package/hooks/blocking/tests/conftest.py +0 -10
@@ -6,6 +6,8 @@ import sys
6
6
  from pathlib import Path
7
7
  from types import SimpleNamespace
8
8
 
9
+ import pytest
10
+
9
11
  _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
10
12
  _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
11
13
  if _BLOCKING_DIRECTORY not in sys.path:
@@ -103,7 +105,19 @@ def test_scan_function_body_constants_does_not_flag_module_level() -> None:
103
105
  assert advisory_issues == []
104
106
 
105
107
 
106
- def test_advisory_should_not_flag_class_attribute_after_method_def() -> None:
108
+ def _advisory_stderr(source: str, capsys: pytest.CaptureFixture[str]) -> str:
109
+ """Run the advisory check over source and hand back what it wrote to stderr.
110
+
111
+ The check returns nothing, so stderr is the only place a finding shows up.
112
+ """
113
+ code_rules_enforcer.check_constants_outside_config_advisory(
114
+ source,
115
+ "example_module.py",
116
+ )
117
+ return capsys.readouterr().err
118
+
119
+
120
+ def test_advisory_should_not_flag_class_attribute_after_method_def(capsys: pytest.CaptureFixture[str]) -> None:
107
121
  source_with_class_attribute_after_method = (
108
122
  "class ExampleModel:\n"
109
123
  " def method_a(self) -> None:\n"
@@ -111,49 +125,40 @@ def test_advisory_should_not_flag_class_attribute_after_method_def() -> None:
111
125
  "\n"
112
126
  " TABLE_NAME = \"example\"\n"
113
127
  )
114
- advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
115
- source_with_class_attribute_after_method,
116
- "example_module.py",
117
- )
118
- assert advisory_issues == [], (
128
+ flagged_names = _advisory_stderr(source_with_class_attribute_after_method, capsys)
129
+ assert flagged_names == "", (
119
130
  "Class-level TABLE_NAME attribute must not be flagged as function-local"
120
131
  )
121
132
 
122
133
 
123
- def test_advisory_should_still_flag_actual_method_body_constant() -> None:
134
+ def test_advisory_should_still_flag_actual_method_body_constant(capsys: pytest.CaptureFixture[str]) -> None:
124
135
  source_with_method_body_constant = (
125
136
  "class ExampleModel:\n"
126
137
  " def method_a(self) -> None:\n"
127
138
  " MAXIMUM_RETRIES = 3\n"
128
139
  " return None\n"
129
140
  )
130
- advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
131
- source_with_method_body_constant,
132
- "example_module.py",
133
- )
134
- assert len(advisory_issues) == 1, (
135
- "Method-body UPPER_SNAKE constant must still surface as advisory"
141
+ flagged_names = _advisory_stderr(source_with_method_body_constant, capsys)
142
+ assert "MAXIMUM_RETRIES" in flagged_names, (
143
+ f"Method-body UPPER_SNAKE constant must still surface as advisory, "
144
+ f"got: {flagged_names!r}"
136
145
  )
137
- assert "MAXIMUM_RETRIES" in advisory_issues[0]
138
146
 
139
147
 
140
- def test_advisory_should_flag_annotated_function_body_constant() -> None:
148
+ def test_advisory_should_flag_annotated_function_body_constant(capsys: pytest.CaptureFixture[str]) -> None:
141
149
  source_with_annotated_function_body_constant = (
142
150
  "def example_function() -> None:\n"
143
151
  " MAXIMUM_RETRIES: int = 3\n"
144
152
  " return None\n"
145
153
  )
146
- advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
147
- source_with_annotated_function_body_constant,
148
- "example_module.py",
154
+ flagged_names = _advisory_stderr(source_with_annotated_function_body_constant, capsys)
155
+ assert "MAXIMUM_RETRIES" in flagged_names, (
156
+ f"Annotated function-body UPPER_SNAKE constant (PEP 526) must surface as advisory, "
157
+ f"got: {flagged_names!r}"
149
158
  )
150
- assert len(advisory_issues) == 1, (
151
- "Annotated function-body UPPER_SNAKE constant (PEP 526) must surface as advisory"
152
- )
153
- assert "MAXIMUM_RETRIES" in advisory_issues[0]
154
159
 
155
160
 
156
- def test_advisory_should_flag_outer_constants_after_nested_def() -> None:
161
+ def test_advisory_should_flag_outer_constants_after_nested_def(capsys: pytest.CaptureFixture[str]) -> None:
157
162
  source_with_nested_def = (
158
163
  "def outer():\n"
159
164
  " OUTER_CONST = 1\n"
@@ -161,11 +166,7 @@ def test_advisory_should_flag_outer_constants_after_nested_def() -> None:
161
166
  " INNER_CONST = 2\n"
162
167
  " ANOTHER_OUTER = 3\n"
163
168
  )
164
- advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
165
- source_with_nested_def,
166
- "example_module.py",
167
- )
168
- flagged_names = " ".join(advisory_issues)
169
+ flagged_names = _advisory_stderr(source_with_nested_def, capsys)
169
170
  assert "OUTER_CONST" in flagged_names, (
170
171
  "OUTER_CONST before nested def must be flagged"
171
172
  )
@@ -36,6 +36,8 @@ code_rules_enforcer = SimpleNamespace(
36
36
  validate_content=validate_content,
37
37
  )
38
38
 
39
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
40
+
39
41
 
40
42
  def _oversized_function_source(name: str) -> str:
41
43
  body_line_count = code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 1
@@ -33,6 +33,8 @@ from code_rules_gate_parts.tests._repo_test_helpers import ( # noqa: E402
33
33
  write_commit_and_stage_change,
34
34
  )
35
35
 
36
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
37
+
36
38
 
37
39
  PRODUCTION_FILE_PATH = "packages/app/services.py"
38
40
  ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
@@ -36,6 +36,8 @@ from code_rules_enforcer_test_support import ( # noqa: E402
36
36
  run_write_entrypoint,
37
37
  )
38
38
 
39
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
40
+
39
41
  PASS_THROUGH_ALIAS_SOURCE = (
40
42
  "def find_bare_path_segments(content: str) -> set[str]:\n"
41
43
  " return {part for part in content.split() if part}\n"
@@ -0,0 +1,43 @@
1
+ """Tests for the Codex apply_patch path-only target resolver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from codex_apply_patch import (
10
+ CODEX_ADD_OPERATION,
11
+ CodexPatchError,
12
+ codex_patch_operation_targets,
13
+ )
14
+
15
+
16
+ def test_codex_patch_operation_targets_names_every_section_without_reading_content(
17
+ tmp_path: Path, three_operation_patch: str
18
+ ) -> None:
19
+ """The path-only helper resolves every section's target without any disk read.
20
+
21
+ Unlike ``parse_codex_apply_patch``, it never reads prior file content, so a
22
+ caller that only needs each target path is not tripped up by an unreadable
23
+ prior file.
24
+ """
25
+ all_targets = codex_patch_operation_targets(three_operation_patch, str(tmp_path))
26
+
27
+ all_target_names = {
28
+ (each_operation, str(each_path).rsplit("/", 1)[-1])
29
+ for each_operation, each_path in all_targets
30
+ }
31
+ assert all_target_names == {
32
+ ("update", "updated.py"),
33
+ (CODEX_ADD_OPERATION, "added.py"),
34
+ ("delete", "deleted.py"),
35
+ }
36
+
37
+
38
+ def test_codex_patch_operation_targets_rejects_path_traversal(tmp_path: Path) -> None:
39
+ """A patch path that escapes the working directory is rejected before any read."""
40
+ escaping_patch = "*** Begin Patch\n*** Add File: ../outside.py\n+content\n*** End Patch"
41
+
42
+ with pytest.raises(CodexPatchError):
43
+ codex_patch_operation_targets(escaping_patch, str(tmp_path))
@@ -205,6 +205,74 @@ def test_extract_payload_text_returns_empty_for_unknown_tool() -> None:
205
205
  assert extracted == ("", "")
206
206
 
207
207
 
208
+ def test_every_applicable_tool_name_reads_payload_text() -> None:
209
+ """extract_payload_text reads content for every name the shared constant lists."""
210
+ for each_applicable_tool_name in hook_module.ALL_APPLICABLE_TOOL_NAMES:
211
+ _file_path, scanned_text = extract_payload_text(
212
+ each_applicable_tool_name,
213
+ {
214
+ "file_path": "foo.py",
215
+ "content": "abc",
216
+ "new_string": "abc",
217
+ "edits": [{"old_string": "a", "new_string": "abc"}],
218
+ },
219
+ )
220
+ assert scanned_text, f"{each_applicable_tool_name} must read some scanned text"
221
+
222
+
223
+ def test_extract_payload_text_reads_every_multi_edit_new_string() -> None:
224
+ extracted = extract_payload_text(
225
+ "MultiEdit",
226
+ {
227
+ "file_path": "foo.py",
228
+ "edits": [
229
+ {"old_string": "a = 1", "new_string": "a = 11"},
230
+ {"old_string": "b = 2", "new_string": "def force_rmtree(target_path):\n pass"},
231
+ ],
232
+ },
233
+ )
234
+ assert extracted[0] == "foo.py"
235
+ assert "force_rmtree" in extracted[1]
236
+
237
+
238
+ def test_main_blocks_trio_copy_introduced_by_second_multi_edit() -> None:
239
+ """A helper definition in the second edit of a MultiEdit is caught, not only the first."""
240
+ stdout_text, exit_code = _run_hook(
241
+ {
242
+ "tool_name": "MultiEdit",
243
+ "tool_input": {
244
+ "file_path": "shared_utils/samsung_utils/cert_failure_processor/parser.py",
245
+ "edits": [
246
+ {"old_string": "a = 1", "new_string": "a = 11"},
247
+ {"old_string": "b = 2", "new_string": COPIED_TRIO},
248
+ ],
249
+ },
250
+ }
251
+ )
252
+ assert exit_code == 0
253
+ response_payload = json.loads(stdout_text)
254
+ decision_block = response_payload["hookSpecificOutput"]
255
+ assert decision_block["permissionDecision"] == "deny"
256
+ assert "duplicate-rmtree-helper" in decision_block["permissionDecisionReason"]
257
+
258
+
259
+ def test_main_allows_clean_multi_edit() -> None:
260
+ stdout_text, exit_code = _run_hook(
261
+ {
262
+ "tool_name": "MultiEdit",
263
+ "tool_input": {
264
+ "file_path": "shared_utils/samsung_utils/cert_failure_processor/parser.py",
265
+ "edits": [
266
+ {"old_string": "a = 1", "new_string": "a = 11"},
267
+ {"old_string": "b = 2", "new_string": "b = 22"},
268
+ ],
269
+ },
270
+ }
271
+ )
272
+ assert exit_code == 0
273
+ assert stdout_text == ""
274
+
275
+
208
276
  def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
209
277
  captured_stdout = io.StringIO()
210
278
  captured_stderr = io.StringIO()
@@ -193,6 +193,16 @@ def test_written_content_reads_edit_new_string() -> None:
193
193
  assert written_content("Edit", edit_input) == "edited body"
194
194
 
195
195
 
196
+ def test_written_content_reads_every_multi_edit_new_string() -> None:
197
+ multi_edit_input = {
198
+ "edits": [
199
+ {"old_string": "a = 1", "new_string": "a = 11"},
200
+ {"old_string": "b = 2", "new_string": _OVERSTATED_MESSAGE_MODULE},
201
+ ]
202
+ }
203
+ assert _OVERSTATED_MESSAGE_MODULE in written_content("MultiEdit", multi_edit_input)
204
+
205
+
196
206
  def _run_main_with_io(
197
207
  input_text: str, *, is_prose_style_enabled: bool = True
198
208
  ) -> str:
@@ -248,6 +258,37 @@ def test_main_blocks_overstated_hook_module_edit() -> None:
248
258
  assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
249
259
 
250
260
 
261
+ def test_main_blocks_overstatement_introduced_by_second_multi_edit() -> None:
262
+ """An overstated claim in the second edit of a MultiEdit is caught, not only the first."""
263
+ hook_input = {
264
+ "tool_name": "MultiEdit",
265
+ "tool_input": {
266
+ "file_path": "/repo/hooks/hooks_constants/some_blocker_constants.py",
267
+ "edits": [
268
+ {"old_string": "a = 1", "new_string": "a = 11"},
269
+ {"old_string": "b = 2", "new_string": _OVERSTATED_MESSAGE_MODULE},
270
+ ],
271
+ },
272
+ }
273
+ output_text = _run_main_with_io(json.dumps(hook_input))
274
+ payload = json.loads(output_text)
275
+ assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
276
+
277
+
278
+ def test_main_passes_clean_multi_edit() -> None:
279
+ hook_input = {
280
+ "tool_name": "MultiEdit",
281
+ "tool_input": {
282
+ "file_path": "/repo/hooks/hooks_constants/some_blocker_constants.py",
283
+ "edits": [
284
+ {"old_string": "a = 1", "new_string": "a = 11"},
285
+ {"old_string": "b = 2", "new_string": _FIXED_MESSAGE_MODULE},
286
+ ],
287
+ },
288
+ }
289
+ assert _run_main_with_io(json.dumps(hook_input)) == ""
290
+
291
+
251
292
  def test_main_passes_fixed_hook_module_write() -> None:
252
293
  hook_input = {
253
294
  "tool_name": "Write",
@@ -13,12 +13,22 @@ _HOOK_DIR = Path(__file__).parent
13
13
  _HOOKS_DIR = _HOOK_DIR.parent
14
14
 
15
15
  try:
16
- from pii_payload_scan import evaluate_post_body_texts, evaluate_write_edit_payload
16
+ from pii_payload_scan import (
17
+ evaluate_apply_patch_payload,
18
+ evaluate_post_body_texts,
19
+ evaluate_write_edit_payload,
20
+ )
17
21
  except ImportError:
18
22
  for each_bootstrap_directory in (str(_HOOK_DIR), str(_HOOKS_DIR)):
19
23
  if each_bootstrap_directory not in sys.path:
20
24
  sys.path.insert(0, each_bootstrap_directory)
21
- from pii_payload_scan import evaluate_post_body_texts, evaluate_write_edit_payload
25
+ from pii_payload_scan import (
26
+ evaluate_apply_patch_payload,
27
+ evaluate_post_body_texts,
28
+ evaluate_write_edit_payload,
29
+ )
30
+
31
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
22
32
 
23
33
  _ALLOW_SLUG = "AllowOwner/allow-repo"
24
34
  _OTHER_SLUG = "OtherOwner/other-repo"
@@ -166,3 +176,56 @@ def test_allowlisted_value_under_missing_nested_parent_is_allowed(
166
176
  nested_target = repository_root / "new_dir" / "deeper" / "notes.md"
167
177
  assert not nested_target.parent.exists()
168
178
  assert _write_deny_reason(nested_target, allowed_value) is None
179
+
180
+
181
+ def test_apply_patch_add_with_pii_is_denied(tmp_path: Path) -> None:
182
+ repository_root = tmp_path / "repo"
183
+ _init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
184
+ deny_reason = evaluate_apply_patch_payload(
185
+ {
186
+ "command": (
187
+ "*** Begin Patch\n"
188
+ "*** Add File: notes.md\n"
189
+ f"+Reach me at {_assembled_fixture_email()}\n"
190
+ "*** End Patch"
191
+ )
192
+ },
193
+ hook_payload={"cwd": str(repository_root)},
194
+ )
195
+ assert deny_reason is not None
196
+ assert "email" in deny_reason
197
+
198
+
199
+ def test_apply_patch_add_with_clean_content_is_allowed(tmp_path: Path) -> None:
200
+ repository_root = tmp_path / "repo"
201
+ _init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
202
+ deny_reason = evaluate_apply_patch_payload(
203
+ {
204
+ "command": (
205
+ "*** Begin Patch\n"
206
+ "*** Add File: notes.md\n"
207
+ "+nothing sensitive here\n"
208
+ "*** End Patch"
209
+ )
210
+ },
211
+ hook_payload={"cwd": str(repository_root)},
212
+ )
213
+ assert deny_reason is None
214
+
215
+
216
+ def test_apply_patch_payload_accepts_the_tool_input_keyword(tmp_path: Path) -> None:
217
+ """evaluate_apply_patch_payload's first parameter is named all_tool_input."""
218
+ repository_root = tmp_path / "repo"
219
+ _init_repo_with_github_origin(repository_root, _ALLOW_SLUG)
220
+ deny_reason = evaluate_apply_patch_payload(
221
+ all_tool_input={
222
+ "command": (
223
+ "*** Begin Patch\n"
224
+ "*** Add File: notes.md\n"
225
+ "+nothing sensitive here\n"
226
+ "*** End Patch"
227
+ )
228
+ },
229
+ hook_payload={"cwd": str(repository_root)},
230
+ )
231
+ assert deny_reason is None
@@ -6,6 +6,7 @@ import json
6
6
  import subprocess
7
7
  import sys
8
8
  from pathlib import Path
9
+ from typing import Callable
9
10
 
10
11
  import pytest
11
12
 
@@ -37,7 +38,6 @@ from pii_prevention_blocker import ( # noqa: E402
37
38
 
38
39
  HOOK_PATH = _HOOK_DIR / "pii_prevention_blocker.py"
39
40
 
40
- SYNTHETIC_GITHUB_TOKEN = "ghp_" + ("C" * 36)
41
41
  SYNTHETIC_REAL_EMAIL = "owner.fixture@acme-corp.example.io"
42
42
  SYNTHETIC_HOME_PATH = r"C:\Users\fixture_commit_user\secret.txt"
43
43
  SYNTHETIC_PRIVATE_IP = "10.44.12.9"
@@ -173,14 +173,12 @@ def test_gh_body_file_reads_relative_path_from_working_directory(
173
173
  assert "email" in deny_reason
174
174
 
175
175
 
176
- def test_gh_post_body_with_secret_is_denied() -> None:
177
- command = (
178
- f'gh pr comment 12 --body "auth material {SYNTHETIC_GITHUB_TOKEN}"'
179
- )
176
+ def test_gh_post_body_with_secret_is_denied(synthetic_github_token: str) -> None:
177
+ command = f'gh pr comment 12 --body "auth material {synthetic_github_token}"'
180
178
  deny_reason = evaluate_bash_command(command, working_directory=None)
181
179
  assert deny_reason is not None
182
180
  assert "secret" in deny_reason
183
- assert SYNTHETIC_GITHUB_TOKEN not in deny_reason
181
+ assert synthetic_github_token not in deny_reason
184
182
 
185
183
 
186
184
  def test_gh_post_clean_body_is_allowed() -> None:
@@ -201,12 +199,12 @@ def test_mcp_github_body_with_private_ip_is_denied() -> None:
201
199
  assert "private-ip" in deny_reason
202
200
 
203
201
 
204
- def test_subprocess_hook_denies_write_with_token() -> None:
202
+ def test_subprocess_hook_denies_write_with_token(synthetic_github_token: str) -> None:
205
203
  payload = {
206
204
  "tool_name": "Write",
207
205
  "tool_input": {
208
206
  "file_path": "src/config.env.example.md",
209
- "content": f"TOKEN={SYNTHETIC_GITHUB_TOKEN}\n",
207
+ "content": f"TOKEN={synthetic_github_token}\n",
210
208
  },
211
209
  }
212
210
  exit_code, stdout_text = _run_hook(payload)
@@ -217,7 +215,7 @@ def test_subprocess_hook_denies_write_with_token() -> None:
217
215
  reason = payload_out["hookSpecificOutput"]["permissionDecisionReason"]
218
216
  assert decision == "deny"
219
217
  assert "secret" in reason
220
- assert SYNTHETIC_GITHUB_TOKEN not in reason
218
+ assert synthetic_github_token not in reason
221
219
 
222
220
 
223
221
  def test_subprocess_hook_allows_clean_write() -> None:
@@ -362,7 +360,9 @@ def test_dispatcher_roster_hosts_pii_blocker() -> None:
362
360
  if each_entry.script_relative_path == "blocking/pii_prevention_blocker.py"
363
361
  ]
364
362
  assert len(matching_write_entries) == 1
365
- assert matching_write_entries[0].applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES
363
+ assert matching_write_entries[0].applicable_tool_names == (
364
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES | {"apply_patch"}
365
+ )
366
366
  assert matching_write_entries[0].is_blocking is True
367
367
  matching_bash_entries = [
368
368
  each_entry
@@ -1202,3 +1202,73 @@ def test_post_body_still_scans_when_repository_is_exempt(
1202
1202
  )
1203
1203
  assert deny_reason is not None
1204
1204
  assert "email" in deny_reason
1205
+
1206
+
1207
+ def test_apply_patch_add_with_real_email_is_denied(
1208
+ tmp_path: Path, init_bare_git_repo: Callable[[Path], None]
1209
+ ) -> None:
1210
+ repository_root = tmp_path / "repo"
1211
+ init_bare_git_repo(repository_root)
1212
+ deny_reason = evaluate(
1213
+ {
1214
+ "tool_name": "apply_patch",
1215
+ "cwd": str(repository_root),
1216
+ "tool_input": {
1217
+ "command": (
1218
+ "*** Begin Patch\n"
1219
+ "*** Add File: notes.md\n"
1220
+ f"+Reach me at {SYNTHETIC_REAL_EMAIL}\n"
1221
+ "*** End Patch"
1222
+ )
1223
+ },
1224
+ }
1225
+ )
1226
+ assert deny_reason is not None
1227
+ assert "email" in deny_reason
1228
+
1229
+
1230
+ def test_apply_patch_add_with_clean_content_is_allowed(
1231
+ tmp_path: Path, init_bare_git_repo: Callable[[Path], None]
1232
+ ) -> None:
1233
+ repository_root = tmp_path / "repo"
1234
+ init_bare_git_repo(repository_root)
1235
+ deny_reason = evaluate(
1236
+ {
1237
+ "tool_name": "apply_patch",
1238
+ "cwd": str(repository_root),
1239
+ "tool_input": {
1240
+ "command": (
1241
+ "*** Begin Patch\n"
1242
+ "*** Add File: notes.md\n"
1243
+ "+Reach me at user@example.com\n"
1244
+ "*** End Patch"
1245
+ )
1246
+ },
1247
+ }
1248
+ )
1249
+ assert deny_reason is None
1250
+
1251
+
1252
+ def test_subprocess_hook_denies_apply_patch_add_with_token(
1253
+ tmp_path: Path,
1254
+ init_bare_git_repo: Callable[[Path], None],
1255
+ synthetic_github_token: str,
1256
+ ) -> None:
1257
+ """The standalone hook process denies an apply_patch payload carrying a token."""
1258
+ repository_root = tmp_path / "repo"
1259
+ init_bare_git_repo(repository_root)
1260
+ payload = {
1261
+ "tool_name": "apply_patch",
1262
+ "cwd": str(repository_root),
1263
+ "tool_input": {
1264
+ "command": (
1265
+ "*** Begin Patch\n"
1266
+ "*** Add File: notes.md\n"
1267
+ f"+token is {synthetic_github_token}\n"
1268
+ "*** End Patch"
1269
+ )
1270
+ },
1271
+ }
1272
+ returncode, stdout = _run_hook(payload)
1273
+ assert returncode == 0
1274
+ assert json.loads(stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"