claude-dev-env 2.5.0 → 2.7.1

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 (153) hide show
  1. package/CLAUDE.md +20 -57
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -1
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  12. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  13. package/_shared/pr-loop/worker-spawn.md +3 -1
  14. package/agents/CLAUDE.md +1 -1
  15. package/agents/code-verifier.md +36 -7
  16. package/bin/codex-compat.mjs +104 -0
  17. package/bin/codex-compat.test.mjs +51 -0
  18. package/codex-capability-map.json +13 -0
  19. package/docs/CODE_RULES.md +2 -0
  20. package/docs/codex-compatibility.md +25 -0
  21. package/docs/nas-ssh-invocation.md +96 -12
  22. package/docs/references/code-review-enforcement.md +31 -6
  23. package/hooks/blocking/CLAUDE.md +3 -0
  24. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
  25. package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
  26. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  27. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  28. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  29. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  30. package/hooks/blocking/state_description_blocker.py +1 -1
  31. package/hooks/blocking/stop_dispatcher.py +1 -1
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  33. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  34. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  35. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  36. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  37. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
  38. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  39. package/hooks/git-hooks/CLAUDE.md +3 -0
  40. package/hooks/git-hooks/conftest.py +30 -0
  41. package/hooks/git-hooks/gate_utils.py +2 -2
  42. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  43. package/hooks/git-hooks/pre_push.py +75 -4
  44. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  45. package/hooks/git-hooks/test_config.py +0 -15
  46. package/hooks/git-hooks/test_gate_utils.py +3 -15
  47. package/hooks/git-hooks/test_pre_commit.py +1 -15
  48. package/hooks/git-hooks/test_pre_push.py +236 -27
  49. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  50. package/hooks/hooks.json +0 -12
  51. package/hooks/hooks_constants/CLAUDE.md +5 -1
  52. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  53. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  54. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  55. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  56. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  57. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  58. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  59. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  60. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  61. package/package.json +4 -2
  62. package/rules/CLAUDE.md +17 -23
  63. package/rules/agent-spawn-protocol.md +6 -6
  64. package/rules/anti-corollary-tests.md +1 -1
  65. package/rules/bdd.md +1 -1
  66. package/rules/cleanup-temp-files.md +10 -4
  67. package/rules/code-standards.md +7 -0
  68. package/rules/conservative-action.md +1 -5
  69. package/rules/context7.md +0 -4
  70. package/rules/destructive-commands.md +47 -0
  71. package/rules/doc-inventory-integrity.md +48 -0
  72. package/rules/doc-prose-cuts.md +58 -0
  73. package/rules/docstring-prose-matches-implementation.md +10 -2
  74. package/rules/durable-post-artifacts.md +0 -4
  75. package/rules/eli11-replies.md +31 -0
  76. package/rules/explore-thoroughly.md +4 -4
  77. package/rules/falsify-before-green.md +68 -0
  78. package/rules/file-global-constants.md +1 -1
  79. package/rules/filesystem-search.md +51 -0
  80. package/rules/gh-cli-conventions.md +27 -0
  81. package/rules/git-workflow.md +26 -0
  82. package/rules/hedging-claims.md +9 -0
  83. package/rules/long-horizon-autonomy.md +0 -4
  84. package/rules/measurement-denominators.md +48 -0
  85. package/rules/nas-ssh-invocation.md +23 -5
  86. package/rules/parallel-tools.md +2 -2
  87. package/rules/plain-illustrative-docstrings.md +3 -7
  88. package/rules/plain-language.md +2 -0
  89. package/rules/proof-of-work-pr-comments.md +0 -4
  90. package/rules/re-stage-before-commit.md +2 -0
  91. package/rules/research-mode.md +10 -0
  92. package/rules/shell-invocation.md +21 -0
  93. package/rules/testing.md +4 -0
  94. package/rules/verified-commit-gate-skip.md +3 -27
  95. package/rules/verify-before-asking.md +5 -0
  96. package/rules/windows-filesystem-safe.md +1 -1
  97. package/rules/workers-done-before-complete.md +4 -0
  98. package/scripts/CLAUDE.md +3 -3
  99. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  100. package/scripts/codex_capability_bridge.py +171 -0
  101. package/scripts/codex_compat_materializer.py +1087 -0
  102. package/scripts/codex_compat_watcher.py +502 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  104. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  105. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +79 -13
  106. package/scripts/grok_headless_runner.py +213 -16
  107. package/scripts/invoke_code_review.py +11 -4
  108. package/scripts/resolve_worker_spawn.py +56 -10
  109. package/scripts/spawn_grok_batch.py +43 -22
  110. package/scripts/sync_to_cursor/rules.py +0 -10
  111. package/scripts/test_grok_headless_runner.py +592 -10
  112. package/scripts/test_invoke_code_review.py +143 -0
  113. package/scripts/test_invoke_code_review_chain.py +1 -1
  114. package/scripts/test_invoke_code_review_contract.py +1 -1
  115. package/scripts/test_resolve_worker_spawn.py +179 -15
  116. package/scripts/test_spawn_grok_batch.py +225 -22
  117. package/scripts/tests/test_code_review_constants.py +80 -0
  118. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  119. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  120. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  121. package/scripts/tests/test_sync_to_cursor.py +0 -1
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +28 -6
  123. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +73 -0
  124. package/skills/autoconverge/workflow/converge.mjs +64 -15
  125. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  126. package/skills/condensing-instructions/SKILL.md +42 -51
  127. package/skills/fresh-branch/CLAUDE.md +1 -1
  128. package/skills/fresh-branch/SKILL.md +5 -6
  129. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  131. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  132. package/skills/grok-spawn/SKILL.md +5 -3
  133. package/skills/grok-spawn/reference/flag-profiles.md +3 -1
  134. package/skills/orchestrator/SKILL.md +23 -9
  135. package/skills/orchestrator-refresh/SKILL.md +20 -1
  136. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  137. package/skills/session-log/SKILL.md +1 -1
  138. package/rules/claude-md-orphan-file.md +0 -28
  139. package/rules/cleanup-command-forms.md +0 -23
  140. package/rules/code-reviews.md +0 -11
  141. package/rules/env-var-table-code-drift.md +0 -10
  142. package/rules/gh-body-file.md +0 -5
  143. package/rules/gh-paginate.md +0 -3
  144. package/rules/hook-prose-matches-detector.md +0 -15
  145. package/rules/no-historical-clutter.md +0 -26
  146. package/rules/no-inline-destructive-literals.md +0 -9
  147. package/rules/no-justification-noise.md +0 -61
  148. package/rules/package-inventory-stale-entry.md +0 -25
  149. package/rules/right-sized-engineering.md +0 -28
  150. package/rules/self-contained-docs.md +0 -17
  151. package/rules/shell-invocation-policy.md +0 -5
  152. package/rules/state-what-is.md +0 -25
  153. package/rules/tdd.md +0 -7
@@ -0,0 +1,102 @@
1
+ """Tests for unscoped_search_blocker hook."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+
11
+ SCRIPT_PATH = Path(__file__).parent / "unscoped_search_blocker.py"
12
+
13
+ ALL_DENIED_COMMANDS = [
14
+ "find / -iname code_rules_gate.py",
15
+ "find /c -name '*.py'",
16
+ "find ~ -name README.md",
17
+ "find $HOME -type f",
18
+ "ls -R /",
19
+ "ls -laR /",
20
+ "ls -Rl /",
21
+ "find //server/share -name x",
22
+ "find \\\\server\\share -name x",
23
+ "bash -c 'find / -name x'",
24
+ ]
25
+
26
+ ALL_ALLOWED_COMMANDS = [
27
+ "find . -iname '*.py'",
28
+ "find packages/claude-dev-env -name code_rules_gate.py",
29
+ "find /c/Users/example/repo -iname SKILL.md",
30
+ "ls -r /",
31
+ "ls -la /tmp/x",
32
+ "find //server/share/project/src -name x",
33
+ "es.exe path:C:\\dev\\repo ext:py gate",
34
+ "git status --short",
35
+ ]
36
+
37
+
38
+ def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
39
+ return subprocess.run(
40
+ [sys.executable, str(SCRIPT_PATH)],
41
+ input=json.dumps(payload),
42
+ text=True,
43
+ capture_output=True,
44
+ check=False,
45
+ )
46
+
47
+
48
+ def _bash_payload(command: str) -> dict:
49
+ return {"tool_name": "Bash", "tool_input": {"command": command}}
50
+
51
+
52
+ def _powershell_payload(command: str) -> dict:
53
+ return {"tool_name": "PowerShell", "tool_input": {"command": command}}
54
+
55
+
56
+ @pytest.mark.parametrize("each_command", ALL_DENIED_COMMANDS)
57
+ def test_denies_unscoped_root_walk(each_command: str) -> None:
58
+ response = json.loads(_run_hook(_bash_payload(each_command)).stdout)
59
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
60
+
61
+
62
+ @pytest.mark.parametrize("each_command", ALL_ALLOWED_COMMANDS)
63
+ def test_allows_scoped_search(each_command: str) -> None:
64
+ assert _run_hook(_bash_payload(each_command)).stdout == ""
65
+
66
+
67
+ def test_denies_recursive_listing_on_windows_drive_root() -> None:
68
+ response = json.loads(
69
+ _run_hook(_powershell_payload("Get-ChildItem -Path C:\\ -Recurse")).stdout
70
+ )
71
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
72
+
73
+
74
+ def test_allows_recursive_listing_under_project_directory() -> None:
75
+ assert _run_hook(_powershell_payload("Get-ChildItem -Path .\\src -Recurse")).stdout == ""
76
+
77
+
78
+ def test_allows_recursive_listing_without_recurse_flag() -> None:
79
+ assert _run_hook(_powershell_payload("Get-ChildItem -Path C:\\")).stdout == ""
80
+
81
+
82
+ def test_denies_find_root_in_a_chained_segment() -> None:
83
+ response = json.loads(
84
+ _run_hook(_bash_payload("cd /tmp && find / -name x.py")).stdout
85
+ )
86
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
87
+
88
+
89
+ def test_ignores_unsupported_tool_name() -> None:
90
+ payload = {"tool_name": "Edit", "tool_input": {"command": "find / -name x"}}
91
+ assert _run_hook(payload).stdout == ""
92
+
93
+
94
+ def test_ignores_payload_without_a_command() -> None:
95
+ payload = {"tool_name": "Bash", "tool_input": {}}
96
+ assert _run_hook(payload).stdout == ""
97
+
98
+
99
+ def test_deny_message_names_the_scoped_alternative() -> None:
100
+ response = json.loads(_run_hook(_bash_payload("find / -name x.py")).stdout)
101
+ deny_reason = response["hookSpecificOutput"]["permissionDecisionReason"]
102
+ assert "scope" in deny_reason.lower()
@@ -741,20 +741,16 @@ def _assert_bash_dispatcher_registered_for_matcher(matcher_substring: str) -> No
741
741
  )
742
742
 
743
743
 
744
- def test_guard_is_registered_on_bash() -> None:
744
+ def test_guard_is_not_registered_on_bash() -> None:
745
745
  _assert_bash_dispatcher_registered_for_matcher("Bash")
746
746
  matching_entries = _verdict_guard_hosted_entries()
747
- assert len(matching_entries) == 1
748
- assert matching_entries[0].applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
749
- assert BASH_TOOL_NAME in matching_entries[0].applicable_tool_names
747
+ assert matching_entries == []
750
748
 
751
749
 
752
- def test_guard_is_registered_on_powershell() -> None:
750
+ def test_guard_is_not_registered_on_powershell() -> None:
753
751
  _assert_bash_dispatcher_registered_for_matcher("PowerShell")
754
752
  matching_entries = _verdict_guard_hosted_entries()
755
- assert len(matching_entries) == 1
756
- assert matching_entries[0].applicable_tool_names == ALL_BASH_AND_POWERSHELL_TOOL_NAMES
757
- assert POWERSHELL_TOOL_NAME in matching_entries[0].applicable_tool_names
753
+ assert matching_entries == []
758
754
 
759
755
 
760
756
  def test_hook_subprocess_imports_real_config_when_parent_holds_shadowing_config(
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: deny drive-root, bare-home, and share-root filesystem searches.
3
+
4
+ Blocks Bash/PowerShell commands that walk from an unscoped root — ``find /``,
5
+ ``find C:\\``, ``find ~``, ``find //server/share``, recursive ``Get-ChildItem``
6
+ on a drive root — so a session cannot thrash the host with millions of handles.
7
+ Scoped walks under a project path stay allowed. Parallel shell storms are out of
8
+ scope for this detector; the deny message still steers agents to batch shell work.
9
+
10
+ ::
11
+
12
+ find / -iname code_rules_gate.py flag: unscoped root
13
+ find . -iname code_rules_gate.py ok: cwd scope
14
+ find //server/share -name x flag: network share root
15
+ find //server/share/project -name x ok: scoped under the share
16
+ Get-ChildItem -Path C:\\ -Recurse flag: recursive drive root
17
+ Get-ChildItem -Path .\\src -Recurse ok: project scope
18
+ ls -r / ok: reverse sort, not recurse
19
+ ls -R / flag: recursive root listing
20
+ ls -laR / flag: clustered short options
21
+ bash -c 'find / -name x' flag: string-exec unwrap
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import shlex
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
32
+ if _hooks_dir not in sys.path:
33
+ sys.path.insert(0, _hooks_dir)
34
+
35
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
36
+ from hooks_constants.shell_command_segments import ( # noqa: E402
37
+ effective_leading_program,
38
+ split_into_segments,
39
+ token_basename,
40
+ )
41
+ from hooks_constants.unscoped_search_blocker_constants import ( # noqa: E402
42
+ ALL_FIND_EXPRESSION_INTRODUCER_TOKENS,
43
+ ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE,
44
+ ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE,
45
+ ALL_FIND_PROGRAM_BASENAMES,
46
+ ALL_LISTING_PROGRAM_BASENAMES,
47
+ ALL_POWERSHELL_PATH_FLAG_PREFIXES,
48
+ ALL_POWERSHELL_PATH_FLAGS,
49
+ ALL_POWERSHELL_RECURSE_FLAG_PREFIXES,
50
+ ALL_POWERSHELL_RECURSE_FLAGS,
51
+ ALL_STRING_EXEC_COMMAND_FLAGS,
52
+ ALL_STRING_EXECUTING_SHELL_BASENAMES,
53
+ ALL_SUPPORTED_TOOL_NAMES,
54
+ ALL_TRUTHY_RECURSE_COLON_VALUES,
55
+ ALL_UNIX_LS_PROGRAM_BASENAMES,
56
+ ALL_UNIX_LS_RECURSE_FLAGS,
57
+ ALL_UNSCOPED_HOME_LITERALS_CASEFOLD,
58
+ CALLING_HOOK_NAME,
59
+ CLUSTERED_SHORT_OPTION_PATTERN,
60
+ CORRECTIVE_MESSAGE,
61
+ DENY_DECISION,
62
+ FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX,
63
+ FLAG_AND_VALUE_TOKEN_STRIDE,
64
+ GIT_BASH_DRIVE_ROOT_PATTERN,
65
+ HOOK_EVENT_NAME,
66
+ POSIX_ROOT_ALIAS_PATTERN,
67
+ UNC_SHARE_ROOT_PATTERN,
68
+ UNIX_LS_RECURSE_SHORT_OPTION_LETTER,
69
+ WINDOWS_DRIVE_ROOT_PATTERN,
70
+ )
71
+
72
+
73
+ def is_unscoped_search_root(path_token: str) -> bool:
74
+ """Return True when a path token is a drive, home, or network-share root.
75
+
76
+ ::
77
+
78
+ / flag
79
+ /. flag (posix root alias)
80
+ /c/ flag (Git Bash drive root)
81
+ C:\\ flag
82
+ ~ flag
83
+ //server/share flag (UNC share root)
84
+ //server/share/src ok (scoped under the share)
85
+ ./src ok
86
+ /c/Users/x ok
87
+
88
+ Args:
89
+ path_token: One shell token treated as a search start path.
90
+
91
+ Returns:
92
+ True when the token names an unscoped filesystem root.
93
+ """
94
+ stripped_path = path_token.strip().strip("\"'")
95
+ if not stripped_path:
96
+ return False
97
+ if stripped_path.casefold() in ALL_UNSCOPED_HOME_LITERALS_CASEFOLD:
98
+ return True
99
+ forward_slash_path = stripped_path.replace("\\", "/")
100
+ if POSIX_ROOT_ALIAS_PATTERN.fullmatch(forward_slash_path):
101
+ return True
102
+ if GIT_BASH_DRIVE_ROOT_PATTERN.fullmatch(forward_slash_path):
103
+ return True
104
+ if UNC_SHARE_ROOT_PATTERN.fullmatch(forward_slash_path):
105
+ return True
106
+ if WINDOWS_DRIVE_ROOT_PATTERN.fullmatch(stripped_path):
107
+ return True
108
+ windows_drive_alias = stripped_path.rstrip(".\\/")
109
+ if WINDOWS_DRIVE_ROOT_PATTERN.fullmatch(windows_drive_alias + "\\"):
110
+ return True
111
+ if WINDOWS_DRIVE_ROOT_PATTERN.fullmatch(windows_drive_alias):
112
+ return True
113
+ return False
114
+
115
+
116
+ def _tokens_after_leading_program(
117
+ all_segment_tokens: list[str], leading_program: str | None
118
+ ) -> list[str]:
119
+ if leading_program is None:
120
+ return []
121
+ leading_index = all_segment_tokens.index(leading_program)
122
+ return all_segment_tokens[leading_index + 1 :]
123
+
124
+
125
+ def _is_find_expression_token(token: str) -> bool:
126
+ if token in ALL_FIND_EXPRESSION_INTRODUCER_TOKENS:
127
+ return True
128
+ return token.startswith("-")
129
+
130
+
131
+ def _collect_find_starting_points(all_argument_tokens: list[str]) -> list[str]:
132
+ all_starting_points: list[str] = []
133
+ token_index = 0
134
+ while token_index < len(all_argument_tokens):
135
+ each_token = all_argument_tokens[token_index]
136
+ if each_token in ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE:
137
+ token_index += 1
138
+ continue
139
+ if each_token in ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE:
140
+ if token_index + 1 >= len(all_argument_tokens):
141
+ token_index += 1
142
+ continue
143
+ next_token = all_argument_tokens[token_index + 1]
144
+ if is_unscoped_search_root(next_token) or _is_find_expression_token(
145
+ next_token
146
+ ):
147
+ token_index += 1
148
+ continue
149
+ token_index += FLAG_AND_VALUE_TOKEN_STRIDE
150
+ continue
151
+ if each_token.startswith(FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX):
152
+ token_index += 1
153
+ continue
154
+ if _is_find_expression_token(each_token):
155
+ break
156
+ all_starting_points.append(each_token)
157
+ token_index += 1
158
+ return all_starting_points
159
+
160
+
161
+ def _segment_has_unscoped_find(all_segment_tokens: list[str]) -> bool:
162
+ leading_program = effective_leading_program(all_segment_tokens)
163
+ if leading_program is None:
164
+ return False
165
+ if token_basename(leading_program) not in ALL_FIND_PROGRAM_BASENAMES:
166
+ return False
167
+ all_starting_points = _collect_find_starting_points(
168
+ _tokens_after_leading_program(all_segment_tokens, leading_program)
169
+ )
170
+ return any(
171
+ is_unscoped_search_root(each_start) for each_start in all_starting_points
172
+ )
173
+
174
+
175
+ def _token_is_unix_ls_recurse_flag(token: str) -> bool:
176
+ """Return True for an ls token that turns the listing recursive.
177
+
178
+ ::
179
+
180
+ -R flag
181
+ -laR flag (clustered short options)
182
+ -Rl flag
183
+ --recursive flag
184
+ -r ok (reverse sort, not recurse)
185
+ -la ok
186
+
187
+ Args:
188
+ token: One argument token from an ``ls`` segment.
189
+
190
+ Returns:
191
+ True when the token carries the recursive option.
192
+ """
193
+ if token in ALL_UNIX_LS_RECURSE_FLAGS:
194
+ return True
195
+ if not CLUSTERED_SHORT_OPTION_PATTERN.fullmatch(token):
196
+ return False
197
+ return UNIX_LS_RECURSE_SHORT_OPTION_LETTER in token
198
+
199
+
200
+ def _token_is_recurse_flag(token: str, program_basename: str) -> bool:
201
+ if program_basename in ALL_UNIX_LS_PROGRAM_BASENAMES:
202
+ return _token_is_unix_ls_recurse_flag(token)
203
+ lowered_token = token.lower()
204
+ if lowered_token in ALL_POWERSHELL_RECURSE_FLAGS:
205
+ return True
206
+ for each_prefix in ALL_POWERSHELL_RECURSE_FLAG_PREFIXES:
207
+ if lowered_token.startswith(each_prefix):
208
+ colon_value = lowered_token[len(each_prefix) :]
209
+ return colon_value in ALL_TRUTHY_RECURSE_COLON_VALUES
210
+ return False
211
+
212
+
213
+ def _path_value_from_path_flag_token(token: str) -> str | None:
214
+ lowered_token = token.lower()
215
+ for each_prefix in ALL_POWERSHELL_PATH_FLAG_PREFIXES:
216
+ if lowered_token.startswith(each_prefix):
217
+ return token[len(each_prefix) :]
218
+ return None
219
+
220
+
221
+ def _collect_get_child_item_path_tokens(all_argument_tokens: list[str]) -> list[str]:
222
+ all_path_tokens: list[str] = []
223
+ token_index = 0
224
+ while token_index < len(all_argument_tokens):
225
+ each_token = all_argument_tokens[token_index]
226
+ lowered_token = each_token.lower()
227
+ path_from_glued_flag = _path_value_from_path_flag_token(each_token)
228
+ if path_from_glued_flag is not None:
229
+ if path_from_glued_flag:
230
+ all_path_tokens.append(path_from_glued_flag)
231
+ token_index += 1
232
+ continue
233
+ if lowered_token in ALL_POWERSHELL_PATH_FLAGS:
234
+ if token_index + 1 < len(all_argument_tokens):
235
+ all_path_tokens.append(all_argument_tokens[token_index + 1])
236
+ token_index += FLAG_AND_VALUE_TOKEN_STRIDE
237
+ continue
238
+ if lowered_token == "/s" or each_token.startswith("-"):
239
+ token_index += 1
240
+ continue
241
+ all_path_tokens.append(each_token)
242
+ token_index += 1
243
+ return all_path_tokens
244
+
245
+
246
+ def _segment_has_unscoped_recursive_listing(all_segment_tokens: list[str]) -> bool:
247
+ leading_program = effective_leading_program(all_segment_tokens)
248
+ if leading_program is None:
249
+ return False
250
+ program_basename = token_basename(leading_program)
251
+ if program_basename not in ALL_LISTING_PROGRAM_BASENAMES:
252
+ return False
253
+ all_argument_tokens = _tokens_after_leading_program(
254
+ all_segment_tokens, leading_program
255
+ )
256
+ has_recurse_flag = any(
257
+ _token_is_recurse_flag(each_token, program_basename)
258
+ for each_token in all_argument_tokens
259
+ )
260
+ if not has_recurse_flag:
261
+ return False
262
+ all_path_tokens = _collect_get_child_item_path_tokens(all_argument_tokens)
263
+ if not all_path_tokens:
264
+ return False
265
+ return any(
266
+ is_unscoped_search_root(each_path) for each_path in all_path_tokens
267
+ )
268
+
269
+
270
+ def _string_exec_inner_command(all_segment_tokens: list[str]) -> str | None:
271
+ leading_program = effective_leading_program(all_segment_tokens)
272
+ if leading_program is None:
273
+ return None
274
+ if token_basename(leading_program) not in ALL_STRING_EXECUTING_SHELL_BASENAMES:
275
+ return None
276
+ all_argument_tokens = _tokens_after_leading_program(
277
+ all_segment_tokens, leading_program
278
+ )
279
+ token_index = 0
280
+ while token_index < len(all_argument_tokens):
281
+ each_token = all_argument_tokens[token_index]
282
+ if each_token.lower() in ALL_STRING_EXEC_COMMAND_FLAGS:
283
+ if token_index + 1 < len(all_argument_tokens):
284
+ return all_argument_tokens[token_index + 1]
285
+ return None
286
+ token_index += 1
287
+ return None
288
+
289
+
290
+ def _all_command_tokenizations(command: str) -> list[list[str]]:
291
+ """Return POSIX and non-POSIX tokenizations so Windows paths stay intact.
292
+
293
+ Git Bash commands need POSIX splitting (quote rules). PowerShell paths like
294
+ ``C:\\`` need non-POSIX splitting so the backslash is not an escape. Both
295
+ are tried; an empty or failed split is dropped.
296
+ """
297
+ all_tokenizations: list[list[str]] = []
298
+ for each_posix_mode in (True, False):
299
+ try:
300
+ all_tokens = shlex.split(command, posix=each_posix_mode)
301
+ except ValueError:
302
+ continue
303
+ if all_tokens:
304
+ all_tokenizations.append(all_tokens)
305
+ return all_tokenizations
306
+
307
+
308
+ def find_unscoped_search_violation(command: str) -> str | None:
309
+ """Return the deny message for an unscoped tree walk, or None to allow.
310
+
311
+ ::
312
+
313
+ find / -iname x.py flag
314
+ find packages -iname x.py ok
315
+ Get-ChildItem -Path C:\\ -Recurse flag
316
+ Get-ChildItem -Path .\\src -Recurse ok
317
+ find //server/share -name x flag
318
+ find //server/share/project -name x ok
319
+ bash -c 'find / -name x' flag
320
+ ls -r / ok
321
+ ls -R / flag
322
+ ls -laR / flag
323
+
324
+ Tokenizes the command under POSIX and non-POSIX shlex modes, splits on shell
325
+ control operators, and evaluates each simple-command segment. A ``find``
326
+ segment with an unscoped starting point denies. A recursive listing segment
327
+ (PowerShell ``Get-ChildItem``/``gci``/``dir`` or Unix ``ls -R``, including a
328
+ clustered short-option token such as ``-laR``) with an unscoped path denies.
329
+ A ``bash -c`` / ``pwsh -Command`` wrapper re-runs the check on its string
330
+ argument. Returns None when neither tokenization yields tokens.
331
+
332
+ Args:
333
+ command: The raw Bash or PowerShell command string from the tool input.
334
+
335
+ Returns:
336
+ The corrective deny message when a segment walks from an unscoped root,
337
+ else None.
338
+ """
339
+ for each_tokenization in _all_command_tokenizations(command):
340
+ for each_segment in split_into_segments(each_tokenization):
341
+ if _segment_has_unscoped_find(each_segment):
342
+ return CORRECTIVE_MESSAGE
343
+ if _segment_has_unscoped_recursive_listing(each_segment):
344
+ return CORRECTIVE_MESSAGE
345
+ inner_command = _string_exec_inner_command(each_segment)
346
+ if inner_command is not None:
347
+ nested_denial = find_unscoped_search_violation(inner_command)
348
+ if nested_denial is not None:
349
+ return nested_denial
350
+ return None
351
+
352
+
353
+ def main() -> None:
354
+ try:
355
+ hook_input = json.load(sys.stdin)
356
+ except json.JSONDecodeError:
357
+ sys.exit(0)
358
+
359
+ tool_name = hook_input.get("tool_name", "")
360
+ if tool_name not in ALL_SUPPORTED_TOOL_NAMES:
361
+ sys.exit(0)
362
+
363
+ command = hook_input.get("tool_input", {}).get("command", "")
364
+ if not command:
365
+ sys.exit(0)
366
+
367
+ deny_reason = find_unscoped_search_violation(command)
368
+ if deny_reason is None:
369
+ sys.exit(0)
370
+
371
+ deny_payload = {
372
+ "hookSpecificOutput": {
373
+ "hookEventName": HOOK_EVENT_NAME,
374
+ "permissionDecision": DENY_DECISION,
375
+ "permissionDecisionReason": deny_reason,
376
+ }
377
+ }
378
+ log_hook_block(
379
+ calling_hook_name=CALLING_HOOK_NAME,
380
+ hook_event=HOOK_EVENT_NAME,
381
+ block_reason=deny_reason,
382
+ tool_name=tool_name,
383
+ offending_input_preview=command,
384
+ )
385
+ print(json.dumps(deny_payload))
386
+ sys.stdout.flush()
387
+ sys.exit(0)
388
+
389
+
390
+ if __name__ == "__main__":
391
+ main()
@@ -8,12 +8,15 @@ Native git hooks that run outside the Claude Code lifecycle — invoked directly
8
8
  |---|---|---|
9
9
  | `pre_commit.py` | `pre-commit` | Runs the CODE_RULES gate (`precommit_code_rules_gate.py`) over staged changes; exits 1 when any staged file has a blocking violation |
10
10
  | `pre_push.py` | `pre-push` | Blocks a push that would land a non-`main` local branch onto remote `main` (or `master`), then runs the CODE_RULES gate over the commits about to be pushed |
11
+ | `pre_push_base_reference.py` | — | Resolves the gate base for `pre_push.py`: reads the pushed remote name from git's arguments, then names that remote's default branch from its symbolic ref or its candidate default branches |
11
12
  | `post_commit.py` | `post-commit` | Runs after a commit lands; performs any post-commit bookkeeping |
12
13
  | `gate_utils.py` | — | Shared helpers: resolves the gate script path, checks that the path is a safe regular file |
13
14
  | `test_config.py` | — | Test configuration helpers |
14
15
  | `test_gate_utils.py` | — | Tests for `gate_utils.py` |
15
16
  | `test_pre_commit.py` | — | Tests for `pre_commit.py` |
16
17
  | `test_pre_push.py` | — | Tests for `pre_push.py` |
18
+ | `test_pre_push_base_reference.py` | — | Tests for the gate base resolution in `pre_push.py` |
19
+ | `conftest.py` | — | Puts this directory on `sys.path` and clears cached `config` modules, so each test module imports the hooks at the top of the file |
17
20
 
18
21
  ## Subdirectory
19
22
 
@@ -0,0 +1,30 @@
1
+ """Put this directory on ``sys.path`` before the git-hook test modules import.
2
+
3
+ The git-hook entry points sit beside their constants package rather than inside
4
+ an installed package, so ``import pre_push`` resolves only once this directory
5
+ leads ``sys.path``. Pytest loads a conftest before the test modules it covers,
6
+ so doing the insertion here lets each test module import the hooks at the top
7
+ of the file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ _GIT_HOOKS_DIRECTORY_STRING = str(Path(__file__).resolve().parent)
18
+ _CONFIG_PACKAGE_NAME = "config"
19
+ _CONFIG_PACKAGE_PREFIX = "config."
20
+
21
+ while _GIT_HOOKS_DIRECTORY_STRING in sys.path:
22
+ sys.path.remove(_GIT_HOOKS_DIRECTORY_STRING)
23
+ sys.path.insert(0, _GIT_HOOKS_DIRECTORY_STRING)
24
+
25
+ for each_module_name in list(sys.modules):
26
+ if each_module_name == _CONFIG_PACKAGE_NAME or each_module_name.startswith(
27
+ _CONFIG_PACKAGE_PREFIX
28
+ ):
29
+ del sys.modules[each_module_name]
30
+ importlib.invalidate_caches()
@@ -7,10 +7,10 @@ import stat
7
7
  from pathlib import Path
8
8
 
9
9
  from git_hooks_constants import (
10
+ ALL_GATE_SCRIPT_RELATIVE_PATH,
10
11
  CLAUDE_HOME_DEFAULT_SUBDIRECTORY,
11
12
  CLAUDE_HOME_ENV_VAR,
12
13
  GATE_PATH_OVERRIDE_ENV_VAR,
13
- GATE_SCRIPT_RELATIVE_PATH,
14
14
  )
15
15
 
16
16
 
@@ -34,7 +34,7 @@ def resolve_gate_script_path() -> tuple[Path, Path | None]:
34
34
  claude_home_directory = Path(claude_home_override).resolve()
35
35
  else:
36
36
  claude_home_directory = Path.home() / CLAUDE_HOME_DEFAULT_SUBDIRECTORY
37
- gate_path = claude_home_directory.joinpath(*GATE_SCRIPT_RELATIVE_PATH)
37
+ gate_path = claude_home_directory.joinpath(*ALL_GATE_SCRIPT_RELATIVE_PATH)
38
38
  return gate_path, None
39
39
 
40
40
 
@@ -9,7 +9,6 @@ import ...`` resolve against this file both inside the repo and under
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
-
13
12
  STAGED_SCOPE_ARGUMENT: str = "--staged"
14
13
  BASE_REFERENCE_ARGUMENT: str = "--base"
15
14
  DEFAULT_REMOTE_BASE_REFERENCE: str = "origin/HEAD"
@@ -21,7 +20,7 @@ REMOTE_REFERENCE_FIELD_INDEX: int = 2
21
20
  GATE_PATH_OVERRIDE_ENV_VAR: str = "CODE_RULES_GATE_PATH"
22
21
  CLAUDE_HOME_ENV_VAR: str = "CLAUDE_HOME"
23
22
  CLAUDE_HOME_DEFAULT_SUBDIRECTORY: str = ".claude"
24
- GATE_SCRIPT_RELATIVE_PATH: tuple[str, ...] = (
23
+ ALL_GATE_SCRIPT_RELATIVE_PATH: tuple[str, ...] = (
25
24
  "_shared",
26
25
  "pr-loop",
27
26
  "scripts",
@@ -65,3 +64,43 @@ PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE: str = (
65
64
  "To push the feature branch to its own ref, name the destination: "
66
65
  "git push origin {local_branch}:refs/heads/{local_branch}"
67
66
  )
67
+ GIT_EXECUTABLE_NAME: str = "git"
68
+ GIT_COMMAND_SUCCESS_EXIT_CODE: int = 0
69
+ GIT_OUTPUT_ENCODING_NAME: str = "utf-8"
70
+ GIT_OUTPUT_DECODE_ERRORS_POLICY: str = "replace"
71
+ GIT_COMMAND_UNAVAILABLE_MESSAGE: str = (
72
+ "claude-dev-env pre-push: could not run git ({error}), aborting"
73
+ )
74
+ GIT_REV_PARSE_SUBCOMMAND: str = "rev-parse"
75
+ GIT_REV_PARSE_VERIFY_FLAG: str = "--verify"
76
+ GIT_QUIET_FLAG: str = "--quiet"
77
+ GIT_SYMBOLIC_REFERENCE_SUBCOMMAND: str = "symbolic-ref"
78
+ GIT_COMMAND_TIMEOUT_SECONDS: int = 30
79
+ GIT_FOR_EACH_REF_SUBCOMMAND: str = "for-each-ref"
80
+ GIT_REFERENCE_SHORT_NAME_FORMAT_ARGUMENT: str = "--format=%(refname:short)"
81
+ COMMIT_OBJECT_NAME_SUFFIX: str = "^{commit}"
82
+ REMOTE_REFERENCE_NAME_PREFIX: str = "refs/remotes/"
83
+ REMOTE_HEAD_SYMBOLIC_REFERENCE_TEMPLATE: str = "refs/remotes/{remote}/HEAD"
84
+ REMOTE_HEAD_BRANCH_NAME: str = "HEAD"
85
+ REMOTE_BRANCH_SHORT_NAME_TEMPLATE: str = "{remote}/{branch}"
86
+ DEFAULT_REMOTE_NAME: str = "origin"
87
+ REMOTE_NAME_ARGUMENT_INDEX: int = 1
88
+ ALL_REJECTED_REMOTE_NAME_CHARACTERS: tuple[str, ...] = (
89
+ ":",
90
+ "/",
91
+ "*",
92
+ "?",
93
+ "[",
94
+ "\\",
95
+ )
96
+ ALL_FALLBACK_REMOTE_DEFAULT_BRANCH_NAMES: tuple[str, ...] = (
97
+ "main",
98
+ "master",
99
+ "trunk",
100
+ "develop",
101
+ )
102
+ UNRESOLVABLE_BASE_REFERENCE_MESSAGE: str = (
103
+ "claude-dev-env pre-push: no usable gate base -- {reference} names no commit and "
104
+ "the default branch of remote {remote!r} could not be read.\n"
105
+ "Set the remote head so the gate has a base: git remote set-head {remote} --auto"
106
+ )