claude-dev-env 2.18.0 → 2.19.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 (93) 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 +89 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/pr-cleanup/SKILL.md +17 -85
  7. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  8. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  9. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  10. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  11. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  12. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  13. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  14. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  15. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  16. package/.agents/skills/skill-builder/SKILL.md +1 -0
  17. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  18. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  19. package/.agents/skills-archived/comments/SKILL.md +1 -1
  20. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  21. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  22. package/AGENTS.md +1 -1
  23. package/_shared/pr-loop/AGENTS.md +2 -1
  24. package/_shared/pr-loop/preflight-proposal.md +56 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  26. package/bin/ever-shipped-skills.mjs +14 -0
  27. package/bin/install-constants.mjs +5 -0
  28. package/bin/install.agents-home.test.mjs +23 -0
  29. package/bin/install.codex-rules.test.mjs +61 -6
  30. package/bin/install.mjs +57 -9
  31. package/bin/install.prune.test.mjs +65 -3
  32. package/bin/install.test.mjs +202 -2
  33. package/docs/CODE_RULES.md +4 -0
  34. package/hooks/advisory/migration_safety_advisor.py +14 -14
  35. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  36. package/hooks/atomic_file_writer.py +81 -0
  37. package/hooks/blocking/AGENTS.md +2 -1
  38. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  39. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  40. package/hooks/blocking/destructive_command_blocker.py +8 -7
  41. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  42. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  43. package/hooks/blocking/sensitive_file_protector.py +8 -6
  44. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  45. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  46. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  47. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  48. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  49. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  50. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +308 -0
  51. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  52. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  53. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  54. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  55. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  56. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +42 -25
  57. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  58. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  59. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  60. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  61. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  62. package/hooks/blocking/test_pii_scanner.py +81 -0
  63. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  64. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  65. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +168 -0
  66. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  67. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  68. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  69. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  70. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  71. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  72. package/hooks/blocking/test_test_preflight_check.py +80 -0
  73. package/hooks/git-hooks/pre_commit.py +6 -2
  74. package/hooks/git-hooks/test_pre_push.py +57 -0
  75. package/hooks/hooks_constants/AGENTS.md +3 -1
  76. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  77. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  78. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  79. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  80. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  81. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  82. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  83. package/hooks/json_file_reader.py +22 -0
  84. package/hooks/pending_sidecars.py +33 -0
  85. package/hooks/test_atomic_file_writer.py +73 -0
  86. package/hooks/test_json_file_reader.py +24 -0
  87. package/hooks/test_pending_sidecars.py +25 -0
  88. package/package.json +1 -1
  89. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  90. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  91. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  92. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  93. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -61,6 +61,87 @@ def test_flags_unix_home_path() -> None:
61
61
  assert any(each.category == "home-path" for each in all_unix_hits)
62
62
 
63
63
 
64
+ def test_flags_raw_escaped_windows_home_path() -> None:
65
+ all_home_hits = scan_text_for_pii(r"path is C:\\Users\\concrete-user\\file")
66
+ assert [each.matched_text for each in all_home_hits] == [
67
+ r"C:\\Users\\concrete-user"
68
+ ]
69
+
70
+
71
+ def test_flags_unc_windows_home_path_at_each_share_depth() -> None:
72
+ all_home_hits = scan_text_for_pii(
73
+ r"path is \\server\Users\concrete-user\file"
74
+ "\n"
75
+ r"path is \\server name\My Share\Users\other-user\file"
76
+ )
77
+ assert [each.matched_text for each in all_home_hits] == [
78
+ r"\\server\Users\concrete-user",
79
+ r"\\server name\My Share\Users\other-user",
80
+ ]
81
+
82
+
83
+ def test_flags_forward_slash_unc_home_paths() -> None:
84
+ all_home_hits = scan_text_for_pii(
85
+ "path is //server/Users/concrete-user/file"
86
+ "\n"
87
+ "path is //server/share/Users/other-user/file"
88
+ )
89
+ assert [each.matched_text for each in all_home_hits] == [
90
+ "//server/Users/concrete-user",
91
+ "//server/share/Users/other-user",
92
+ ]
93
+
94
+
95
+ def test_allows_http_urls_with_users_segments() -> None:
96
+ all_home_hits = scan_text_for_pii(
97
+ "https://docs-7.example.com/Users/concrete-user/file"
98
+ ) + scan_text_for_pii("http://server-2/share/Users/concrete-user/file")
99
+ assert all_home_hits == []
100
+
101
+
102
+ def test_allows_http_urls_with_home_segments() -> None:
103
+ assert scan_text_for_pii("https://home/user/file") == []
104
+ assert scan_text_for_pii("https://Users/user/file") == []
105
+ assert scan_text_for_pii("https://[::1]/home/user/file") == []
106
+ assert scan_text_for_pii("https://[2001:db8::1]/Users/user/file") == []
107
+ assert scan_text_for_pii("https://docs-7.example.com/home/concrete-user/file") == []
108
+
109
+
110
+ def test_flags_absolute_and_file_uri_home_paths() -> None:
111
+ all_home_hits = scan_text_for_pii("path is /home/concrete-user/file")
112
+ all_home_hits += scan_text_for_pii("path is file:///home/concrete-user/file")
113
+ all_home_hits += scan_text_for_pii("path is file://server/home/concrete-user/file")
114
+ all_home_hits += scan_text_for_pii("path is FILE://server/home/other-user/file")
115
+ all_home_hits += scan_text_for_pii(
116
+ "path is FILE://server/share/home/third-user/file"
117
+ )
118
+ assert [each.matched_text for each in all_home_hits] == [
119
+ "/home/concrete-user",
120
+ "file:///home/concrete-user",
121
+ "file://server/home/concrete-user",
122
+ "FILE://server/home/other-user",
123
+ "FILE://server/share/home/third-user",
124
+ ]
125
+
126
+
127
+ def test_flags_file_uri_home_path() -> None:
128
+ all_home_hits = scan_text_for_pii(
129
+ "path is file:///Users/concrete-user/file"
130
+ "\n"
131
+ "path is file://server/Users/concrete-user/file"
132
+ "\n"
133
+ "path is FILE:///Users/other-user/file"
134
+ "\n"
135
+ "path is FILE://server/share/Users/third-user/file"
136
+ )
137
+ assert [each.matched_text for each in all_home_hits] == [
138
+ "file:///Users/concrete-user",
139
+ "file://server/Users/concrete-user",
140
+ "FILE:///Users/other-user",
141
+ "FILE://server/share/Users/third-user",
142
+ ]
143
+
144
+
64
145
  def test_flags_private_ip_and_allows_public_ip() -> None:
65
146
  all_lan_hits = scan_text_for_pii(f"host {SYNTHETIC_PRIVATE_IP}")
66
147
  all_dns_hits = scan_text_for_pii(f"dns {SYNTHETIC_PUBLIC_IP}")
@@ -42,6 +42,7 @@ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I
42
42
  from pre_tool_use_dispatcher import ( # noqa: E402, I001
43
43
  DispatcherDecision,
44
44
  HostedHookResult,
45
+ _emit_allow_decision,
45
46
  _emit_deny_decision,
46
47
  aggregate_hosted_hook_results,
47
48
  run_hosted_hook,
@@ -649,6 +650,28 @@ def test_emit_deny_does_not_cross_collapse_reason_and_context(
649
650
  assert parsed["hookSpecificOutput"]["additionalContext"] == shared_text
650
651
 
651
652
 
653
+ def test_emit_allow_preserves_system_message_and_additional_context(
654
+ capsys: pytest.CaptureFixture[str],
655
+ ) -> None:
656
+ """An allow payload carries the advisory's system and context messages."""
657
+ decision = DispatcherDecision(
658
+ should_deny=False,
659
+ should_allow=True,
660
+ all_deny_reasons=[],
661
+ all_system_messages=["system-a", "system-b"],
662
+ all_additional_context=["context-a", "context-b"],
663
+ should_suppress_output=False,
664
+ )
665
+
666
+ _emit_allow_decision(decision)
667
+
668
+ parsed = json.loads(capsys.readouterr().out)
669
+ hook_specific = parsed["hookSpecificOutput"]
670
+ assert hook_specific["permissionDecision"] == "allow"
671
+ assert hook_specific["additionalContext"] == "context-a\ncontext-b"
672
+ assert parsed["systemMessage"] == "system-a\nsystem-b"
673
+
674
+
652
675
  def test_later_hook_deny_survives_early_hook_exit() -> None:
653
676
  """Dispatcher denies even when an earlier hook exits cleanly before a later hook denies.
654
677
 
@@ -698,10 +721,16 @@ def test_dispatcher_edit_applies_both_groups() -> None:
698
721
 
699
722
 
700
723
  def test_dispatcher_multi_edit_applies_only_group_b() -> None:
701
- """MultiEdit tool triggers only Group B (8 hooks), not Group A."""
724
+ """MultiEdit applies to 9 hosted Group-B hooks, including the sensitive protector."""
702
725
  all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
703
- assert len(all_multi_edit_entries) == 8, (
704
- f"MultiEdit tool must apply to exactly 8 Group-B hooks, got {len(all_multi_edit_entries)}"
726
+ all_multi_edit_script_paths = {
727
+ each_entry.script_relative_path for each_entry in all_multi_edit_entries
728
+ }
729
+ assert "blocking/sensitive_file_protector.py" in all_multi_edit_script_paths, (
730
+ "sensitive_file_protector belongs in the MultiEdit applicable set"
731
+ )
732
+ assert len(all_multi_edit_entries) == 9, (
733
+ f"MultiEdit tool must apply to exactly 9 Group-B hooks, got {len(all_multi_edit_entries)}"
705
734
  )
706
735
 
707
736
 
@@ -1,8 +1,8 @@
1
- """Behavior tests for the precommit_code_rules_gate PreToolUse hook.
1
+ """Behavior tests for the pass-through precommit_code_rules_gate hook.
2
2
 
3
3
  Each test builds a real git repository in a temporary directory, stages
4
4
  real files, and runs the hook script as a subprocess with a PreToolUse
5
- JSON payload on stdin the exact production invocation path.
5
+ JSON payload on stdin through the exact production invocation path.
6
6
  """
7
7
 
8
8
  import json
@@ -140,10 +140,6 @@ def run_hook(bash_command: str, working_directory: Path) -> subprocess.Completed
140
140
  )
141
141
 
142
142
 
143
- def parse_denial(hook_stdout: str) -> dict:
144
- return json.loads(hook_stdout)["hookSpecificOutput"]
145
-
146
-
147
143
  def test_non_commit_command_passes_through(tmp_path: Path) -> None:
148
144
  initialize_repository(tmp_path)
149
145
  completed_hook = run_hook("git status", tmp_path)
@@ -157,20 +153,19 @@ def test_commit_with_clean_staged_python_file_is_allowed(tmp_path: Path) -> None
157
153
  completed_hook = run_hook("git commit -m add", tmp_path)
158
154
  assert completed_hook.returncode == 0
159
155
  assert completed_hook.stdout.strip() == ""
156
+ assert "Git commit proceeds to configured Git hooks." in completed_hook.stderr
160
157
 
161
158
 
162
- def test_commit_with_violating_staged_file_is_blocked(tmp_path: Path) -> None:
159
+ def test_commit_with_staged_python_file_passes_through(tmp_path: Path) -> None:
163
160
  initialize_repository(tmp_path)
164
161
  stage_file(tmp_path, "totals.py", VIOLATING_MODULE_SOURCE)
165
162
  completed_hook = run_hook("git commit -m add", tmp_path)
166
163
  assert completed_hook.returncode == 0
167
- denial = parse_denial(completed_hook.stdout)
168
- assert denial["permissionDecision"] == "deny"
169
- assert "totals.py" in denial["permissionDecisionReason"]
170
- assert "Line" in denial["permissionDecisionReason"]
164
+ assert completed_hook.stdout.strip() == ""
165
+ assert "Git commit proceeds to configured Git hooks." in completed_hook.stderr
171
166
 
172
167
 
173
- def test_git_dash_c_commit_form_is_blocked(tmp_path: Path) -> None:
168
+ def test_git_dash_c_commit_form_passes_through(tmp_path: Path) -> None:
174
169
  repository_root = tmp_path / "repo"
175
170
  repository_root.mkdir()
176
171
  initialize_repository(repository_root)
@@ -180,9 +175,8 @@ def test_git_dash_c_commit_form_is_blocked(tmp_path: Path) -> None:
180
175
  quoted_root = str(repository_root)
181
176
  completed_hook = run_hook(f'git -C "{quoted_root}" commit -m add', elsewhere)
182
177
  assert completed_hook.returncode == 0
183
- denial = parse_denial(completed_hook.stdout)
184
- assert denial["permissionDecision"] == "deny"
185
- assert "totals.py" in denial["permissionDecisionReason"]
178
+ assert completed_hook.stdout.strip() == ""
179
+ assert "Git commit proceeds to configured Git hooks." in completed_hook.stderr
186
180
 
187
181
 
188
182
  def test_commit_with_no_staged_python_files_is_allowed(tmp_path: Path) -> None:
@@ -193,7 +187,7 @@ def test_commit_with_no_staged_python_files_is_allowed(tmp_path: Path) -> None:
193
187
  assert completed_hook.stdout.strip() == ""
194
188
 
195
189
 
196
- def test_worktree_commit_reads_worktree_paired_tests_not_sibling_checkout(
190
+ def test_worktree_commit_passes_through(
197
191
  tmp_path: Path,
198
192
  ) -> None:
199
193
  main_checkout = tmp_path / "main_checkout"
@@ -213,3 +207,4 @@ def test_worktree_commit_reads_worktree_paired_tests_not_sibling_checkout(
213
207
  )
214
208
  assert completed_hook.returncode == 0
215
209
  assert completed_hook.stdout.strip() == ""
210
+ assert "Git commit proceeds to configured Git hooks." in completed_hook.stderr
@@ -0,0 +1,168 @@
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
+ import json
11
+ import shutil
12
+ import stat
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ HOOKS_ROOT = Path(__file__).resolve().parent.parent
20
+ BLOCKING_ROOT = HOOKS_ROOT / "blocking"
21
+ GIT_HOOKS_ROOT = HOOKS_ROOT / "git-hooks"
22
+ PRE_COMMIT_SOURCE_PATH = GIT_HOOKS_ROOT / "pre_commit.py"
23
+ GATE_UTILS_SOURCE_PATH = GIT_HOOKS_ROOT / "gate_utils.py"
24
+ GIT_HOOKS_CONSTANTS_SOURCE_PATH = GIT_HOOKS_ROOT / "git_hooks_constants"
25
+ DISPATCHER_SOURCE_PATH = BLOCKING_ROOT / "bash_pre_tool_use_dispatcher.py"
26
+ GIT_COMMAND_TIMEOUT_SECONDS = 60
27
+ CLEAN_MODULE_SOURCE = "def add_one(number: int) -> int:\n return number + 1\n"
28
+
29
+
30
+ def run_git(
31
+ repository_root: Path,
32
+ *git_arguments: str,
33
+ check: bool = True,
34
+ ) -> subprocess.CompletedProcess[str]:
35
+ """Run one isolated Git command and return its captured process record."""
36
+ return subprocess.run(
37
+ ["git", *git_arguments],
38
+ cwd=str(repository_root),
39
+ check=check,
40
+ capture_output=True,
41
+ text=True,
42
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
43
+ )
44
+
45
+
46
+ def initialize_repository(repository_root: Path) -> None:
47
+ """Create a repository with one committed base file and no active hook."""
48
+ run_git(repository_root, "init")
49
+ run_git(repository_root, "config", "user.name", "Hook Test")
50
+ run_git(repository_root, "config", "user.email", "hook-test@example.invalid")
51
+ run_git(repository_root, "config", "core.hooksPath", "absent_fixture_hooks")
52
+ (repository_root / "README.md").write_text("base\n", encoding="utf-8")
53
+ run_git(repository_root, "add", "README.md")
54
+ run_git(repository_root, "commit", "-m", "base")
55
+
56
+
57
+ def write_gate_script(gate_path: Path, marker_path: Path, exit_code: int) -> None:
58
+ """Write a staged-gate fixture that records arguments and exits."""
59
+ gate_path.write_text(
60
+ "import pathlib\n"
61
+ "import sys\n"
62
+ f"pathlib.Path({str(marker_path)!r}).write_text("
63
+ "'\\n'.join(sys.argv[1:]), encoding='utf-8')\n"
64
+ f"sys.exit({exit_code})\n",
65
+ encoding="utf-8",
66
+ )
67
+
68
+
69
+ def install_native_pre_commit(repository_root: Path, hooks_path: Path) -> None:
70
+ """Install the native pre-commit module and its self-contained imports."""
71
+ hooks_path.mkdir()
72
+ shutil.copyfile(PRE_COMMIT_SOURCE_PATH, hooks_path / "pre_commit.py")
73
+ shutil.copyfile(GATE_UTILS_SOURCE_PATH, hooks_path / "gate_utils.py")
74
+ shutil.copytree(
75
+ GIT_HOOKS_CONSTANTS_SOURCE_PATH,
76
+ hooks_path / "git_hooks_constants",
77
+ )
78
+ pre_commit_path = hooks_path / "pre-commit"
79
+ pre_commit_path.write_text(
80
+ "#!/usr/bin/env python3\n"
81
+ "import sys\n"
82
+ "from pathlib import Path\n"
83
+ "shim_directory = Path(__file__).resolve().parent\n"
84
+ "sys.path.insert(0, str(shim_directory))\n"
85
+ "import pre_commit\n"
86
+ "sys.exit(pre_commit.main())\n",
87
+ encoding="utf-8",
88
+ )
89
+ pre_commit_path.chmod(pre_commit_path.stat().st_mode | stat.S_IXUSR)
90
+ run_git(repository_root, "config", "core.hooksPath", str(hooks_path))
91
+
92
+
93
+ def stage_module(repository_root: Path) -> None:
94
+ """Write and stage one Python module for the commit fixture."""
95
+ (repository_root / "widget.py").write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
96
+ run_git(repository_root, "add", "widget.py")
97
+
98
+
99
+ def build_bash_payload(repository_root: Path) -> str:
100
+ """Build the Bash dispatcher payload for a commit in the fixture repository."""
101
+ commit_command = f'git -C "{repository_root}" commit -m add'
102
+ return json.dumps({"tool_name": "Bash", "tool_input": {"command": commit_command}})
103
+
104
+
105
+ def run_agent_dispatcher(repository_root: Path) -> subprocess.CompletedProcess[str]:
106
+ """Run the production Bash dispatcher against the fixture commit command."""
107
+ return subprocess.run(
108
+ [sys.executable, str(DISPATCHER_SOURCE_PATH)],
109
+ cwd=str(repository_root),
110
+ input=build_bash_payload(repository_root),
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
114
+ )
115
+
116
+
117
+ def run_native_commit(repository_root: Path) -> subprocess.CompletedProcess[str]:
118
+ """Run the real Git commit that invokes the configured native hook."""
119
+ return run_git(
120
+ repository_root,
121
+ "commit",
122
+ "-m",
123
+ "add widget",
124
+ check=False,
125
+ )
126
+
127
+
128
+ def test_agent_dispatcher_passes_commit_through_without_running_gate(
129
+ tmp_path: Path,
130
+ monkeypatch: pytest.MonkeyPatch,
131
+ ) -> None:
132
+ """The Agent Bash path continues the commit without running the staged gate."""
133
+ initialize_repository(tmp_path)
134
+ stage_module(tmp_path)
135
+ gate_marker_path = tmp_path / "agent_gate_invocation.txt"
136
+ gate_path = tmp_path / "agent_gate.py"
137
+ write_gate_script(gate_path, gate_marker_path, exit_code=1)
138
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
139
+
140
+ completed_dispatcher = run_agent_dispatcher(tmp_path)
141
+
142
+ assert completed_dispatcher.returncode == 0, completed_dispatcher.stderr
143
+ assert completed_dispatcher.stdout.strip() == ""
144
+ assert "Git commit proceeds to configured Git hooks." in completed_dispatcher.stderr
145
+ assert not gate_marker_path.exists()
146
+
147
+
148
+ @pytest.mark.parametrize("gate_exit_code, expected_commit_exit_code", [(1, 1), (0, 0)])
149
+ def test_installed_native_pre_commit_controls_staged_gate_decision(
150
+ tmp_path: Path,
151
+ monkeypatch: pytest.MonkeyPatch,
152
+ gate_exit_code: int,
153
+ expected_commit_exit_code: int,
154
+ ) -> None:
155
+ """The native Git hook runs the staged gate and returns its commit decision."""
156
+ initialize_repository(tmp_path)
157
+ native_hooks_path = tmp_path / "native_hooks"
158
+ install_native_pre_commit(tmp_path, native_hooks_path)
159
+ stage_module(tmp_path)
160
+ gate_marker_path = tmp_path / "native_gate_invocation.txt"
161
+ gate_path = tmp_path / "native_gate.py"
162
+ write_gate_script(gate_path, gate_marker_path, gate_exit_code)
163
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
164
+
165
+ completed_commit = run_native_commit(tmp_path)
166
+
167
+ assert completed_commit.returncode == expected_commit_exit_code
168
+ assert gate_marker_path.read_text(encoding="utf-8") == "--staged"
@@ -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