claude-dev-env 2.18.0 → 2.20.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 (133) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -0,0 +1,234 @@
1
+ """Production-path tests for native staged-gate ownership.
2
+
3
+ The tests run the Agent Bash dispatcher and a native Git pre-commit hook against
4
+ the same isolated repository. The agent path passes the commit through. The
5
+ native path runs the staged gate and controls the commit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ import json
13
+ import shutil
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+
21
+ HOOKS_ROOT = Path(__file__).resolve().parent.parent
22
+ BLOCKING_ROOT = HOOKS_ROOT / "blocking"
23
+ GIT_HOOKS_ROOT = HOOKS_ROOT / "git-hooks"
24
+ PRE_COMMIT_SOURCE_PATH = GIT_HOOKS_ROOT / "pre_commit.py"
25
+ GATE_UTILS_SOURCE_PATH = GIT_HOOKS_ROOT / "gate_utils.py"
26
+ GIT_HOOKS_CONSTANTS_SOURCE_PATH = GIT_HOOKS_ROOT / "git_hooks_constants"
27
+ DISPATCHER_SOURCE_PATH = BLOCKING_ROOT / "bash_pre_tool_use_dispatcher.py"
28
+ GIT_COMMAND_TIMEOUT_SECONDS = 60
29
+ CLEAN_MODULE_SOURCE = "def add_one(number: int) -> int:\n return number + 1\n"
30
+
31
+
32
+ def run_git(
33
+ repository_root: Path,
34
+ *git_arguments: str,
35
+ check: bool = True,
36
+ ) -> subprocess.CompletedProcess[str]:
37
+ """Run one isolated Git command and return its captured process record."""
38
+ return subprocess.run(
39
+ ["git", *git_arguments],
40
+ cwd=str(repository_root),
41
+ check=check,
42
+ capture_output=True,
43
+ text=True,
44
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
45
+ )
46
+
47
+
48
+ def initialize_repository(repository_root: Path) -> None:
49
+ """Create a repository with one committed base file and no active hook."""
50
+ run_git(repository_root, "init")
51
+ run_git(repository_root, "config", "user.name", "Hook Test")
52
+ run_git(repository_root, "config", "user.email", "hook-test@example.invalid")
53
+ run_git(repository_root, "config", "core.hooksPath", "absent_fixture_hooks")
54
+ (repository_root / "README.md").write_text("base\n", encoding="utf-8")
55
+ run_git(repository_root, "add", "README.md")
56
+ run_git(repository_root, "commit", "-m", "base")
57
+
58
+
59
+ def write_gate_script(gate_path: Path, marker_path: Path, exit_code: int) -> None:
60
+ """Write a staged-gate fixture that records arguments and exits."""
61
+ gate_path.write_text(
62
+ "import pathlib\n"
63
+ "import sys\n"
64
+ f"pathlib.Path({str(marker_path)!r}).write_text("
65
+ "'\\n'.join(sys.argv[1:]), encoding='utf-8')\n"
66
+ f"sys.exit({exit_code})\n",
67
+ encoding="utf-8",
68
+ )
69
+
70
+
71
+ def install_native_pre_commit(hooks_path: Path) -> None:
72
+ """Install the native pre-commit module and its self-contained imports."""
73
+ hooks_path.mkdir()
74
+ shutil.copyfile(PRE_COMMIT_SOURCE_PATH, hooks_path / "pre_commit.py")
75
+ shutil.copyfile(GATE_UTILS_SOURCE_PATH, hooks_path / "gate_utils.py")
76
+ shutil.copytree(
77
+ GIT_HOOKS_CONSTANTS_SOURCE_PATH,
78
+ hooks_path / "git_hooks_constants",
79
+ )
80
+ pre_commit_path = hooks_path / "pre-commit"
81
+ pre_commit_path.write_text(
82
+ "#!/usr/bin/env python3\n"
83
+ "import sys\n"
84
+ "from pathlib import Path\n"
85
+ "shim_directory = Path(__file__).resolve().parent\n"
86
+ "sys.path.insert(0, str(shim_directory))\n"
87
+ "import pre_commit\n"
88
+ "sys.exit(pre_commit.main())\n",
89
+ encoding="utf-8",
90
+ )
91
+ pre_commit_path.chmod(pre_commit_path.stat().st_mode | stat.S_IXUSR)
92
+
93
+
94
+ def configured_hooks_path(repository_root: Path) -> str | None:
95
+ """Return the repository-local hooks path when one is configured."""
96
+ completed_config = run_git(
97
+ repository_root,
98
+ "config",
99
+ "--local",
100
+ "--get",
101
+ "core.hooksPath",
102
+ check=False,
103
+ )
104
+ if completed_config.returncode != 0:
105
+ return None
106
+ return completed_config.stdout.strip()
107
+
108
+
109
+ def restore_hooks_path(repository_root: Path, prior_hooks_path: str | None) -> None:
110
+ """Restore the repository-local hooks path captured before a benchmark."""
111
+ if prior_hooks_path is None:
112
+ run_git(
113
+ repository_root,
114
+ "config",
115
+ "--local",
116
+ "--unset-all",
117
+ "core.hooksPath",
118
+ check=False,
119
+ )
120
+ return
121
+ run_git(repository_root, "config", "--local", "core.hooksPath", prior_hooks_path)
122
+
123
+
124
+ @contextmanager
125
+ def temporary_hooks_path(repository_root: Path, benchmark_hooks_path: Path) -> Iterator[None]:
126
+ """Apply a benchmark hooks path and restore the prior local setting."""
127
+ prior_hooks_path = configured_hooks_path(repository_root)
128
+ run_git(repository_root, "config", "--local", "core.hooksPath", str(benchmark_hooks_path))
129
+ try:
130
+ yield
131
+ finally:
132
+ restore_hooks_path(repository_root, prior_hooks_path)
133
+
134
+
135
+ def stage_module(repository_root: Path) -> None:
136
+ """Write and stage one Python module for the commit fixture."""
137
+ (repository_root / "widget.py").write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
138
+ run_git(repository_root, "add", "widget.py")
139
+
140
+
141
+ def build_bash_payload(repository_root: Path) -> str:
142
+ """Build the Bash dispatcher payload for a commit in the fixture repository."""
143
+ commit_command = f'git -C "{repository_root}" commit -m add'
144
+ return json.dumps({"tool_name": "Bash", "tool_input": {"command": commit_command}})
145
+
146
+
147
+ def run_agent_dispatcher(repository_root: Path) -> subprocess.CompletedProcess[str]:
148
+ """Run the production Bash dispatcher against the fixture commit command."""
149
+ return subprocess.run(
150
+ [sys.executable, str(DISPATCHER_SOURCE_PATH)],
151
+ cwd=str(repository_root),
152
+ input=build_bash_payload(repository_root),
153
+ capture_output=True,
154
+ text=True,
155
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
156
+ )
157
+
158
+
159
+ def run_native_commit(repository_root: Path) -> subprocess.CompletedProcess[str]:
160
+ """Run the real Git commit that invokes the configured native hook."""
161
+ return run_git(
162
+ repository_root,
163
+ "commit",
164
+ "-m",
165
+ "add widget",
166
+ check=False,
167
+ )
168
+
169
+
170
+ def test_agent_dispatcher_passes_commit_through_without_running_gate(
171
+ tmp_path: Path,
172
+ monkeypatch: pytest.MonkeyPatch,
173
+ ) -> None:
174
+ """The Agent Bash path continues the commit without running the staged gate."""
175
+ initialize_repository(tmp_path)
176
+ stage_module(tmp_path)
177
+ gate_marker_path = tmp_path / "agent_gate_invocation.txt"
178
+ gate_path = tmp_path / "agent_gate.py"
179
+ write_gate_script(gate_path, gate_marker_path, exit_code=1)
180
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
181
+
182
+ completed_dispatcher = run_agent_dispatcher(tmp_path)
183
+
184
+ assert completed_dispatcher.returncode == 0, completed_dispatcher.stderr
185
+ assert completed_dispatcher.stdout.strip() == ""
186
+ assert "Git commit proceeds to configured Git hooks." in completed_dispatcher.stderr
187
+ assert not gate_marker_path.exists()
188
+
189
+
190
+ @pytest.mark.parametrize("gate_exit_code, expected_commit_exit_code", [(1, 1), (0, 0)])
191
+ def test_installed_native_pre_commit_controls_staged_gate_decision(
192
+ tmp_path: Path,
193
+ monkeypatch: pytest.MonkeyPatch,
194
+ gate_exit_code: int,
195
+ expected_commit_exit_code: int,
196
+ ) -> None:
197
+ """The native Git hook runs the staged gate and returns its commit decision."""
198
+ initialize_repository(tmp_path)
199
+ native_hooks_path = tmp_path / "native_hooks"
200
+ install_native_pre_commit(native_hooks_path)
201
+ stage_module(tmp_path)
202
+ gate_marker_path = tmp_path / "native_gate_invocation.txt"
203
+ gate_path = tmp_path / "native_gate.py"
204
+ write_gate_script(gate_path, gate_marker_path, gate_exit_code)
205
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
206
+
207
+ with temporary_hooks_path(tmp_path, native_hooks_path):
208
+ completed_commit = run_native_commit(tmp_path)
209
+
210
+ assert completed_commit.returncode == expected_commit_exit_code
211
+ assert gate_marker_path.read_text(encoding="utf-8") == "--immediate"
212
+
213
+
214
+ def test_temporary_hooks_path_restores_shared_worktree_config_on_timeout(
215
+ tmp_path: Path,
216
+ ) -> None:
217
+ initialize_repository(tmp_path)
218
+ supported_hooks_path = tmp_path / "supported_hooks"
219
+ supported_hooks_path.mkdir()
220
+ run_git(tmp_path, "config", "--local", "core.hooksPath", str(supported_hooks_path))
221
+ unrelated_worktree = tmp_path.parent / "unrelated_worktree"
222
+ run_git(tmp_path, "worktree", "add", "--detach", str(unrelated_worktree))
223
+ benchmark_hooks_path = tmp_path / "benchmark_hooks"
224
+ benchmark_hooks_path.mkdir()
225
+
226
+ try:
227
+ with pytest.raises(subprocess.TimeoutExpired):
228
+ with temporary_hooks_path(tmp_path, benchmark_hooks_path):
229
+ raise subprocess.TimeoutExpired("benchmark", 1)
230
+
231
+ assert configured_hooks_path(tmp_path) == str(supported_hooks_path)
232
+ assert configured_hooks_path(unrelated_worktree) == str(supported_hooks_path)
233
+ finally:
234
+ run_git(tmp_path, "worktree", "remove", "--force", str(unrelated_worktree))
@@ -6,7 +6,8 @@ import subprocess
6
6
  import sys
7
7
  from pathlib import Path
8
8
 
9
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
9
+ HOOK_DIRECTORY = Path(__file__).resolve().parent
10
+ sys.path.insert(0, str(HOOK_DIRECTORY))
10
11
 
11
12
  from pytest_testpaths_orphan_blocker import (
12
13
  _explicit_testpaths,
@@ -15,7 +16,7 @@ from pytest_testpaths_orphan_blocker import (
15
16
  is_test_file,
16
17
  )
17
18
 
18
- HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "pytest_testpaths_orphan_blocker.py")
19
+ HOOK_SCRIPT_PATH = HOOK_DIRECTORY / "pytest_testpaths_orphan_blocker.py"
19
20
 
20
21
  PYPROJECT_WITH_EXPLICIT_TESTPATHS = (
21
22
  "[tool.pytest.ini_options]\n"
@@ -51,21 +52,22 @@ def _write_package(package_root: Path, pyproject_text: str) -> None:
51
52
  (package_root / "pyproject.toml").write_text(pyproject_text, encoding="utf-8")
52
53
 
53
54
 
54
- class _RunHook:
55
- """Helper to test the hook via subprocess, mirroring the sibling test style."""
56
-
57
- def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
58
- payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
59
- return subprocess.run(
60
- [sys.executable, HOOK_SCRIPT_PATH],
61
- input=payload,
62
- capture_output=True,
63
- text=True,
64
- check=False,
65
- )
66
-
67
-
68
- _run_hook = _RunHook()
55
+ def _run_hook(
56
+ tool_name: str, tool_input: dict, home_directory: Path
57
+ ) -> subprocess.CompletedProcess:
58
+ """Run the hook in a subprocess with the supplied tool payload."""
59
+ payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
60
+ child_environment = os.environ.copy()
61
+ child_environment["HOME"] = str(home_directory)
62
+ child_environment["USERPROFILE"] = str(home_directory)
63
+ return subprocess.run(
64
+ [sys.executable, HOOK_SCRIPT_PATH],
65
+ input=payload,
66
+ capture_output=True,
67
+ text=True,
68
+ check=True,
69
+ env=child_environment,
70
+ )
69
71
 
70
72
 
71
73
  def test_is_test_file_accepts_test_prefixed_python_file() -> None:
@@ -129,11 +131,13 @@ def test_hook_blocks_create_of_unregistered_test_file(tmp_path: Path) -> None:
129
131
  "file_path": str(unregistered_test_file),
130
132
  "content": "def test_x() -> None:\n assert True\n",
131
133
  },
134
+ tmp_path,
132
135
  )
133
136
  decision = json.loads(completed.stdout)
134
137
  hook_output = decision["hookSpecificOutput"]
135
138
  assert hook_output["permissionDecision"] == "deny"
136
139
  assert "theme_assets/tests" in hook_output["permissionDecisionReason"]
140
+ assert (tmp_path / ".claude" / "logs" / "hook-blocks.log").is_file()
137
141
 
138
142
 
139
143
  def test_hook_allows_create_of_registered_test_file(tmp_path: Path) -> None:
@@ -146,6 +150,7 @@ def test_hook_allows_create_of_registered_test_file(tmp_path: Path) -> None:
146
150
  "file_path": str(registered_test_file),
147
151
  "content": "def test_x() -> None:\n assert True\n",
148
152
  },
153
+ tmp_path,
149
154
  )
150
155
  assert completed.stdout.strip() == ""
151
156
 
@@ -163,6 +168,7 @@ def test_hook_ignores_edit_of_existing_test_file(tmp_path: Path) -> None:
163
168
  "old_string": "assert True",
164
169
  "new_string": "assert 1 == 1",
165
170
  },
171
+ tmp_path,
166
172
  )
167
173
  assert completed.stdout.strip() == ""
168
174
 
@@ -174,6 +180,7 @@ def test_hook_ignores_non_test_python_file(tmp_path: Path) -> None:
174
180
  completed = _run_hook(
175
181
  "Write",
176
182
  {"file_path": str(production_module), "content": "VALUE = 1\n"},
183
+ tmp_path,
177
184
  )
178
185
  assert completed.stdout.strip() == ""
179
186
 
@@ -242,6 +249,6 @@ def test_hook_does_not_crash_on_scalar_tool_ancestor(tmp_path: Path) -> None:
242
249
  "file_path": str(any_test_file),
243
250
  "content": "def test_x() -> None:\n assert True\n",
244
251
  },
252
+ tmp_path,
245
253
  )
246
- assert completed.returncode == 0
247
254
  assert completed.stdout.strip() == ""
@@ -14,20 +14,33 @@ it stays denied.
14
14
  from __future__ import annotations
15
15
 
16
16
  import json
17
+ import shlex
17
18
  import subprocess
18
19
  import sys
20
+ from functools import cache
19
21
  from pathlib import Path
20
22
 
21
23
  import pytest
22
24
 
23
25
  BLOCKING_DIRECTORY = Path(__file__).resolve().parent
26
+ HOOKS_DIRECTORY = BLOCKING_DIRECTORY.parent
27
+ CLAUDE_DEV_ENV_DIRECTORY = BLOCKING_DIRECTORY.parent.parent
24
28
  HOOK_SCRIPT_PATH = BLOCKING_DIRECTORY / "sensitive_file_protector.py"
29
+ DISPATCHER_SCRIPT_PATH = BLOCKING_DIRECTORY / "pre_tool_use_dispatcher.py"
30
+ HOOKS_JSON_PATH = HOOKS_DIRECTORY / "hooks.json"
31
+ DIRECT_HOOK_TIMEOUT_SECONDS = 10
25
32
 
26
- WRITE_TOOL_NAME = "Write"
27
- EDIT_TOOL_NAME = "Edit"
28
33
  READ_TOOL_NAME = "Read"
29
34
 
30
- DENY_DECISION = "deny"
35
+ if str(HOOKS_DIRECTORY) not in sys.path:
36
+ sys.path.insert(0, str(HOOKS_DIRECTORY))
37
+
38
+ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
39
+ DENY_DECISION,
40
+ EDIT_TOOL_NAME,
41
+ MULTI_EDIT_TOOL_NAME,
42
+ WRITE_TOOL_NAME,
43
+ )
31
44
 
32
45
  PLACEHOLDER_TEMPLATE_BODY = "API_TOKEN=your-token-here\nDATABASE_HOST=localhost\n"
33
46
  ORDINARY_SOURCE_BODY = "def add(left: int, right: int) -> int:\n return left + right\n"
@@ -65,11 +78,77 @@ ALL_UPPERCASE_DENIED_FILENAMES = (".ENV", "ID_RSA", "CREDENTIALS.JSON", "SERVER.
65
78
  ALL_ORDINARY_FILENAMES = ("main.py", "README.md", "settings.json")
66
79
 
67
80
 
68
- def _run_hook(tool_name: str, file_path: Path, content: str) -> subprocess.CompletedProcess:
81
+ @cache
82
+ def _registered_dispatcher_details(
83
+ hooks_json_path: Path = HOOKS_JSON_PATH,
84
+ ) -> tuple[list[str], int]:
85
+ hooks_configuration = json.loads(hooks_json_path.read_text(encoding="utf-8"))
86
+ pre_tool_use_registrations = hooks_configuration["hooks"]["PreToolUse"]
87
+ dispatcher_registrations = []
88
+ for each_registration in pre_tool_use_registrations:
89
+ for each_hook in each_registration["hooks"]:
90
+ command_parts = shlex.split(each_hook["command"])
91
+ if Path(command_parts[-1]).name != "pre_tool_use_dispatcher.py":
92
+ continue
93
+ dispatcher_registrations.append(
94
+ (each_registration, each_hook, command_parts)
95
+ )
96
+ assert len(dispatcher_registrations) == 1, (
97
+ "hooks.json must contain exactly one pre_tool_use_dispatcher.py registration; "
98
+ f"found {len(dispatcher_registrations)}"
99
+ )
100
+ each_registration, each_hook, command_parts = dispatcher_registrations[0]
101
+ assert each_registration["matcher"] == "Write|Edit|MultiEdit", (
102
+ "dispatcher registration must cover exactly Write, Edit, and MultiEdit; "
103
+ f"got matcher {each_registration['matcher']!r}"
104
+ )
105
+ assert command_parts[0] == "python3"
106
+ dispatcher_script_path = Path(
107
+ command_parts[-1].replace(
108
+ "${CLAUDE_PLUGIN_ROOT}", str(CLAUDE_DEV_ENV_DIRECTORY)
109
+ )
110
+ )
111
+ assert dispatcher_script_path.resolve() == DISPATCHER_SCRIPT_PATH.resolve()
112
+ registered_timeout_seconds = each_hook["timeout"]
113
+ assert isinstance(registered_timeout_seconds, int)
114
+ return (
115
+ [sys.executable, str(dispatcher_script_path)],
116
+ registered_timeout_seconds,
117
+ )
118
+
119
+
120
+ def _build_file_tool_input(
121
+ tool_name: str, file_path: Path, replacement_content: str
122
+ ) -> dict[str, str | list[dict[str, str]]]:
123
+ file_path_text = str(file_path)
124
+ if tool_name == EDIT_TOOL_NAME:
125
+ return {
126
+ "file_path": file_path_text,
127
+ "old_string": "API_TOKEN=old-token\n",
128
+ "new_string": replacement_content,
129
+ }
130
+ if tool_name == MULTI_EDIT_TOOL_NAME:
131
+ return {
132
+ "file_path": file_path_text,
133
+ "edits": [
134
+ {
135
+ "old_string": "API_TOKEN=old-token\n",
136
+ "new_string": replacement_content,
137
+ }
138
+ ],
139
+ }
140
+ return {"file_path": file_path_text, "content": replacement_content}
141
+
142
+
143
+ def _run_hook(
144
+ tool_name: str, file_path: Path, replacement_content: str
145
+ ) -> subprocess.CompletedProcess:
69
146
  payload = json.dumps(
70
147
  {
71
148
  "tool_name": tool_name,
72
- "tool_input": {"file_path": str(file_path), "content": content},
149
+ "tool_input": _build_file_tool_input(
150
+ tool_name, file_path, replacement_content
151
+ ),
73
152
  }
74
153
  )
75
154
  return _run_hook_with_stdin(payload)
@@ -82,6 +161,22 @@ def _run_hook_with_stdin(stdin_text: str) -> subprocess.CompletedProcess:
82
161
  capture_output=True,
83
162
  text=True,
84
163
  check=False,
164
+ timeout=DIRECT_HOOK_TIMEOUT_SECONDS,
165
+ )
166
+
167
+
168
+ def _run_dispatcher(
169
+ tool_name: str, tool_input: dict[str, str | list[dict[str, str]]]
170
+ ) -> subprocess.CompletedProcess[str]:
171
+ payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
172
+ dispatcher_command, dispatcher_timeout_seconds = _registered_dispatcher_details()
173
+ return subprocess.run(
174
+ dispatcher_command,
175
+ input=payload,
176
+ capture_output=True,
177
+ text=True,
178
+ check=False,
179
+ timeout=dispatcher_timeout_seconds,
85
180
  )
86
181
 
87
182
 
@@ -153,6 +248,51 @@ def test_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
153
248
  assert _permission_decision(completed) == DENY_DECISION
154
249
 
155
250
 
251
+ def test_multi_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
252
+ completed = _run_hook(
253
+ MULTI_EDIT_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY
254
+ )
255
+ assert completed.returncode == 0, completed.stderr
256
+ assert _permission_decision(completed) == DENY_DECISION
257
+
258
+
259
+ def test_hooks_json_registers_exact_dispatcher_matcher_and_timeout() -> None:
260
+ dispatcher_command, dispatcher_timeout_seconds = _registered_dispatcher_details()
261
+ assert dispatcher_command[1] == str(DISPATCHER_SCRIPT_PATH)
262
+ assert dispatcher_timeout_seconds == 60
263
+
264
+
265
+ @pytest.mark.parametrize(
266
+ ("tool_name", "target_filename", "expected_permission_decision"),
267
+ [
268
+ pytest.param(WRITE_TOOL_NAME, ".env", DENY_DECISION, id="write-secret-denied"),
269
+ pytest.param(EDIT_TOOL_NAME, ".env", DENY_DECISION, id="edit-secret-denied"),
270
+ pytest.param(
271
+ MULTI_EDIT_TOOL_NAME, ".env", DENY_DECISION, id="multi-edit-secret-denied"
272
+ ),
273
+ pytest.param(WRITE_TOOL_NAME, ".env.example", None, id="write-template-allowed"),
274
+ pytest.param(EDIT_TOOL_NAME, ".env.example", None, id="edit-template-allowed"),
275
+ ],
276
+ )
277
+ def test_dispatcher_applies_sensitive_filename_contract(
278
+ tmp_path: Path,
279
+ tool_name: str,
280
+ target_filename: str,
281
+ expected_permission_decision: str | None,
282
+ ) -> None:
283
+ completed = _run_dispatcher(
284
+ tool_name,
285
+ _build_file_tool_input(
286
+ tool_name, tmp_path / target_filename, PLACEHOLDER_TEMPLATE_BODY
287
+ ),
288
+ )
289
+ assert completed.returncode == 0, completed.stderr
290
+ assert _permission_decision(completed) == expected_permission_decision, (
291
+ f"{tool_name} to {target_filename} expected "
292
+ f"{expected_permission_decision!r}, got stdout {completed.stdout!r}"
293
+ )
294
+
295
+
156
296
  def test_read_tool_is_not_evaluated(tmp_path: Path) -> None:
157
297
  completed = _run_hook(READ_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
158
298
  assert completed.returncode == 0, completed.stderr
@@ -0,0 +1,64 @@
1
+ """Production-path staging tests for the session-edit stage gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _TEST_DIRECTORY = Path(__file__).resolve().parent
9
+ if str(_TEST_DIRECTORY) not in sys.path:
10
+ sys.path.insert(0, str(_TEST_DIRECTORY))
11
+
12
+ from test_session_edit_stage_gate_support import ( # noqa: E402
13
+ make_test_directories,
14
+ parse_dispatcher_decision,
15
+ prepare_repository_with_unstaged_edit,
16
+ run_bash_dispatcher,
17
+ run_tracker_event,
18
+ read_tracker_paths,
19
+ )
20
+
21
+ SESSION_ID = "staging-production-session"
22
+
23
+
24
+ def test_dispatcher_denies_tracker_recorded_unstaged_edit(tmp_path: Path) -> None:
25
+ """The Bash dispatcher denies a commit for a tracker-recorded edit."""
26
+ repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
27
+ tracked_file = prepare_repository_with_unstaged_edit(repository_root)
28
+ tracker_run = run_tracker_event(
29
+ tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
30
+ )
31
+ assert tracker_run.returncode == 0
32
+ assert read_tracker_paths(temp_directory, SESSION_ID) == [str(tracked_file.resolve())]
33
+
34
+ dispatcher_run = run_bash_dispatcher(
35
+ "git commit -m update",
36
+ SESSION_ID,
37
+ repository_root,
38
+ temp_directory,
39
+ home_directory,
40
+ )
41
+ assert dispatcher_run.returncode == 0
42
+ decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
43
+ assert decision == "deny"
44
+ assert "widget.py" in reason
45
+
46
+
47
+ def test_dispatcher_allows_specific_staging_segment(tmp_path: Path) -> None:
48
+ """The Bash dispatcher accepts a commit preceded by a specific git add."""
49
+ repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
50
+ tracked_file = prepare_repository_with_unstaged_edit(repository_root)
51
+ tracker_run = run_tracker_event(
52
+ tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
53
+ )
54
+ assert tracker_run.returncode == 0
55
+
56
+ dispatcher_run = run_bash_dispatcher(
57
+ "git add widget.py && git commit -m update",
58
+ SESSION_ID,
59
+ repository_root,
60
+ temp_directory,
61
+ home_directory,
62
+ )
63
+ assert dispatcher_run.returncode == 0
64
+ assert parse_dispatcher_decision(dispatcher_run.stdout) == ("", "")