claude-dev-env 2.5.0 → 2.7.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 (139) 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/agents/CLAUDE.md +1 -1
  14. package/agents/code-verifier.md +36 -7
  15. package/bin/codex-compat.mjs +104 -0
  16. package/bin/codex-compat.test.mjs +51 -0
  17. package/codex-capability-map.json +13 -0
  18. package/docs/CODE_RULES.md +2 -0
  19. package/docs/codex-compatibility.md +25 -0
  20. package/docs/nas-ssh-invocation.md +96 -12
  21. package/docs/references/code-review-enforcement.md +31 -6
  22. package/hooks/blocking/CLAUDE.md +3 -0
  23. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
  24. package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
  25. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  26. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  27. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  28. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  29. package/hooks/blocking/state_description_blocker.py +1 -1
  30. package/hooks/blocking/stop_dispatcher.py +1 -1
  31. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  32. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  33. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  34. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  35. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  36. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
  37. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  38. package/hooks/git-hooks/CLAUDE.md +3 -0
  39. package/hooks/git-hooks/conftest.py +30 -0
  40. package/hooks/git-hooks/gate_utils.py +2 -2
  41. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  42. package/hooks/git-hooks/pre_push.py +75 -4
  43. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  44. package/hooks/git-hooks/test_config.py +0 -15
  45. package/hooks/git-hooks/test_gate_utils.py +3 -15
  46. package/hooks/git-hooks/test_pre_commit.py +1 -15
  47. package/hooks/git-hooks/test_pre_push.py +236 -27
  48. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  49. package/hooks/hooks.json +0 -12
  50. package/hooks/hooks_constants/CLAUDE.md +5 -1
  51. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  52. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  53. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  54. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  55. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  56. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  57. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  58. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  59. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  60. package/package.json +4 -2
  61. package/rules/CLAUDE.md +17 -23
  62. package/rules/agent-spawn-protocol.md +6 -6
  63. package/rules/anti-corollary-tests.md +1 -1
  64. package/rules/bdd.md +1 -1
  65. package/rules/cleanup-temp-files.md +10 -4
  66. package/rules/code-standards.md +7 -0
  67. package/rules/conservative-action.md +1 -5
  68. package/rules/context7.md +0 -4
  69. package/rules/destructive-commands.md +47 -0
  70. package/rules/doc-inventory-integrity.md +48 -0
  71. package/rules/doc-prose-cuts.md +58 -0
  72. package/rules/docstring-prose-matches-implementation.md +10 -2
  73. package/rules/durable-post-artifacts.md +0 -4
  74. package/rules/eli11-replies.md +31 -0
  75. package/rules/explore-thoroughly.md +4 -4
  76. package/rules/falsify-before-green.md +68 -0
  77. package/rules/file-global-constants.md +1 -1
  78. package/rules/filesystem-search.md +51 -0
  79. package/rules/gh-cli-conventions.md +27 -0
  80. package/rules/git-workflow.md +26 -0
  81. package/rules/hedging-claims.md +9 -0
  82. package/rules/long-horizon-autonomy.md +0 -4
  83. package/rules/measurement-denominators.md +48 -0
  84. package/rules/nas-ssh-invocation.md +23 -5
  85. package/rules/parallel-tools.md +2 -2
  86. package/rules/plain-illustrative-docstrings.md +3 -7
  87. package/rules/plain-language.md +2 -0
  88. package/rules/proof-of-work-pr-comments.md +0 -4
  89. package/rules/re-stage-before-commit.md +2 -0
  90. package/rules/research-mode.md +10 -0
  91. package/rules/shell-invocation.md +21 -0
  92. package/rules/testing.md +4 -0
  93. package/rules/verified-commit-gate-skip.md +3 -27
  94. package/rules/verify-before-asking.md +5 -0
  95. package/rules/windows-filesystem-safe.md +1 -1
  96. package/rules/workers-done-before-complete.md +4 -0
  97. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  98. package/scripts/codex_capability_bridge.py +171 -0
  99. package/scripts/codex_compat_materializer.py +1087 -0
  100. package/scripts/codex_compat_watcher.py +502 -0
  101. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  102. package/scripts/invoke_code_review.py +11 -4
  103. package/scripts/sync_to_cursor/rules.py +0 -10
  104. package/scripts/test_invoke_code_review.py +143 -0
  105. package/scripts/test_invoke_code_review_chain.py +1 -1
  106. package/scripts/test_invoke_code_review_contract.py +1 -1
  107. package/scripts/tests/test_code_review_constants.py +80 -0
  108. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  109. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  110. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  111. package/scripts/tests/test_sync_to_cursor.py +0 -1
  112. package/skills/autoconverge/workflow/converge.mjs +1 -1
  113. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  114. package/skills/condensing-instructions/SKILL.md +42 -51
  115. package/skills/fresh-branch/CLAUDE.md +1 -1
  116. package/skills/fresh-branch/SKILL.md +5 -6
  117. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  118. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  119. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  120. package/skills/orchestrator/SKILL.md +23 -9
  121. package/skills/orchestrator-refresh/SKILL.md +20 -1
  122. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  123. package/skills/session-log/SKILL.md +1 -1
  124. package/rules/claude-md-orphan-file.md +0 -28
  125. package/rules/cleanup-command-forms.md +0 -23
  126. package/rules/code-reviews.md +0 -11
  127. package/rules/env-var-table-code-drift.md +0 -10
  128. package/rules/gh-body-file.md +0 -5
  129. package/rules/gh-paginate.md +0 -3
  130. package/rules/hook-prose-matches-detector.md +0 -15
  131. package/rules/no-historical-clutter.md +0 -26
  132. package/rules/no-inline-destructive-literals.md +0 -9
  133. package/rules/no-justification-noise.md +0 -61
  134. package/rules/package-inventory-stale-entry.md +0 -25
  135. package/rules/right-sized-engineering.md +0 -28
  136. package/rules/self-contained-docs.md +0 -17
  137. package/rules/shell-invocation-policy.md +0 -5
  138. package/rules/state-what-is.md +0 -25
  139. package/rules/tdd.md +0 -7
@@ -345,7 +345,7 @@ def build_deny_payload(deny_reason: str) -> dict[str, object]:
345
345
  "only the current state. For example:\n"
346
346
  ' BAD: "Uses X instead of Y" → GOOD: "Uses X"\n'
347
347
  ' BAD: "Previously configured via Z" → GOOD: "Configured via Z"\n'
348
- "See ~/.claude/rules/no-historical-clutter.md for full rules."
348
+ "See ~/.claude/rules/doc-prose-cuts.md for full rules."
349
349
  ),
350
350
  },
351
351
  "systemMessage": "Agent wrote comparative/historical language - describe current state only",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Stop-hook dispatcher that hosts the five Stop-chain hooks in one process.
2
+ """Stop-hook dispatcher that hosts the six Stop-chain hooks in one process.
3
3
 
4
4
  Reads the Stop payload from stdin once, runs each hosted hook in registration
5
5
  order via the shared hosted-hook runner, and emits the first block decision the
@@ -212,16 +212,15 @@ def test_additional_context_is_collected_from_deciding_hooks() -> None:
212
212
  assert aggregated.all_additional_context == ["see docs/runbook.md"]
213
213
 
214
214
 
215
- def test_powershell_selects_the_three_shared_hooks() -> None:
215
+ def test_powershell_selects_the_shared_hooks_in_registration_order() -> None:
216
216
  """Selecting for PowerShell yields the shared hooks in registration order."""
217
217
  powershell_paths = [
218
218
  each_entry.script_relative_path
219
219
  for each_entry in select_applicable_entries(POWERSHELL_TOOL_NAME)
220
220
  ]
221
221
  assert powershell_paths == [
222
+ "blocking/unscoped_search_blocker.py",
222
223
  "blocking/pii_prevention_blocker.py",
223
- "blocking/verified_commit_gate.py",
224
- "blocking/verdict_directory_write_blocker.py",
225
224
  "blocking/code_review_push_gate.py",
226
225
  "blocking/code_review_pr_create_gate.py",
227
226
  "blocking/code_review_stamp_directory_write_blocker.py",
@@ -0,0 +1,457 @@
1
+ """Tests for eli11_reply_enforcer hook response shape and reply-shape detection."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+
8
+ HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "eli11_reply_enforcer.py")
9
+ _HOOKS_DIR = os.path.dirname(HOOK_SCRIPT_PATH)
10
+ _HOOKS_ROOT = os.path.join(_HOOKS_DIR, "..")
11
+ if _HOOKS_DIR not in sys.path:
12
+ sys.path.insert(0, _HOOKS_DIR)
13
+ if _HOOKS_ROOT not in sys.path:
14
+ sys.path.insert(0, _HOOKS_ROOT)
15
+ import eli11_reply_enforcer
16
+ from hooks_constants.eli11_reply_enforcer_constants import (
17
+ MAXIMUM_BULLET_LINE_COUNT,
18
+ MAXIMUM_OVERPACKED_LINE_COUNT,
19
+ MAXIMUM_REPLY_WORD_COUNT,
20
+ MAXIMUM_WORDS_PER_LINE,
21
+ MINIMUM_ENFORCED_WORD_COUNT,
22
+ TARGET_BULLET_LINE_COUNT,
23
+ )
24
+ from hooks_constants.text_stripping import strip_code_and_quotes
25
+
26
+ SAFE_LINE_WORD_COUNT = MAXIMUM_WORDS_PER_LINE // 2
27
+ OVERPACKED_LINE_WORD_COUNT = MAXIMUM_WORDS_PER_LINE + 5
28
+
29
+
30
+ def build_filler_prose(word_count: int) -> str:
31
+ """Return a single prose line holding exactly the requested number of words."""
32
+ return " ".join(f"finding{each_index}" for each_index in range(word_count))
33
+
34
+
35
+ def build_bullet_block(bullet_count: int, words_per_bullet: int) -> str:
36
+ """Return a bullet list of the requested size, each bullet a filler line."""
37
+ return "\n".join(
38
+ f"- {build_filler_prose(words_per_bullet)}" for _ in range(bullet_count)
39
+ )
40
+
41
+
42
+ def build_prose_block(line_count: int, words_per_line: int) -> str:
43
+ """Return a block of plain prose lines, each holding the requested words."""
44
+ return "\n".join(build_filler_prose(words_per_line) for _ in range(line_count))
45
+
46
+
47
+ def build_reply_of_exactly(total_word_count: int) -> str:
48
+ """Return a filler reply holding exactly the requested words, no line overpacked.
49
+
50
+ ::
51
+
52
+ in: 22 -> three lines of 10, 10, and 2 filler words
53
+
54
+ Args:
55
+ total_word_count: How many countable words the whole reply carries.
56
+
57
+ Returns:
58
+ A newline-joined reply whose every line stays under the per-line cap.
59
+ """
60
+ all_lines = []
61
+ remaining_word_count = total_word_count
62
+ while remaining_word_count > 0:
63
+ line_word_count = min(SAFE_LINE_WORD_COUNT, remaining_word_count)
64
+ all_lines.append(build_filler_prose(line_word_count))
65
+ remaining_word_count -= line_word_count
66
+ return "\n".join(all_lines)
67
+
68
+
69
+ SHORT_REPLY_WORD_COUNT = MINIMUM_ENFORCED_WORD_COUNT // 2
70
+ OVERLONG_REPLY_WORD_COUNT = MAXIMUM_REPLY_WORD_COUNT * 2
71
+ JUST_OVER_WORD_CAP_WORD_COUNT = MAXIMUM_REPLY_WORD_COUNT + 1
72
+ UNDER_FLOOR_WORD_COUNT = MINIMUM_ENFORCED_WORD_COUNT - 1
73
+
74
+ SHORT_REPLY = build_reply_of_exactly(SHORT_REPLY_WORD_COUNT)
75
+ OVERLONG_REPLY = build_reply_of_exactly(OVERLONG_REPLY_WORD_COUNT)
76
+ UNDER_FLOOR_REPLY = build_reply_of_exactly(UNDER_FLOOR_WORD_COUNT)
77
+ JUST_OVER_WORD_CAP_REPLY = build_reply_of_exactly(JUST_OVER_WORD_CAP_WORD_COUNT)
78
+ THREE_OVERPACKED_LINE_REPLY = build_prose_block(
79
+ MAXIMUM_OVERPACKED_LINE_COUNT + 1, OVERPACKED_LINE_WORD_COUNT
80
+ )
81
+ TWO_OVERPACKED_LINES = build_prose_block(
82
+ MAXIMUM_OVERPACKED_LINE_COUNT, OVERPACKED_LINE_WORD_COUNT
83
+ )
84
+ TWO_OVERPACKED_LINE_REPLY = (
85
+ f"{TWO_OVERPACKED_LINES}\n{build_filler_prose(SAFE_LINE_WORD_COUNT)}"
86
+ )
87
+ SEVEN_BULLET_JUST_OVER_FLOOR_REPLY = build_bullet_block(
88
+ MAXIMUM_BULLET_LINE_COUNT + 1, SAFE_LINE_WORD_COUNT
89
+ )
90
+ LONG_FORM_OVERLONG_REPLY = f"Long form: the audit report follows.\n\n{OVERLONG_REPLY}"
91
+ BOLD_LONG_FORM_OVERLONG_REPLY = f"**Long form:** the audit report follows.\n\n{OVERLONG_REPLY}"
92
+ QUOTED_LONG_FORM_OVERLONG_REPLY = f"> Long form: the audit report follows.\n\n{OVERLONG_REPLY}"
93
+ HEADING_LONG_FORM_OVERLONG_REPLY = f"# Long form: the audit report follows.\n\n{OVERLONG_REPLY}"
94
+
95
+ INSTRUCTION_LINE = "Run the migration script."
96
+ INSTRUCTION_LINE_WORD_COUNT = len(INSTRUCTION_LINE.split())
97
+
98
+ ACTION_WITHOUT_STEPS_FIRST_REPLY = (
99
+ f"{build_reply_of_exactly(MAXIMUM_REPLY_WORD_COUNT // 2)}\n\n"
100
+ f"{INSTRUCTION_LINE}\n\nMerge the branch."
101
+ )
102
+ ACTION_WITH_STEPS_FIRST_REPLY = (
103
+ "1. **Run** the migration script.\n"
104
+ "2. **Merge** the branch.\n\n"
105
+ f"{build_reply_of_exactly(MAXIMUM_REPLY_WORD_COUNT // 2)}"
106
+ )
107
+ AT_FLOOR_ACTION_REPLY = (
108
+ f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT - INSTRUCTION_LINE_WORD_COUNT)}"
109
+ f"\n\n{INSTRUCTION_LINE}"
110
+ )
111
+ SEVEN_BULLET_REPLY = build_bullet_block(
112
+ MAXIMUM_BULLET_LINE_COUNT + 1, SAFE_LINE_WORD_COUNT + 3
113
+ )
114
+ SIX_BULLET_REPLY = build_bullet_block(
115
+ MAXIMUM_BULLET_LINE_COUNT, SAFE_LINE_WORD_COUNT + 5
116
+ )
117
+ FENCED_CODE_REPLY = f"{SHORT_REPLY}\n\n```python\n{OVERLONG_REPLY}\n```\n"
118
+ BLOCKQUOTE_REPLY = f"{SHORT_REPLY}\n\n> {build_filler_prose(OVERLONG_REPLY_WORD_COUNT)}\n"
119
+ TABLE_REPLY = "{}\n\n{}\n".format(
120
+ SHORT_REPLY,
121
+ "\n".join(
122
+ f"| {build_filler_prose(MAXIMUM_WORDS_PER_LINE)} | cell |" for _ in range(15)
123
+ ),
124
+ )
125
+
126
+ ALL_NARRATIVE_OPENER_LINES = (
127
+ "Open questions remain about the stripper edge cases.",
128
+ "Run time stays under one second on the package suite.",
129
+ "Merge is complete on main.",
130
+ )
131
+ NARRATIVE_OPENER_REPLY = "{}\n\n{}".format(
132
+ "\n".join(ALL_NARRATIVE_OPENER_LINES),
133
+ build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT),
134
+ )
135
+ INSTALL_WITHOUT_STEPS_FIRST_REPLY = (
136
+ f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT)}\n\n"
137
+ "Install the package from the registry."
138
+ )
139
+ DO_THINGS_WITH_STEPS_FIRST_REPLY = (
140
+ "Do 3 things:\n"
141
+ "1. Install the package.\n"
142
+ "2. Restart the daemon.\n"
143
+ "3. Save the file.\n\n"
144
+ f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT)}"
145
+ )
146
+ DO_THINGS_WITHOUT_STEPS_FIRST_REPLY = (
147
+ f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT)}\n\n"
148
+ "Do 3 things: install, restart, save."
149
+ )
150
+
151
+
152
+ def run_hook_with_payload(payload: dict) -> subprocess.CompletedProcess:
153
+ """Run the hook script with the given Stop payload and capture its output."""
154
+ hook_input_payload = json.dumps(payload)
155
+ return subprocess.run(
156
+ [sys.executable, HOOK_SCRIPT_PATH],
157
+ input=hook_input_payload,
158
+ capture_output=True,
159
+ text=True,
160
+ check=False,
161
+ )
162
+
163
+
164
+ def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess:
165
+ """Run the hook against a single assistant message."""
166
+ return run_hook_with_payload({"last_assistant_message": assistant_message})
167
+
168
+
169
+ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
170
+ """The hook reuses the shared stripper rather than re-implementing it."""
171
+ assert eli11_reply_enforcer.strip_code_and_quotes is strip_code_and_quotes
172
+
173
+
174
+ def test_short_reply_passes_through() -> None:
175
+ """A reply under the enforced word floor is never judged."""
176
+ completed_process = run_hook_with_message(SHORT_REPLY)
177
+ assert completed_process.returncode == 0
178
+ assert completed_process.stdout == ""
179
+
180
+
181
+ def test_empty_message_passes_through() -> None:
182
+ """A tool-only turn carries no assistant prose and passes."""
183
+ completed_process = run_hook_with_message("")
184
+ assert completed_process.returncode == 0
185
+ assert completed_process.stdout == ""
186
+
187
+
188
+ def test_stop_hook_active_flag_passes_through() -> None:
189
+ """A re-entrant Stop invocation never blocks again."""
190
+ completed_process = run_hook_with_payload(
191
+ {"last_assistant_message": OVERLONG_REPLY, "stop_hook_active": True}
192
+ )
193
+ assert completed_process.returncode == 0
194
+ assert completed_process.stdout == ""
195
+
196
+
197
+ def test_reply_over_word_cap_emits_block() -> None:
198
+ """A reply past the word cap blocks with the count and the cap named."""
199
+ completed_process = run_hook_with_message(OVERLONG_REPLY)
200
+ assert completed_process.returncode == 0
201
+ parsed_response = json.loads(completed_process.stdout)
202
+ assert parsed_response["decision"] == "block"
203
+ assert str(OVERLONG_REPLY_WORD_COUNT) in parsed_response["reason"]
204
+ assert str(MAXIMUM_REPLY_WORD_COUNT) in parsed_response["reason"]
205
+
206
+
207
+ def test_reply_just_over_word_cap_emits_block() -> None:
208
+ """A reply one word past the cap blocks on length alone."""
209
+ completed_process = run_hook_with_message(JUST_OVER_WORD_CAP_REPLY)
210
+ assert completed_process.returncode == 0
211
+ parsed_response = json.loads(completed_process.stdout)
212
+ assert parsed_response["decision"] == "block"
213
+ assert (
214
+ f"{JUST_OVER_WORD_CAP_WORD_COUNT} words, over the "
215
+ f"{MAXIMUM_REPLY_WORD_COUNT}-word cap"
216
+ ) in parsed_response["reason"]
217
+
218
+
219
+ def test_reply_under_word_floor_passes_through() -> None:
220
+ """A reply one word under the floor is never judged."""
221
+ completed_process = run_hook_with_message(UNDER_FLOOR_REPLY)
222
+ assert completed_process.returncode == 0
223
+ assert completed_process.stdout == ""
224
+
225
+
226
+ def test_reply_at_the_word_floor_is_judged_for_action_first() -> None:
227
+ """A reply of exactly the floor word count earns its action-first violation."""
228
+ completed_process = run_hook_with_message(AT_FLOOR_ACTION_REPLY)
229
+ assert completed_process.returncode == 0
230
+ parsed_response = json.loads(completed_process.stdout)
231
+ assert parsed_response["decision"] == "block"
232
+ assert "put the steps first" in parsed_response["reason"]
233
+
234
+
235
+ def test_reply_just_over_word_floor_is_judged() -> None:
236
+ """A bullet-heavy reply just over the floor earns its bullet violation."""
237
+ completed_process = run_hook_with_message(SEVEN_BULLET_JUST_OVER_FLOOR_REPLY)
238
+ assert completed_process.returncode == 0
239
+ parsed_response = json.loads(completed_process.stdout)
240
+ assert parsed_response["decision"] == "block"
241
+ assert (
242
+ f"cut findings to {TARGET_BULLET_LINE_COUNT} bullets"
243
+ in parsed_response["reason"]
244
+ )
245
+
246
+
247
+ def test_three_overpacked_lines_emit_block() -> None:
248
+ """One line past the overpacked-line cap blocks on one idea per line."""
249
+ completed_process = run_hook_with_message(THREE_OVERPACKED_LINE_REPLY)
250
+ assert completed_process.returncode == 0
251
+ parsed_response = json.loads(completed_process.stdout)
252
+ assert parsed_response["decision"] == "block"
253
+ assert "lines carry too many words - one idea per line" in (
254
+ parsed_response["reason"]
255
+ )
256
+
257
+
258
+ def test_two_overpacked_lines_pass_through() -> None:
259
+ """Over-packed lines at the cap pass."""
260
+ completed_process = run_hook_with_message(TWO_OVERPACKED_LINE_REPLY)
261
+ assert completed_process.returncode == 0
262
+ assert completed_process.stdout == ""
263
+
264
+
265
+ def test_bullet_marker_is_not_counted_as_a_line_word() -> None:
266
+ """A bullet at the per-line cap stays under it once its marker comes off."""
267
+ capped_bullet_lines = build_bullet_block(
268
+ MAXIMUM_BULLET_LINE_COUNT - 2, MAXIMUM_WORDS_PER_LINE
269
+ )
270
+ all_violations = eli11_reply_enforcer.find_reply_shape_violations(
271
+ capped_bullet_lines
272
+ )
273
+ assert all_violations == []
274
+
275
+
276
+ def test_long_form_prefix_exempts_an_overlong_reply() -> None:
277
+ """The Long form escape hatch clears every reply-shape check."""
278
+ completed_process = run_hook_with_message(LONG_FORM_OVERLONG_REPLY)
279
+ assert completed_process.returncode == 0
280
+ assert completed_process.stdout == ""
281
+
282
+
283
+ def test_bold_long_form_prefix_exempts_an_overlong_reply() -> None:
284
+ """A bold-wrapped Long form prefix still opts the reply out."""
285
+ completed_process = run_hook_with_message(BOLD_LONG_FORM_OVERLONG_REPLY)
286
+ assert completed_process.returncode == 0
287
+ assert completed_process.stdout == ""
288
+
289
+
290
+ def test_quoted_long_form_prefix_exempts_an_overlong_reply() -> None:
291
+ """A blockquoted Long form prefix still opts the reply out."""
292
+ completed_process = run_hook_with_message(QUOTED_LONG_FORM_OVERLONG_REPLY)
293
+ assert completed_process.returncode == 0
294
+ assert completed_process.stdout == ""
295
+
296
+
297
+ def test_heading_long_form_prefix_exempts_an_overlong_reply() -> None:
298
+ """A heading-wrapped Long form prefix still opts the reply out."""
299
+ completed_process = run_hook_with_message(HEADING_LONG_FORM_OVERLONG_REPLY)
300
+ assert completed_process.returncode == 0
301
+ assert completed_process.stdout == ""
302
+
303
+
304
+ def test_instruction_lines_without_leading_steps_emit_block() -> None:
305
+ """Imperative instructions buried under prose block with a steps-first message."""
306
+ completed_process = run_hook_with_message(ACTION_WITHOUT_STEPS_FIRST_REPLY)
307
+ assert completed_process.returncode == 0
308
+ parsed_response = json.loads(completed_process.stdout)
309
+ assert parsed_response["decision"] == "block"
310
+ assert "put the steps first" in parsed_response["reason"]
311
+
312
+
313
+ def test_numbered_steps_in_lead_lines_pass_through() -> None:
314
+ """Numbered steps inside the lead lines satisfy the action-first check."""
315
+ completed_process = run_hook_with_message(ACTION_WITH_STEPS_FIRST_REPLY)
316
+ assert completed_process.returncode == 0
317
+ assert completed_process.stdout == ""
318
+
319
+
320
+ def test_narrative_lines_opening_with_a_tracked_verb_pass_through() -> None:
321
+ """Narrative openers such as "Open questions remain" are not instructions."""
322
+ completed_process = run_hook_with_message(NARRATIVE_OPENER_REPLY)
323
+ assert completed_process.returncode == 0
324
+ assert completed_process.stdout == ""
325
+
326
+
327
+ def test_each_narrative_opener_is_not_an_instruction_line() -> None:
328
+ """Every narrative opener reads as a sentence subject, not an imperative."""
329
+ for each_line in ALL_NARRATIVE_OPENER_LINES:
330
+ assert not eli11_reply_enforcer.is_imperative_instruction_line(each_line)
331
+
332
+
333
+ def test_object_word_after_leading_verb_reads_the_second_word() -> None:
334
+ """The word after the opening verb comes back, list markers ignored."""
335
+ assert eli11_reply_enforcer.object_word_after_leading_verb(
336
+ "1. **Run** the migration script."
337
+ ) == "the"
338
+ assert eli11_reply_enforcer.object_word_after_leading_verb("Merge") == ""
339
+
340
+
341
+ def test_names_imperative_object_accepts_determiners_counts_and_paths() -> None:
342
+ """A determiner, a count, or a path marks a real imperative object."""
343
+ assert eli11_reply_enforcer.names_imperative_object("the")
344
+ assert eli11_reply_enforcer.names_imperative_object("3")
345
+ assert eli11_reply_enforcer.names_imperative_object("scripts/deploy.py")
346
+ assert not eli11_reply_enforcer.names_imperative_object("questions")
347
+ assert not eli11_reply_enforcer.names_imperative_object("")
348
+
349
+
350
+ def test_strip_markdown_lead_markers_removes_every_wrapper() -> None:
351
+ """Blockquote, heading, and bold wrappers come off the front of a line."""
352
+ assert eli11_reply_enforcer.strip_markdown_lead_markers(
353
+ "> **Long form:** the report follows"
354
+ ) == "Long form:** the report follows"
355
+ assert eli11_reply_enforcer.strip_markdown_lead_markers(
356
+ "# Long form: the report follows"
357
+ ) == "Long form: the report follows"
358
+
359
+
360
+ def test_install_instruction_without_leading_steps_emits_block() -> None:
361
+ """An install instruction buried under prose blocks on action-first."""
362
+ completed_process = run_hook_with_message(INSTALL_WITHOUT_STEPS_FIRST_REPLY)
363
+ assert completed_process.returncode == 0
364
+ parsed_response = json.loads(completed_process.stdout)
365
+ assert parsed_response["decision"] == "block"
366
+ assert "put the steps first" in parsed_response["reason"]
367
+
368
+
369
+ def test_do_three_things_without_leading_steps_emits_block() -> None:
370
+ """The canonical "Do 3 things:" opener counts as an instruction line."""
371
+ completed_process = run_hook_with_message(DO_THINGS_WITHOUT_STEPS_FIRST_REPLY)
372
+ assert completed_process.returncode == 0
373
+ parsed_response = json.loads(completed_process.stdout)
374
+ assert parsed_response["decision"] == "block"
375
+ assert "put the steps first" in parsed_response["reason"]
376
+
377
+
378
+ def test_do_three_things_with_numbered_steps_passes_through() -> None:
379
+ """The canonical action-first opener with its numbered steps passes."""
380
+ completed_process = run_hook_with_message(DO_THINGS_WITH_STEPS_FIRST_REPLY)
381
+ assert completed_process.returncode == 0
382
+ assert completed_process.stdout == ""
383
+
384
+
385
+ def test_decimal_and_year_openers_are_not_numbered_steps() -> None:
386
+ """A decimal or a year opening a line is prose, not a numbered step."""
387
+ assert not eli11_reply_enforcer.has_leading_numbered_step(
388
+ ["1.5% of hosts still fail."]
389
+ )
390
+ assert not eli11_reply_enforcer.has_leading_numbered_step(
391
+ ["2024. Revenue doubled."]
392
+ )
393
+
394
+
395
+ def test_numbered_step_opener_is_a_numbered_step() -> None:
396
+ """A digit, a period, and a space open a real numbered step."""
397
+ assert eli11_reply_enforcer.has_leading_numbered_step(["1. Run the script"])
398
+
399
+
400
+ def test_more_than_six_bullets_emits_block() -> None:
401
+ """One bullet past the cap blocks with the cut-to-target-bullets message."""
402
+ completed_process = run_hook_with_message(SEVEN_BULLET_REPLY)
403
+ assert completed_process.returncode == 0
404
+ parsed_response = json.loads(completed_process.stdout)
405
+ assert parsed_response["decision"] == "block"
406
+ assert (
407
+ f"cut findings to {TARGET_BULLET_LINE_COUNT} bullets"
408
+ in parsed_response["reason"]
409
+ )
410
+
411
+
412
+ def test_six_bullets_pass_through() -> None:
413
+ """Bullets at the cap pass."""
414
+ completed_process = run_hook_with_message(SIX_BULLET_REPLY)
415
+ assert completed_process.returncode == 0
416
+ assert completed_process.stdout == ""
417
+
418
+
419
+ def test_fenced_code_words_are_not_counted() -> None:
420
+ """Words inside a fenced code block never push a reply past the cap."""
421
+ completed_process = run_hook_with_message(FENCED_CODE_REPLY)
422
+ assert completed_process.returncode == 0
423
+ assert completed_process.stdout == ""
424
+
425
+
426
+ def test_blockquote_words_are_not_counted() -> None:
427
+ """Quoted lines are the user's words and never push a reply past the cap."""
428
+ completed_process = run_hook_with_message(BLOCKQUOTE_REPLY)
429
+ assert completed_process.returncode == 0
430
+ assert completed_process.stdout == ""
431
+
432
+
433
+ def test_table_rows_are_not_counted() -> None:
434
+ """Table rows carry reference data and never push a reply past the cap."""
435
+ completed_process = run_hook_with_message(TABLE_REPLY)
436
+ assert completed_process.returncode == 0
437
+ assert completed_process.stdout == ""
438
+
439
+
440
+ def test_urls_are_removed_from_counted_prose() -> None:
441
+ """A link target is stripped before the words are counted."""
442
+ prose_text = eli11_reply_enforcer.extract_reply_prose(
443
+ "The draft is at https://github.com/owner/repo/pull/704 now"
444
+ )
445
+ assert "github.com" not in prose_text
446
+ assert "draft" in prose_text
447
+
448
+
449
+ def test_block_response_json_shape() -> None:
450
+ """The block payload carries the Stop-hook keys and names the escape hatch."""
451
+ completed_process = run_hook_with_message(OVERLONG_REPLY)
452
+ assert completed_process.returncode == 0
453
+ parsed_response = json.loads(completed_process.stdout)
454
+ assert parsed_response["decision"] == "block"
455
+ assert parsed_response["suppressOutput"] is True
456
+ assert parsed_response["systemMessage"]
457
+ assert "Long form:" in parsed_response["reason"]
@@ -0,0 +1,124 @@
1
+ """Tests for shell_substitution_blocker hook."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ SCRIPT_PATH = Path(__file__).parent / "shell_substitution_blocker.py"
10
+
11
+
12
+ def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
13
+ return subprocess.run(
14
+ [sys.executable, str(SCRIPT_PATH)],
15
+ input=json.dumps(payload),
16
+ text=True,
17
+ capture_output=True,
18
+ check=False,
19
+ )
20
+
21
+
22
+ def test_denies_dollar_paren_substitution() -> None:
23
+ payload = {
24
+ "tool_name": "Bash",
25
+ "tool_input": {"command": 'echo "head: $(git rev-parse HEAD)"'},
26
+ }
27
+ response = json.loads(_run_hook(payload).stdout)
28
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
29
+ assert (
30
+ "shell-substitution"
31
+ in response["hookSpecificOutput"]["permissionDecisionReason"]
32
+ )
33
+
34
+
35
+ def test_denies_unescaped_backtick_substitution() -> None:
36
+ payload = {
37
+ "tool_name": "Bash",
38
+ "tool_input": {"command": "echo `date`"},
39
+ }
40
+ response = json.loads(_run_hook(payload).stdout)
41
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
42
+
43
+
44
+ def test_allows_plain_command_without_substitution() -> None:
45
+ payload = {
46
+ "tool_name": "Bash",
47
+ "tool_input": {"command": "git rev-parse HEAD"},
48
+ }
49
+ assert _run_hook(payload).stdout == ""
50
+
51
+
52
+ def test_allows_escaped_backtick_in_prose() -> None:
53
+ payload = {
54
+ "tool_name": "Bash",
55
+ "tool_input": {"command": r'echo "use \`foo\` like this"'},
56
+ }
57
+ assert _run_hook(payload).stdout == ""
58
+
59
+
60
+ def test_ignores_non_bash_tool() -> None:
61
+ payload = {
62
+ "tool_name": "Edit",
63
+ "tool_input": {"command": 'echo "$(date)"'},
64
+ }
65
+ assert _run_hook(payload).stdout == ""
66
+
67
+
68
+ def test_denies_double_backslash_backtick_bypass() -> None:
69
+ payload = {
70
+ "tool_name": "Bash",
71
+ "tool_input": {"command": r"echo \\`date`"},
72
+ }
73
+ response = json.loads(_run_hook(payload).stdout)
74
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
75
+
76
+
77
+ def test_allows_dollar_paren_inside_single_quotes() -> None:
78
+ payload = {
79
+ "tool_name": "Bash",
80
+ "tool_input": {"command": "echo '$(not-executed)'"},
81
+ }
82
+ assert _run_hook(payload).stdout == ""
83
+
84
+
85
+ def test_allows_backtick_inside_single_quotes() -> None:
86
+ payload = {
87
+ "tool_name": "Bash",
88
+ "tool_input": {"command": "echo '`not-executed`'"},
89
+ }
90
+ assert _run_hook(payload).stdout == ""
91
+
92
+
93
+ def test_denies_input_process_substitution() -> None:
94
+ payload = {
95
+ "tool_name": "Bash",
96
+ "tool_input": {"command": "diff <(cat a) <(cat b)"},
97
+ }
98
+ response = json.loads(_run_hook(payload).stdout)
99
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
100
+
101
+
102
+ def test_denies_output_process_substitution() -> None:
103
+ payload = {
104
+ "tool_name": "Bash",
105
+ "tool_input": {"command": "tee >(gzip > out.gz)"},
106
+ }
107
+ response = json.loads(_run_hook(payload).stdout)
108
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
109
+
110
+
111
+ def test_allows_process_substitution_inside_single_quotes() -> None:
112
+ payload = {
113
+ "tool_name": "Bash",
114
+ "tool_input": {"command": "echo '<(not-executed)'"},
115
+ }
116
+ assert _run_hook(payload).stdout == ""
117
+
118
+
119
+ def test_allows_arithmetic_expansion() -> None:
120
+ payload = {
121
+ "tool_name": "Bash",
122
+ "tool_input": {"command": "echo $((2+2))"},
123
+ }
124
+ assert _run_hook(payload).stdout == ""
@@ -173,6 +173,29 @@ def test_dispatcher_blocks_hedging_message_matching_standalone() -> None:
173
173
  assert "probably" in parsed["reason"].lower() or "hedging" in parsed["reason"].lower()
174
174
 
175
175
 
176
+ def test_dispatcher_blocks_overlong_reply_matching_standalone() -> None:
177
+ """An overlong last_assistant_message blocks through the dispatcher."""
178
+ overlong_message = "\n".join(
179
+ " ".join(f"finding{each_index}" for each_index in range(10))
180
+ for _ in range(30)
181
+ )
182
+ payload_text = json.dumps(
183
+ {"stop_hook_active": False, "last_assistant_message": overlong_message}
184
+ )
185
+ completed = subprocess.run(
186
+ [sys.executable, _DISPATCHER_SCRIPT],
187
+ check=False,
188
+ input=payload_text,
189
+ capture_output=True,
190
+ text=True,
191
+ encoding="utf-8",
192
+ )
193
+ assert completed.returncode == 0
194
+ parsed = json.loads(completed.stdout)
195
+ assert parsed["decision"] == "block"
196
+ assert "ELI11 REPLY SHAPE" in parsed["reason"]
197
+
198
+
176
199
  def test_dispatcher_imports_standalone_with_only_blocking_on_the_path() -> None:
177
200
  """The dispatcher's bootstrap resolves hooks_constants without hooks/ on PYTHONPATH."""
178
201
  subprocess_environment = {**os.environ, "PYTHONPATH": str(_BLOCKING_DIR)}