claude-dev-env 7.0.0 → 7.2.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 (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +213 -0
  6. package/.agents/skills/session-continuity/SKILL.md +125 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +447 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +88 -0
  20. package/bin/install-session-continuity.test.mjs +55 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -1,182 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Stop hook that blocks Claude responses proposing to stop on account of context.
4
-
5
- When a turn proposes stopping, summarizing to hand off, or starting a new
6
- session because of context or token limits, the agent is reassured that ample
7
- context remains and forced to continue the work. A mere topical mention of the
8
- word "context" does not fire - only a self-termination or handoff proposal does.
9
- The rule name is long-horizon-autonomy.
10
- """
11
-
12
- import json
13
- import re
14
- import sys
15
- from pathlib import Path
16
-
17
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
- if _hooks_dir not in sys.path:
19
- sys.path.insert(0, _hooks_dir)
20
-
21
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
- from hooks_constants.messages import USER_FACING_CONTEXT_REASSURANCE_NOTICE # noqa: E402
23
- from hooks_constants.session_handoff_blocker_constants import ( # noqa: E402
24
- FIRST_PERSON_SUBJECT_PATTERN,
25
- )
26
- from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
27
-
28
-
29
- def split_into_sentences(text: str) -> list[str]:
30
- """Return the non-empty sentences of the prose.
31
-
32
- Args:
33
- text: The prose to split on sentence boundaries.
34
- """
35
- sentence_boundary_pattern = re.compile(r"(?<=[.!?])\s+")
36
- return [
37
- each_sentence.strip()
38
- for each_sentence in sentence_boundary_pattern.split(text)
39
- if each_sentence.strip()
40
- ]
41
-
42
-
43
- def has_first_person_self_termination(text: str) -> bool:
44
- """Return whether any sentence binds a first-person subject to a stop or handoff cue.
45
-
46
- Args:
47
- text: The prose to scan sentence by sentence.
48
- """
49
- self_termination_cue_pattern = re.compile(
50
- r"\b(?:stop|summari[sz]\w*|wrap\s+up|wrap\s+things\s+up"
51
- r"|hand\s+(?:off|it\s+off|this\s+off)|pause"
52
- r"|continue\s+(?:this|later)|pick\s+(?:this|it)\s+up"
53
- r"|new\s+session|fresh\s+session|separate\s+session|clean\s+session"
54
- r"|running\s+(?:low|out)\s+(?:on|of)\s+(?:context|tokens)"
55
- r"|(?:low|short)\s+on\s+(?:context|tokens))\b",
56
- re.IGNORECASE,
57
- )
58
- for each_sentence in split_into_sentences(text):
59
- if FIRST_PERSON_SUBJECT_PATTERN.search(
60
- each_sentence
61
- ) and self_termination_cue_pattern.search(each_sentence):
62
- return True
63
- return False
64
-
65
-
66
- def has_resource_reference_with_handoff_cue(text: str) -> bool:
67
- """Return whether any sentence pairs a context/token reference with a stop cue.
68
-
69
- Args:
70
- text: The prose to scan sentence by sentence.
71
- """
72
- resource_reference_pattern = re.compile(
73
- r"\b(?:context|token)\s+(?:budget|window|limit|count|usage)\b"
74
- r"|\b(?:low|short)\s+on\s+(?:context|tokens)\b"
75
- r"|\bto\s+(?:save|conserve|preserve|free\s+up)\s+(?:context|tokens)\b",
76
- re.IGNORECASE,
77
- )
78
- stop_or_handoff_cue_pattern = re.compile(
79
- r"\b(?:stop|summari[sz]\w*|wrap\s+up|wrap\s+things\s+up|hand\s+off"
80
- r"|new\s+session|pause|continue\s+later|pick\s+this\s+up\s+later)\b",
81
- re.IGNORECASE,
82
- )
83
- for each_sentence in split_into_sentences(text):
84
- if resource_reference_pattern.search(
85
- each_sentence
86
- ) and stop_or_handoff_cue_pattern.search(each_sentence):
87
- return True
88
- return False
89
-
90
-
91
- def has_first_person_direct_handoff(text: str) -> bool:
92
- """Return whether any sentence binds a first-person subject to a direct-handoff cue.
93
-
94
- Args:
95
- text: The prose to scan sentence by sentence.
96
- """
97
- new_session_proposal_pattern = re.compile(
98
- r"\b(?:wrap\s+up|wrap\s+things\s+up|hand\s+off|hand\s+this\s+off"
99
- r"|hand\s+it\s+off|continue\s+this|continue\s+later|pick\s+this\s+up"
100
- r"|pick\s+it\s+up|pause|resume)\b"
101
- r"[^.!?]*"
102
- r"\b(?:a\s+|the\s+)?(?:new|fresh|separate|clean)\s+session\b",
103
- re.IGNORECASE,
104
- )
105
- running_low_pattern = re.compile(
106
- r"\brunning\s+(?:low|out)\s+(?:on|of)\s+(?:context|tokens)\b",
107
- re.IGNORECASE,
108
- )
109
- all_direct_handoff_patterns = [
110
- new_session_proposal_pattern,
111
- running_low_pattern,
112
- ]
113
- for each_sentence in split_into_sentences(text):
114
- if not FIRST_PERSON_SUBJECT_PATTERN.search(each_sentence):
115
- continue
116
- if any(
117
- each_pattern.search(each_sentence)
118
- for each_pattern in all_direct_handoff_patterns
119
- ):
120
- return True
121
- return False
122
-
123
-
124
- def find_session_handoff_proposal(text: str) -> bool:
125
- """Return whether the message proposes stopping on account of context or tokens.
126
-
127
- Args:
128
- text: The raw assistant message to evaluate.
129
- """
130
- prose_text = strip_code_and_quotes(text)
131
-
132
- if not has_first_person_self_termination(prose_text):
133
- return False
134
-
135
- if has_first_person_direct_handoff(prose_text):
136
- return True
137
-
138
- return has_resource_reference_with_handoff_cue(prose_text)
139
-
140
-
141
- def main() -> None:
142
- """Read the stop-hook payload and block turns proposing a context-driven handoff."""
143
- try:
144
- hook_input = json.load(sys.stdin)
145
- except json.JSONDecodeError:
146
- sys.exit(0)
147
-
148
- if hook_input.get("stop_hook_active", False):
149
- sys.exit(0)
150
-
151
- assistant_message = hook_input.get("last_assistant_message", "")
152
-
153
- if not assistant_message:
154
- sys.exit(0)
155
-
156
- if not find_session_handoff_proposal(assistant_message):
157
- sys.exit(0)
158
-
159
- block_reason = (
160
- "LONG-HORIZON-AUTONOMY GUARDRAIL: You have ample context remaining. Do not "
161
- "stop, summarize, or suggest a new session on account of context limits. "
162
- "Continue the work.\n\n"
163
- "Re-output your response continuing the task without the handoff suggestion, "
164
- "per the long-horizon-autonomy rule."
165
- )
166
- block_response = {
167
- "decision": "block",
168
- "reason": block_reason,
169
- "systemMessage": USER_FACING_CONTEXT_REASSURANCE_NOTICE,
170
- "suppressOutput": True,
171
- }
172
- log_hook_block(
173
- calling_hook_name="session_handoff_blocker.py",
174
- hook_event="Stop",
175
- block_reason=block_reason,
176
- )
177
- print(json.dumps(block_response))
178
- sys.exit(0)
179
-
180
-
181
- if __name__ == "__main__":
182
- main()
@@ -1,350 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: block Bash commands containing shell command substitution.
3
-
4
- Root cause: Claude Code's auto-allowlist matcher operates on the raw command
5
- string, not on parsed syntax, so it does not descend into command substitutions
6
- such as $(...), unescaped backticks, or bash process substitutions <(...) and
7
- >(...). A compound of the form
8
-
9
- cd X && echo "$(git rev-parse HEAD)"
10
-
11
- therefore falls outside the auto-allowed set for `cd` + `echo` + `git rev-parse`
12
- and forces a permission prompt, even though every segment would be auto-allowed
13
- individually. The same mechanics apply to `diff <(cat a) <(cat b)` and
14
- `tee >(gzip > out.gz)`: each inner command would be auto-allowed on its own,
15
- but the outer wrapper defeats the matcher. Splitting into two separate Bash
16
- tool calls, or collapsing to a substitution-free form like
17
- `git -C X rev-parse HEAD`, avoids the prompt.
18
-
19
- Detection: a regex match for `$(`, an unescaped backtick, or `<(` / `>(`, after
20
- stripping single-quoted literal runs. Single-quoted regions are intentionally
21
- ignored because bash performs no substitution inside them (for example
22
- `echo '$(not-executed)'`). Backslash-escaped backticks outside single quotes
23
- are treated as literal only when the count of preceding backslashes is odd;
24
- an even count (including zero) means the backtick is a live substitution.
25
-
26
- A quoted heredoc body is stripped for the same reason. `<<'EOF'`, `<<"EOF"`
27
- and `<<\\EOF` each tell bash to expand nothing between the opener and the
28
- terminator, so a backtick there is text the file receives rather than a
29
- command the shell runs. A bare `<<EOF` expands its body and keeps its scan,
30
- and text outside any heredoc is scanned either way.
31
-
32
- Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
33
- spawn a subshell and does not defeat the allowlist matcher. Telling it apart
34
- from a disguised command substitution takes more than counting two open
35
- parens, because bash itself falls back from arithmetic to command
36
- substitution when the expansion body does not close as arithmetic. Observed
37
- against real bash:
38
-
39
- echo $((1 + 2)) prints 3 (arithmetic; not blocked)
40
- echo $((cd /tmp) && pwd) prints the new cwd (command substitution; blocked)
41
- echo $((1+2) ) runs "1+2" as a command, rc=0, no output
42
- (command substitution; blocked)
43
-
44
- Bash's own rule is the terminator: walk forward from ``$((`` tracking paren
45
- depth; when depth returns to zero on a closer immediately preceded by
46
- another ``)`` (an ``))`` pair), the expansion is arithmetic and passes. When
47
- the closer that brings depth to zero is a lone ``)``, bash has fallen back to
48
- running a parenthesized command inside the substitution, so it is blocked.
49
- This hook applies that walk, tracking paren depth from the opening ``$((``.
50
- """
51
-
52
- from __future__ import annotations
53
-
54
- import json
55
- import sys
56
- from pathlib import Path
57
- from re import Match
58
-
59
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
60
- if _hooks_dir not in sys.path:
61
- sys.path.insert(0, _hooks_dir)
62
-
63
- from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
64
- BASH_TOOL_NAME,
65
- CLOSE_PAREN_CHARACTER,
66
- COMMAND_KEY,
67
- CORRECTIVE_MESSAGE,
68
- DENY_DECISION,
69
- DOLLAR_PAREN_PATTERN,
70
- EVEN_BACKSLASH_BACKTICK_PATTERN,
71
- HOOK_EVENT_NAME,
72
- HOOK_EVENT_NAME_KEY,
73
- HOOK_SPECIFIC_OUTPUT_KEY,
74
- OPEN_PAREN_CHARACTER,
75
- PAREN_DEPTH_AFTER_DOUBLE_OPEN,
76
- PERMISSION_DECISION_KEY,
77
- PERMISSION_DECISION_REASON_KEY,
78
- PROCESS_SUBSTITUTION_PATTERN,
79
- BACKSLASH_CHARACTER,
80
- COMMAND_LINE_SEPARATOR,
81
- DOUBLE_QUOTE_CHARACTER,
82
- NO_TERMINATOR_INDEX,
83
- SINGLE_QUOTE_CHARACTER,
84
- SINGLE_QUOTED_RUN_PATTERN,
85
- STRIPPED_RUN_REPLACEMENT,
86
- TOOL_INPUT_KEY,
87
- TOOL_NAME_KEY,
88
- )
89
-
90
-
91
- from hooks_constants.piped_pytest_blocker_constants import ( # noqa: E402
92
- HEREDOC_ESCAPE_GROUP,
93
- HEREDOC_OPENER_PATTERN,
94
- HEREDOC_QUOTE_GROUP,
95
- HEREDOC_TAB_STRIP_GROUP,
96
- HEREDOC_TAB_STRIP_MARKER,
97
- HEREDOC_TERMINATOR_GROUP,
98
- HEREDOC_UNQUOTED_MARKER,
99
- )
100
- from hooks_constants.shell_command_pipeline import ( # noqa: E402
101
- PendingHeredoc,
102
- closes_the_heredoc,
103
- )
104
-
105
-
106
- def _strip_single_quoted_runs(command: str) -> str:
107
- return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
108
-
109
-
110
- def _opener_suppresses_expansion(opener_match: Match[str]) -> bool:
111
- """Return whether an opener's delimiter is quoted or backslash-escaped.
112
-
113
- ::
114
-
115
- <<'EOF' -> True quoted, the body is literal
116
- <<EOF -> False bare, bash expands the body
117
-
118
- Args:
119
- opener_match: One HEREDOC_OPENER_PATTERN match from a command line.
120
-
121
- Returns:
122
- True when bash expands nothing down to this opener's terminator.
123
- """
124
- is_quoted = opener_match.group(HEREDOC_QUOTE_GROUP) != HEREDOC_UNQUOTED_MARKER
125
- is_escaped = opener_match.group(HEREDOC_ESCAPE_GROUP) != HEREDOC_UNQUOTED_MARKER
126
- return is_quoted or is_escaped
127
-
128
-
129
- def _sits_outside_quotes(command_line: str, character_index: int) -> bool:
130
- """Return whether a position on a line sits outside every quoted run.
131
-
132
- ::
133
-
134
- cat <<'EOF' index of << -> True bash reads an opener
135
- echo "<<'EOF'" index of << -> False bash reads a string
136
-
137
- Counting unescaped quotes before the position tells the two apart: an even
138
- count of each quote character means every run opened before it also closed.
139
-
140
- Args:
141
- command_line: One line of the command.
142
- character_index: Position of the candidate opener on that line.
143
-
144
- Returns:
145
- True when bash reads this position as shell syntax.
146
- """
147
- single_quote_count = 0
148
- double_quote_count = 0
149
- is_escaped = False
150
- for each_character in command_line[:character_index]:
151
- if is_escaped:
152
- is_escaped = False
153
- continue
154
- if each_character == BACKSLASH_CHARACTER:
155
- is_escaped = True
156
- continue
157
- if each_character == SINGLE_QUOTE_CHARACTER:
158
- single_quote_count += 1
159
- elif each_character == DOUBLE_QUOTE_CHARACTER:
160
- double_quote_count += 1
161
- return single_quote_count % 2 == 0 and double_quote_count % 2 == 0
162
-
163
-
164
- def _first_literal_opener(command_line: str) -> Match[str] | None:
165
- """Return the line's first heredoc opener whose body bash leaves literal.
166
-
167
- An opener spelled inside a quoted string is text bash passes on, so it
168
- opens nothing and the lines below it stay in the scan.
169
-
170
- Args:
171
- command_line: One line of the command.
172
-
173
- Returns:
174
- The matching opener, or None when the line opens no literal heredoc.
175
- """
176
- for each_opener in HEREDOC_OPENER_PATTERN.finditer(command_line):
177
- if not _sits_outside_quotes(command_line, each_opener.start()):
178
- continue
179
- if _opener_suppresses_expansion(each_opener):
180
- return each_opener
181
- return None
182
-
183
-
184
- def _index_past_literal_body(
185
- all_lines: list[str], body_start_index: int, opener_match: Match[str]
186
- ) -> int:
187
- """Return the index of the line that closes a literal heredoc body.
188
-
189
- The terminator match, including the ``<<-`` tab-strip rule, comes from
190
- ``closes_the_heredoc`` so this hook and the shared pipeline agree on what
191
- ends a body.
192
-
193
- Args:
194
- all_lines: Every line of the command.
195
- body_start_index: Index of the body's first line.
196
- opener_match: The opener whose terminator closes this body.
197
-
198
- Returns:
199
- The index of the terminator line, or NO_TERMINATOR_INDEX when the
200
- command holds none.
201
- """
202
- pending_heredoc = PendingHeredoc(
203
- terminator=opener_match.group(HEREDOC_TERMINATOR_GROUP),
204
- allows_leading_tabs=(
205
- opener_match.group(HEREDOC_TAB_STRIP_GROUP) == HEREDOC_TAB_STRIP_MARKER
206
- ),
207
- )
208
- for each_index in range(body_start_index, len(all_lines)):
209
- if closes_the_heredoc(all_lines[each_index], pending_heredoc):
210
- return each_index
211
- return NO_TERMINATOR_INDEX
212
-
213
-
214
- def _strip_quoted_heredoc_bodies(command: str) -> str:
215
- """Return the command holding its openers, terminators, and shell text.
216
-
217
- ::
218
-
219
- cat <<'EOF' -> cat <<'EOF'
220
- inert text EOF
221
- EOF
222
-
223
- A quoted or escaped delimiter makes the lines below it text the shell hands
224
- on, so a scan of those lines reports a substitution bash never runs. A bare
225
- delimiter keeps its body, and text outside any heredoc keeps its scan.
226
-
227
- An opener with no terminator returns the command whole. Bash consumes the
228
- rest of the input in that case, and dropping those lines would hide a live
229
- substitution among them.
230
-
231
- Args:
232
- command: The raw Bash command string from the tool input.
233
-
234
- Returns:
235
- The command with each literal heredoc body dropped.
236
- """
237
- all_lines = command.split(COMMAND_LINE_SEPARATOR)
238
- all_kept_lines: list[str] = []
239
- each_index = 0
240
- while each_index < len(all_lines):
241
- current_line = all_lines[each_index]
242
- all_kept_lines.append(current_line)
243
- each_index += 1
244
- opener_match = _first_literal_opener(current_line)
245
- if opener_match is None:
246
- continue
247
- terminator_index = _index_past_literal_body(all_lines, each_index, opener_match)
248
- if terminator_index == NO_TERMINATOR_INDEX:
249
- return command
250
- each_index = terminator_index
251
- return COMMAND_LINE_SEPARATOR.join(all_kept_lines)
252
-
253
-
254
- def _closes_as_arithmetic_expansion(command: str, scan_start_index: int) -> bool:
255
- """Return True when a ``$((`` expansion closes on an ``))`` pair.
256
-
257
- See the module docstring for the real-bash evidence behind this rule.
258
- """
259
- paren_depth = PAREN_DEPTH_AFTER_DOUBLE_OPEN
260
- was_previous_character_close_paren = False
261
- for each_index in range(scan_start_index, len(command)):
262
- current_character = command[each_index]
263
- if current_character == OPEN_PAREN_CHARACTER:
264
- paren_depth += 1
265
- was_previous_character_close_paren = False
266
- continue
267
- if current_character != CLOSE_PAREN_CHARACTER:
268
- was_previous_character_close_paren = False
269
- continue
270
- paren_depth -= 1
271
- if paren_depth == 0:
272
- return was_previous_character_close_paren
273
- was_previous_character_close_paren = True
274
- return False
275
-
276
-
277
- def _has_unsafe_dollar_paren_expansion(command: str) -> bool:
278
- """Return True for a `$(` that is not a safe `$((...))` arithmetic pair."""
279
- for each_match in DOLLAR_PAREN_PATTERN.finditer(command):
280
- is_arithmetic_style_open = (
281
- command[each_match.end() : each_match.end() + 1] == OPEN_PAREN_CHARACTER
282
- )
283
- if not is_arithmetic_style_open:
284
- return True
285
- if not _closes_as_arithmetic_expansion(command, each_match.end() + 1):
286
- return True
287
- return False
288
-
289
-
290
- def has_shell_substitution(command: str) -> bool:
291
- """Return True when a Bash command carries a live shell substitution.
292
-
293
- ::
294
-
295
- echo "$(git rev-parse HEAD)" flag: command substitution
296
- echo `date` flag: live backtick
297
- diff <(cat a) <(cat b) flag: process substitution
298
- echo '$(not-executed)' ok: single-quoted, inert
299
- echo $((2 + 2)) ok: arithmetic expansion
300
-
301
- Literal heredoc bodies are dropped first, then single-quoted runs are
302
- stripped, because bash substitutes nothing inside either. A backtick
303
- preceded by an odd backslash count is escaped.
304
- A `$((` pair is walked to its terminator (module docstring) to tell
305
- real arithmetic from a disguised subshell.
306
-
307
- Args:
308
- command: The raw Bash command string from the tool input.
309
-
310
- Returns:
311
- True when a substitution the matcher cannot descend into is present.
312
- """
313
- scannable_command = _strip_single_quoted_runs(_strip_quoted_heredoc_bodies(command))
314
- if _has_unsafe_dollar_paren_expansion(scannable_command):
315
- return True
316
- if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
317
- return True
318
- if PROCESS_SUBSTITUTION_PATTERN.search(scannable_command):
319
- return True
320
- return False
321
-
322
-
323
- def main() -> None:
324
- try:
325
- hook_input = json.load(sys.stdin)
326
- except json.JSONDecodeError:
327
- sys.exit(0)
328
-
329
- tool_name = hook_input.get(TOOL_NAME_KEY, "")
330
- if tool_name != BASH_TOOL_NAME:
331
- sys.exit(0)
332
-
333
- command = hook_input.get(TOOL_INPUT_KEY, {}).get(COMMAND_KEY, "")
334
- if not command or not has_shell_substitution(command):
335
- sys.exit(0)
336
-
337
- deny_payload = {
338
- HOOK_SPECIFIC_OUTPUT_KEY: {
339
- HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
340
- PERMISSION_DECISION_KEY: DENY_DECISION,
341
- PERMISSION_DECISION_REASON_KEY: CORRECTIVE_MESSAGE,
342
- }
343
- }
344
- print(json.dumps(deny_payload))
345
- sys.stdout.flush()
346
- sys.exit(0)
347
-
348
-
349
- if __name__ == "__main__":
350
- main()
@@ -1,69 +0,0 @@
1
- """Production-path tests for the AskUserQuestion shape hook."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import subprocess
7
- import sys
8
- from pathlib import Path
9
-
10
- HOOK_PATH = Path(__file__).parent / "ask_user_question_shape_blocker.py"
11
-
12
-
13
- def _payload(question_text: str, option_description: str) -> dict[str, object]:
14
- return {
15
- "tool_name": "AskUserQuestion",
16
- "tool_input": {
17
- "questions": [
18
- {
19
- "question": question_text,
20
- "header": "Gate",
21
- "options": [{"label": "Run", "description": option_description}],
22
- }
23
- ]
24
- },
25
- }
26
-
27
-
28
- def _run_hook(payload_by_key: dict[str, object]) -> subprocess.CompletedProcess[str]:
29
- return subprocess.run(
30
- [sys.executable, str(HOOK_PATH)],
31
- input=json.dumps(payload_by_key),
32
- capture_output=True,
33
- text=True,
34
- check=False,
35
- )
36
-
37
-
38
- def test_lean_question_passes_through_the_runtime_hook() -> None:
39
- completed = _run_hook(_payload("Which gate should run first?", "Runs on write."))
40
-
41
- assert completed.returncode == 0
42
- assert completed.stdout == ""
43
-
44
-
45
- def test_question_with_list_detail_is_denied_by_the_runtime_hook() -> None:
46
- completed = _run_hook(
47
- _payload(
48
- "Which gate should run first?\n- Write\n- Commit",
49
- "Runs on write.",
50
- )
51
- )
52
-
53
- parsed_payload = json.loads(completed.stdout)
54
- hook_specific_output = parsed_payload["hookSpecificOutput"]
55
- assert hook_specific_output["permissionDecision"] == "deny"
56
- assert "list marker" in hook_specific_output["permissionDecisionReason"]
57
- assert "question" in parsed_payload["systemMessage"].lower()
58
-
59
-
60
- def test_non_question_tool_passes_through_the_runtime_hook() -> None:
61
- completed = _run_hook(
62
- {
63
- "tool_name": "Write",
64
- "tool_input": {"file_path": "notes.md", "content": "Text."},
65
- }
66
- )
67
-
68
- assert completed.returncode == 0
69
- assert completed.stdout == ""