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.
- package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
- package/.agents/skills/session-continuity/AGENTS.md +1 -0
- package/.agents/skills/session-continuity/CLAUDE.md +1 -0
- package/.agents/skills/session-continuity/README.md +213 -0
- package/.agents/skills/session-continuity/SKILL.md +125 -0
- package/.agents/skills/session-continuity/continuity.mjs +447 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +88 -0
- package/bin/install-session-continuity.test.mjs +55 -0
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +478 -63
- package/bin/install.policy-lint.test.mjs +56 -4
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +227 -17
- package/bin/install.test.mjs +500 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -90
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +39 -6
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +36 -392
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +0 -119
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +1 -1
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/git-workflow.md +12 -0
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/durable_post_lint.py +30 -2
- package/scripts/durable_post_lint_config/config/constants.py +10 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/test_durable_post_lint.py +97 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_verification_notice.py +347 -0
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- 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 == ""
|