claude-dev-env 2.17.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 (117) 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/orchestrator/SKILL.md +6 -4
  7. package/.agents/skills/orchestrator-refresh/SKILL.md +9 -5
  8. package/.agents/skills/pr-cleanup/SKILL.md +17 -85
  9. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  10. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  11. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  12. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  13. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  14. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  15. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  16. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  17. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  18. package/.agents/skills/skill-builder/SKILL.md +1 -0
  19. package/.agents/skills/team-advisor/SKILL.md +5 -5
  20. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  21. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  22. package/.agents/skills-archived/comments/SKILL.md +1 -1
  23. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  24. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  25. package/AGENTS.md +1 -1
  26. package/_shared/advisor/AGENTS.md +5 -5
  27. package/_shared/advisor/advisor-protocol.md +54 -31
  28. package/_shared/advisor/reference/advisor-block.md +5 -1
  29. package/_shared/advisor/reference/consult-format.md +1 -1
  30. package/_shared/advisor/reference/identity.md +28 -0
  31. package/_shared/advisor/reference/lifecycle.md +8 -1
  32. package/_shared/advisor/reference/sol-rung.md +12 -7
  33. package/_shared/advisor/reference/spawn-walk-log.md +6 -5
  34. package/_shared/advisor/reference/third-party-bind.md +5 -6
  35. package/_shared/advisor/reference/warm-up.md +9 -2
  36. package/_shared/advisor/scripts/codex_sol_advisor.py +66 -11
  37. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +9 -0
  38. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -4
  39. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +11 -10
  40. package/_shared/advisor/scripts/model_tier_run_validator.py +50 -23
  41. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +108 -3
  42. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +171 -82
  43. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +43 -0
  44. package/_shared/advisor/scripts/tier_model_ids.py +75 -7
  45. package/_shared/pr-loop/AGENTS.md +2 -1
  46. package/_shared/pr-loop/preflight-proposal.md +56 -0
  47. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  48. package/_shared/pr-loop/worker-spawn.md +4 -3
  49. package/bin/ever-shipped-skills.mjs +14 -0
  50. package/bin/install-constants.mjs +5 -0
  51. package/bin/install.agents-home.test.mjs +23 -0
  52. package/bin/install.codex-rules.test.mjs +61 -6
  53. package/bin/install.mjs +57 -9
  54. package/bin/install.prune.test.mjs +65 -3
  55. package/bin/install.test.mjs +202 -2
  56. package/docs/CODE_RULES.md +4 -0
  57. package/docs/references/team-advisor-skill.md +2 -2
  58. package/hooks/advisory/migration_safety_advisor.py +14 -14
  59. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  60. package/hooks/atomic_file_writer.py +81 -0
  61. package/hooks/blocking/AGENTS.md +2 -1
  62. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  63. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  64. package/hooks/blocking/destructive_command_blocker.py +8 -7
  65. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  66. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  67. package/hooks/blocking/sensitive_file_protector.py +8 -6
  68. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  69. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  70. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  71. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  72. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  73. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  74. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +308 -0
  75. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  76. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  77. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  78. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  79. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  80. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +42 -25
  81. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  82. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  83. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  84. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  85. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  86. package/hooks/blocking/test_pii_scanner.py +81 -0
  87. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  88. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  89. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +168 -0
  90. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  91. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  92. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  93. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  94. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  95. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  96. package/hooks/blocking/test_test_preflight_check.py +80 -0
  97. package/hooks/git-hooks/pre_commit.py +6 -2
  98. package/hooks/git-hooks/test_pre_push.py +57 -0
  99. package/hooks/hooks_constants/AGENTS.md +3 -1
  100. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  101. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  102. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  103. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  104. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  105. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  106. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  107. package/hooks/json_file_reader.py +22 -0
  108. package/hooks/pending_sidecars.py +33 -0
  109. package/hooks/test_atomic_file_writer.py +73 -0
  110. package/hooks/test_json_file_reader.py +24 -0
  111. package/hooks/test_pending_sidecars.py +25 -0
  112. package/package.json +1 -1
  113. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  114. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  115. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  116. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  117. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -0,0 +1,92 @@
1
+ """Shared entrypoint helpers for code-rules enforcer behavior tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+
13
+
14
+ _ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
15
+
16
+
17
+ def build_write_payload(file_path: str, content: str) -> str:
18
+ """Build the JSON payload sent by a Write tool call."""
19
+ return json.dumps(
20
+ {
21
+ "tool_name": "Write",
22
+ "tool_input": {"file_path": file_path, "content": content},
23
+ }
24
+ )
25
+
26
+
27
+ def run_serialized_payload_entrypoint(
28
+ entrypoint: Callable[[list[str]], None],
29
+ serialized_payload: str,
30
+ ) -> tuple[str, int | None]:
31
+ """Run a callable hook entrypoint with a serialized stdin payload."""
32
+ input_stream = io.StringIO(serialized_payload)
33
+ captured_stream = io.StringIO()
34
+ original_stdin = sys.stdin
35
+ original_stdout = sys.stdout
36
+ exit_code: int | None = None
37
+ try:
38
+ sys.stdin = input_stream
39
+ sys.stdout = captured_stream
40
+ entrypoint([])
41
+ except SystemExit as each_exit:
42
+ raw_code = each_exit.code
43
+ exit_code = raw_code if isinstance(raw_code, int) else None
44
+ finally:
45
+ sys.stdin = original_stdin
46
+ sys.stdout = original_stdout
47
+ return captured_stream.getvalue(), exit_code
48
+
49
+
50
+ def run_write_entrypoint(
51
+ entrypoint: Callable[[list[str]], None],
52
+ file_path: str,
53
+ content: str,
54
+ ) -> tuple[str, int]:
55
+ """Drive a real hook entrypoint with a Write payload and capture stdout."""
56
+ captured_stdout, exit_code = run_serialized_payload_entrypoint(
57
+ entrypoint, build_write_payload(file_path, content)
58
+ )
59
+ return captured_stdout, int(exit_code or 0)
60
+
61
+
62
+ def run_enforcer_cli(
63
+ script_path: Path,
64
+ all_arguments: list[str],
65
+ extra_environment_by_name: dict[str, str] | None,
66
+ ) -> subprocess.CompletedProcess[str]:
67
+ """Run an enforcer script through its real command-line entrypoint."""
68
+ subprocess_environment: dict[str, str] | None = None
69
+ if extra_environment_by_name is not None:
70
+ subprocess_environment = os.environ.copy()
71
+ subprocess_environment.update(extra_environment_by_name)
72
+ return subprocess.run(
73
+ [sys.executable, str(script_path), *all_arguments],
74
+ stdin=subprocess.DEVNULL,
75
+ capture_output=True,
76
+ text=True,
77
+ check=False,
78
+ env=subprocess_environment,
79
+ )
80
+
81
+
82
+ def run_precheck(
83
+ candidate_path: Path,
84
+ target_path: str,
85
+ extra_environment_by_name: dict[str, str] | None,
86
+ ) -> subprocess.CompletedProcess[str]:
87
+ """Run an enforcer script through its real precheck command."""
88
+ return run_enforcer_cli(
89
+ _ENFORCER_SCRIPT_PATH,
90
+ ["--check", str(candidate_path), "--as", target_path],
91
+ extra_environment_by_name,
92
+ )
@@ -20,12 +20,16 @@ from hooks_constants.convergence_branch_constants import ( # noqa: E402
20
20
  CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
21
21
  )
22
22
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
23
- from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
24
- ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
25
- ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
23
+ from hooks_constants.destructive_command_environment_constants import ( # noqa: E402
26
24
  ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES,
27
25
  ALL_TRUTHY_ENV_VALUES,
28
26
  DESTRUCTIVE_DENY_MODE_ENV_VAR,
27
+ EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR,
28
+ GH_REDIRECT_ACTIVE_ENV_VAR,
29
+ )
30
+ from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
31
+ ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
32
+ ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
29
33
  ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM,
30
34
  ALL_FIND_EXEC_ACTION_FLAGS,
31
35
  ALL_FIND_EXEC_ACTION_TERMINATORS,
@@ -57,7 +61,6 @@ from hooks_constants.destructive_command_segment_constants import ( # noqa: E40
57
61
  )
58
62
 
59
63
  CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
60
- GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
61
64
 
62
65
 
63
66
  def gh_redirect_is_active() -> bool:
@@ -87,9 +90,7 @@ def directory_is_ephemeral(directory_path: str) -> bool:
87
90
  Returns:
88
91
  True when the directory belongs to the ephemeral auto-allow namespace.
89
92
  """
90
- ephemeral_auto_allow_disabled_env_var = "CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"
91
- truthy_string_values = frozenset({"1", "true", "yes", "on"})
92
- if os.environ.get(ephemeral_auto_allow_disabled_env_var, "").strip().lower() in truthy_string_values:
93
+ if os.environ.get(EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR, "").strip().lower() in ALL_TRUTHY_ENV_VALUES:
93
94
  return False
94
95
  forward_slash_normalized_directory_path = os.path.normpath(directory_path).replace("\\", "/").lower()
95
96
  all_worktree_path_segments = ("/worktrees/", "/worktree/")
@@ -486,16 +486,20 @@ def _emit_allow_decision(decision: DispatcherDecision) -> None:
486
486
 
487
487
  Matches the shape a standalone hosted hook emits when it auto-approves the
488
488
  write, so a write a hosted hook allows explicitly is auto-approved under the
489
- dispatcher rather than falling back to the default permission flow. When a
490
- hook also carried an advisory systemMessage,
491
- that notice rides on the allow payload without denying.
489
+ dispatcher rather than falling back to the default permission flow. Advisory
490
+ systemMessage and additionalContext fields ride on the allow payload.
492
491
  """
492
+ allow_hook_specific: dict[str, object] = {
493
+ "hookEventName": HOOK_EVENT_NAME,
494
+ "permissionDecision": ALLOW_DECISION,
495
+ }
493
496
  allow_payload: dict[str, object] = {
494
- "hookSpecificOutput": {
495
- "hookEventName": HOOK_EVENT_NAME,
496
- "permissionDecision": ALLOW_DECISION,
497
- }
497
+ "hookSpecificOutput": allow_hook_specific,
498
498
  }
499
+ if decision.all_additional_context:
500
+ allow_hook_specific["additionalContext"] = CONTEXT_JOIN_SEPARATOR.join(
501
+ decision.all_additional_context
502
+ )
499
503
  if decision.all_system_messages:
500
504
  allow_payload["systemMessage"] = SYSTEM_MESSAGE_JOIN_SEPARATOR.join(
501
505
  decision.all_system_messages
@@ -1,40 +1,26 @@
1
- """PreToolUse hook that runs the staged CODE_RULES gate before git commit.
1
+ """PreToolUse hook that passes Git commit validation to Git hooks.
2
2
 
3
3
  Intercepts Bash `git commit` invocations (including `git -C <path> commit`),
4
- resolves the repository root, and runs the shared code_rules_gate engine in
5
- ``--staged`` mode over the staged files. A commit that would introduce
6
- CODE_RULES violations is denied with the gate's file:line report so the
7
- violations surface before the commit instead of stalling converge loops at
8
- commit time. Non-commit commands, repositories with no staged Python files,
9
- and clean staged changes pass through silently. A gate-engine failure denies
10
- the commit with the failure detail — the gate never fails open.
4
+ emits neutral evidence, and returns control to Git. Configured Git hooks
5
+ receive the commit at the commit boundary.
11
6
  """
12
7
 
13
- import json
14
8
  import re
15
9
  import subprocess
16
10
  import sys
17
11
  from pathlib import Path
18
12
 
19
- _blocking_dir = str(Path(__file__).resolve().parent)
20
- if _blocking_dir not in sys.path:
21
- sys.path.insert(0, _blocking_dir)
22
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
23
- if _hooks_dir not in sys.path:
24
- sys.path.insert(0, _hooks_dir)
13
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
14
+ if _hooks_directory not in sys.path:
15
+ sys.path.insert(0, _hooks_directory)
25
16
 
26
17
  from block_main_commit import ( # noqa: E402
27
- extract_git_working_directory,
28
18
  is_commit_command,
29
19
  parse_bash_command_from_stdin,
30
- resolve_directory,
31
20
  )
32
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
21
+
33
22
  from hooks_constants.precommit_code_rules_gate_constants import ( # noqa: E402
34
23
  ALL_GIT_REPOSITORY_ROOT_COMMAND,
35
- ALL_STAGED_PYTHON_FILES_COMMAND,
36
- GATE_RELATIVE_PATH,
37
- GATE_TIMEOUT_SECONDS,
38
24
  GIT_COMMAND_TIMEOUT_SECONDS,
39
25
  GIT_DASH_C_COMMIT_PATTERN,
40
26
  )
@@ -59,20 +45,19 @@ def is_git_commit_invocation(bash_command: str) -> bool:
59
45
 
60
46
 
61
47
  def resolve_repository_root(working_directory: str | None) -> Path | None:
62
- """Resolve the git repository root for the commit's working directory.
48
+ """Resolve the Git repository root for a directory.
63
49
 
64
50
  Args:
65
- working_directory: Directory the commit runs in, or None for the
66
- hook's current working directory.
51
+ working_directory: Directory inside the repository.
67
52
 
68
53
  Returns:
69
- The repository root path, or None when the directory is not inside
70
- a git repository or git is unavailable.
54
+ Repository root. Returns None when Git cannot resolve the directory.
71
55
  """
72
56
  try:
73
57
  completed_process = subprocess.run(
74
58
  list(ALL_GIT_REPOSITORY_ROOT_COMMAND),
75
- check=False, capture_output=True,
59
+ check=False,
60
+ capture_output=True,
76
61
  text=True,
77
62
  timeout=GIT_COMMAND_TIMEOUT_SECONDS,
78
63
  cwd=working_directory,
@@ -87,122 +72,12 @@ def resolve_repository_root(working_directory: str | None) -> Path | None:
87
72
  return Path(top_level_text)
88
73
 
89
74
 
90
- def list_staged_python_files(repository_root: Path) -> list[str]:
91
- """List repository-relative paths of staged Python files.
92
-
93
- Args:
94
- repository_root: Repository root used as the git working directory.
95
-
96
- Returns:
97
- Repository-relative paths of Python files staged for add, copy,
98
- modify, or rename. Empty when the listing command fails — the
99
- caller then allows the commit because git itself will surface the
100
- repository problem.
101
- """
102
- try:
103
- completed_process = subprocess.run(
104
- list(ALL_STAGED_PYTHON_FILES_COMMAND),
105
- check=False, capture_output=True,
106
- text=True,
107
- timeout=GIT_COMMAND_TIMEOUT_SECONDS,
108
- cwd=str(repository_root),
109
- )
110
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
111
- return []
112
- if completed_process.returncode != 0:
113
- return []
114
- return [
115
- each_line.strip()
116
- for each_line in completed_process.stdout.splitlines()
117
- if each_line.strip()
118
- ]
119
-
120
-
121
- def run_staged_gate(repository_root: Path) -> tuple[int, str]:
122
- """Run the shared code_rules_gate engine in staged mode.
123
-
124
- Args:
125
- repository_root: Repository root passed to the gate's --repo-root
126
- and used as the gate subprocess working directory, so relative
127
- paths the gate's checks resolve land in the committing worktree
128
- rather than the hook's own working directory.
129
-
130
- Returns:
131
- Tuple of the gate exit code and its stderr report. A missing gate
132
- script or a gate timeout returns a non-zero code with an
133
- explanatory message so the commit is denied rather than waved
134
- through on infrastructure failure.
135
- """
136
- gate_path = Path(__file__).resolve().parents[2] / GATE_RELATIVE_PATH
137
- if not gate_path.is_file():
138
- return 1, f"precommit_code_rules_gate: gate engine missing at {gate_path}"
139
- try:
140
- completed_process = subprocess.run(
141
- [
142
- sys.executable,
143
- str(gate_path),
144
- "--repo-root",
145
- str(repository_root),
146
- "--staged",
147
- ],
148
- check=False, capture_output=True,
149
- text=True,
150
- encoding="utf-8",
151
- errors="replace",
152
- timeout=GATE_TIMEOUT_SECONDS,
153
- cwd=str(repository_root),
154
- )
155
- except subprocess.TimeoutExpired:
156
- return 1, (
157
- f"precommit_code_rules_gate: gate engine timed out after {GATE_TIMEOUT_SECONDS}s"
158
- )
159
- return completed_process.returncode, completed_process.stderr
160
-
161
-
162
- def build_denial_response(gate_report: str) -> dict:
163
- """Build the PreToolUse deny payload carrying the gate report.
164
-
165
- Args:
166
- gate_report: The gate's stderr report listing file:line violations.
167
-
168
- Returns:
169
- The hookSpecificOutput deny mapping for the PreToolUse protocol.
170
- """
171
- denial_reason = (
172
- f"BLOCKED: staged files violate CODE_RULES; fix before committing.\n{gate_report.strip()}"
173
- )
174
- return {
175
- "hookSpecificOutput": {
176
- "hookEventName": "PreToolUse",
177
- "permissionDecision": "deny",
178
- "permissionDecisionReason": denial_reason,
179
- }
180
- }
181
-
182
-
183
75
  def main() -> None:
184
- """Gate git commits on the staged CODE_RULES report."""
76
+ """Pass Git commit validation through to configured Git hooks."""
185
77
  bash_command = parse_bash_command_from_stdin()
186
78
  if not is_git_commit_invocation(bash_command):
187
79
  sys.exit(0)
188
- working_directory = resolve_directory(extract_git_working_directory(bash_command))
189
- repository_root = resolve_repository_root(working_directory)
190
- if repository_root is None:
191
- sys.exit(0)
192
- if not list_staged_python_files(repository_root):
193
- sys.exit(0)
194
- gate_exit_code, gate_report = run_staged_gate(repository_root)
195
- if gate_exit_code == 0:
196
- sys.exit(0)
197
- denial = build_denial_response(gate_report)
198
- log_hook_block(
199
- calling_hook_name="precommit_code_rules_gate.py",
200
- hook_event="PreToolUse",
201
- block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
202
- tool_name="Bash",
203
- offending_input_preview=bash_command,
204
- )
205
- print(json.dumps(denial))
80
+ sys.stderr.write("Git commit proceeds to configured Git hooks.\n")
206
81
  sys.exit(0)
207
82
 
208
83
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Deny a Write or Edit whose target filename names a secret or a lock file.
2
+ """Deny a Write, Edit, or MultiEdit whose target filename names a secret or a lock file.
3
3
 
4
4
  ::
5
5
 
@@ -35,10 +35,12 @@ import sys
35
35
  import _path_setup # noqa: F401
36
36
 
37
37
  from hooks_constants.hook_block_logger import log_hook_block
38
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
39
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
40
+ )
38
41
  from hooks_constants.sensitive_file_protector_constants import (
39
42
  ALL_SENSITIVE_PATTERNS,
40
43
  ALL_TEMPLATE_SUFFIXES,
41
- ALL_WRITE_EDIT_TOOLS,
42
44
  DENY_DECISION,
43
45
  DENY_REASON_TEMPLATE,
44
46
  HOOK_EVENT_NAME,
@@ -57,7 +59,7 @@ def is_template_filename(filename: str) -> bool:
57
59
  .env.local -> False
58
60
 
59
61
  Args:
60
- filename: The basename of the file a Write or Edit targets.
62
+ filename: The basename of the file a Write, Edit, or MultiEdit targets.
61
63
 
62
64
  Returns:
63
65
  True when the basename's final suffix is a template suffix.
@@ -69,7 +71,7 @@ def is_sensitive_file(file_path: str) -> str | None:
69
71
  """Return the sensitive pattern a path's basename matches, or None.
70
72
 
71
73
  Args:
72
- file_path: The path the Write or Edit targets.
74
+ file_path: The path the Write, Edit, or MultiEdit targets.
73
75
 
74
76
  Returns:
75
77
  The matched pattern, or None when the basename names a template or
@@ -106,7 +108,7 @@ def deny_write(file_path: str, matched_pattern: str) -> None:
106
108
  """Record the block in the hook-blocks log and emit the deny decision.
107
109
 
108
110
  Args:
109
- file_path: The path the Write or Edit targets.
111
+ file_path: The path the Write, Edit, or MultiEdit targets.
110
112
  matched_pattern: The sensitive pattern the basename matched.
111
113
  """
112
114
  deny_reason = DENY_REASON_TEMPLATE.format(
@@ -132,7 +134,7 @@ def main() -> None:
132
134
  tool_name = hook_input.get("tool_name", "")
133
135
  tool_input = hook_input.get("tool_input", {})
134
136
 
135
- if tool_name not in ALL_WRITE_EDIT_TOOLS:
137
+ if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
136
138
  sys.exit(0)
137
139
 
138
140
  file_path = tool_input.get("file_path", "")
@@ -18,6 +18,7 @@ blocks on infrastructure trouble.
18
18
  from __future__ import annotations
19
19
 
20
20
  import json
21
+ import os
21
22
  import shlex
22
23
  import subprocess
23
24
  import sys
@@ -601,6 +602,35 @@ def _build_denial(all_offending_paths: list[str]) -> dict:
601
602
  }
602
603
 
603
604
 
605
+ def _resolve_hook_working_directory(
606
+ bash_command: str,
607
+ all_hook_payload: dict[str, object],
608
+ ) -> str | None:
609
+ """Resolve the commit directory from the command or its event payload.
610
+
611
+ Args:
612
+ bash_command: Bash command whose explicit directory takes precedence.
613
+ all_hook_payload: PreToolUse payload carrying the event ``cwd``.
614
+
615
+ Returns:
616
+ The validated commit directory, or None for the hook process directory.
617
+ """
618
+ command_directory = extract_git_working_directory(bash_command)
619
+ event_directory = all_hook_payload.get("cwd")
620
+ if not isinstance(event_directory, str) or not event_directory:
621
+ return resolve_directory(command_directory)
622
+ validated_event_directory = resolve_directory(event_directory)
623
+ if command_directory is None:
624
+ return validated_event_directory
625
+ expanded_command_directory = os.path.expanduser(command_directory)
626
+ command_path = Path(expanded_command_directory)
627
+ if command_path.is_absolute():
628
+ return resolve_directory(str(command_path))
629
+ if validated_event_directory is None:
630
+ return None
631
+ return resolve_directory(str(Path(validated_event_directory) / command_path))
632
+
633
+
604
634
  def main() -> None:
605
635
  """Deny a git commit that would drop a tracked file that was edited yet left unstaged."""
606
636
  hook_payload = read_hook_input_dictionary_from_stdin()
@@ -620,7 +650,7 @@ def main() -> None:
620
650
  session_edited_keys = _session_edited_keys(session_id)
621
651
  if not session_edited_keys:
622
652
  sys.exit(0)
623
- working_directory = resolve_directory(extract_git_working_directory(bash_command))
653
+ working_directory = _resolve_hook_working_directory(bash_command, hook_payload)
624
654
  repository_root = resolve_repository_root(working_directory)
625
655
  if repository_root is None:
626
656
  sys.exit(0)
@@ -1,6 +1,7 @@
1
1
  """Tests for claude_md_orphan_file_blocker hook."""
2
2
 
3
3
  import json
4
+ import importlib
4
5
  import os
5
6
  import subprocess
6
7
  import sys
@@ -9,7 +10,57 @@ from pathlib import Path
9
10
 
10
11
  import pytest
11
12
 
12
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
13
+ _test_file_path = Path(__file__).resolve()
14
+ _blocking_directory = str(_test_file_path.parent)
15
+ _hooks_directory = str(_test_file_path.parent.parent)
16
+ _blocking_directory_key = os.path.normcase(os.path.abspath(_blocking_directory))
17
+ _hooks_directory_key = os.path.normcase(os.path.abspath(_hooks_directory))
18
+ _hooks_directory_origin_key = _hooks_directory_key
19
+ sys.path[:] = [_blocking_directory, _hooks_directory] + [
20
+ each_path
21
+ for each_path in sys.path
22
+ if not isinstance(each_path, str)
23
+ or os.path.normcase(os.path.abspath(each_path))
24
+ not in (_blocking_directory_key, _hooks_directory_key)
25
+ ]
26
+ importlib.invalidate_caches()
27
+
28
+ _all_cached_module_prefixes = (
29
+ "claude_md_orphan_file_blocker",
30
+ "claude_md_orphan_file_blocker_parts",
31
+ "inventory_intent_records",
32
+ "hooks_constants",
33
+ "code_rules_annotations_length",
34
+ "code_rules_boolean_mustcheck",
35
+ "code_rules_naming_collection",
36
+ "code_rules_path_utils",
37
+ "code_rules_shared",
38
+ )
39
+
40
+
41
+ for each_module_name, each_module in tuple(sys.modules.items()):
42
+ if not any(
43
+ each_module_name == each_prefix or each_module_name.startswith(f"{each_prefix}.")
44
+ for each_prefix in _all_cached_module_prefixes
45
+ ):
46
+ continue
47
+ module_file = getattr(each_module, "__file__", None)
48
+ is_cached_module_in_this_checkout = False
49
+ if isinstance(module_file, str):
50
+ module_file_key = os.path.normcase(str(Path(module_file).resolve()))
51
+ try:
52
+ is_cached_module_in_this_checkout = (
53
+ os.path.commonpath((_hooks_directory_origin_key, module_file_key))
54
+ == _hooks_directory_origin_key
55
+ )
56
+ except ValueError:
57
+ pass
58
+ if is_cached_module_in_this_checkout:
59
+ continue
60
+ raise ImportError(
61
+ "test_claude_md_orphan_file_blocker: cached module outside this checkout: "
62
+ f"{each_module_name} ({module_file!r})"
63
+ )
13
64
 
14
65
  import claude_md_orphan_file_blocker as blocker_module
15
66
  from claude_md_orphan_file_blocker import (
@@ -82,6 +133,125 @@ def _isolated_claude_md_path(tmp_path: Path) -> Path:
82
133
  return isolated_directory / "CLAUDE.md"
83
134
 
84
135
 
136
+ def _run_isolated_test_module_import(
137
+ driver_setup: str, driver_assertions: str
138
+ ) -> subprocess.CompletedProcess:
139
+ """Import this test module in an isolated interpreter with caller-supplied state."""
140
+ test_module_path = _test_file_path
141
+ driver = (
142
+ "import importlib.util\n"
143
+ "import os\n"
144
+ "import site\n"
145
+ "import sys\n"
146
+ "from pathlib import Path\n"
147
+ f"test_module_path = Path({str(test_module_path)!r})\n"
148
+ "sys.path.append(site.getusersitepackages())\n"
149
+ f"{driver_setup}"
150
+ "spec = importlib.util.spec_from_file_location('bootstrap_target_test', test_module_path)\n"
151
+ "module = importlib.util.module_from_spec(spec)\n"
152
+ "spec.loader.exec_module(module)\n"
153
+ f"{driver_assertions}"
154
+ )
155
+ return subprocess.run(
156
+ [sys.executable, "-I", "-c", driver],
157
+ check=False,
158
+ capture_output=True,
159
+ text=True,
160
+ )
161
+
162
+
163
+ def test_import_bootstrap_prioritizes_this_checkout(tmp_path: Path) -> None:
164
+ foreign_hooks_directory = tmp_path / "foreign_hooks"
165
+ foreign_blocking_directory = foreign_hooks_directory / "blocking"
166
+ foreign_constants_directory = foreign_hooks_directory / "hooks_constants"
167
+ foreign_blocking_directory.mkdir(parents=True)
168
+ foreign_constants_directory.mkdir()
169
+ (foreign_blocking_directory / "claude_md_orphan_file_blocker.py").write_text(
170
+ "raise RuntimeError('foreign blocker loaded')\n", encoding="utf-8"
171
+ )
172
+ (foreign_constants_directory / "__init__.py").write_text(
173
+ "raise RuntimeError('foreign constants loaded')\n", encoding="utf-8"
174
+ )
175
+ target_blocking_directory = Path(__file__).resolve().parent
176
+ target_hooks_directory = target_blocking_directory.parent
177
+ driver_setup = (
178
+ f"foreign_blocking_directory = Path({str(foreign_blocking_directory)!r})\n"
179
+ f"foreign_hooks_directory = Path({str(foreign_hooks_directory)!r})\n"
180
+ f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
181
+ f"target_hooks_directory = Path({str(target_hooks_directory)!r})\n"
182
+ "sys.path[:0] = [\n"
183
+ " str(foreign_blocking_directory), str(foreign_hooks_directory),\n"
184
+ " str(target_blocking_directory), str(target_hooks_directory),\n"
185
+ "]\n"
186
+ )
187
+ driver_assertions = (
188
+ "import claude_md_orphan_file_blocker\n"
189
+ "import hooks_constants\n"
190
+ "assert Path(claude_md_orphan_file_blocker.__file__).resolve().parent "
191
+ "== target_blocking_directory\n"
192
+ "assert Path(hooks_constants.__file__).resolve().parent.parent "
193
+ "== target_hooks_directory\n"
194
+ "all_uncovered_module_names = [\n"
195
+ " each_module_name\n"
196
+ " for each_module_name, each_loaded_module in sys.modules.items()\n"
197
+ " if each_loaded_module is not module\n"
198
+ " and isinstance(getattr(each_loaded_module, '__file__', None), str)\n"
199
+ " and Path(each_loaded_module.__file__).resolve().is_relative_to(\n"
200
+ " target_hooks_directory\n"
201
+ " )\n"
202
+ " and not any(\n"
203
+ " each_module_name == each_prefix\n"
204
+ " or each_module_name.startswith(f'{each_prefix}.')\n"
205
+ " for each_prefix in module._all_cached_module_prefixes\n"
206
+ " )\n"
207
+ "]\n"
208
+ "assert not all_uncovered_module_names, all_uncovered_module_names\n"
209
+ )
210
+ completed = _run_isolated_test_module_import(driver_setup, driver_assertions)
211
+ assert completed.returncode == 0, completed.stderr
212
+
213
+
214
+ def test_import_bootstrap_preserves_non_string_path_entries() -> None:
215
+ completed = _run_isolated_test_module_import(
216
+ "sys.path.insert(0, None)\n", "assert None in sys.path\n"
217
+ )
218
+ assert completed.returncode == 0, completed.stderr
219
+
220
+
221
+ def test_import_bootstrap_invalidates_stale_importer_cache() -> None:
222
+ target_blocking_directory = Path(__file__).resolve().parent
223
+ driver_setup = (
224
+ f"target_blocking_directory = Path({str(target_blocking_directory)!r})\n"
225
+ "sys.path_importer_cache[str(target_blocking_directory)] = None\n"
226
+ )
227
+ completed = _run_isolated_test_module_import(driver_setup, "")
228
+ assert completed.returncode == 0, completed.stderr
229
+
230
+
231
+ @pytest.mark.parametrize(
232
+ "cached_module_name",
233
+ (
234
+ "hooks_constants",
235
+ "claude_md_orphan_file_blocker",
236
+ "code_rules_path_utils",
237
+ ),
238
+ )
239
+ def test_import_bootstrap_rejects_foreign_cached_modules(
240
+ tmp_path: Path, cached_module_name: str
241
+ ) -> None:
242
+ foreign_module_path = tmp_path / "foreign_hooks" / f"{cached_module_name}.py"
243
+ driver_setup = (
244
+ "import types\n"
245
+ f"cached_module_name = {cached_module_name!r}\n"
246
+ "cached_module = types.ModuleType(cached_module_name)\n"
247
+ f"cached_module.__file__ = {str(foreign_module_path)!r}\n"
248
+ "sys.modules[cached_module_name] = cached_module\n"
249
+ )
250
+ completed = _run_isolated_test_module_import(driver_setup, "")
251
+ assert completed.returncode != 0
252
+ assert f"cached module outside this checkout: {cached_module_name}" in completed.stderr
253
+
254
+
85
255
  def test_blocks_write_naming_absent_python_file(tmp_path: Path):
86
256
  claude_md_path = _isolated_claude_md_path(tmp_path)
87
257
  result = _run_hook(