claude-dev-env 7.0.0 → 7.1.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 +189 -0
- package/.agents/skills/session-continuity/SKILL.md +114 -0
- package/.agents/skills/session-continuity/continuity.mjs +375 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +303 -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 +78 -0
- package/bin/install-session-continuity.test.mjs +42 -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,298 +0,0 @@
|
|
|
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 == ""
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def test_allows_arithmetic_expansion_with_nested_parens() -> None:
|
|
128
|
-
payload = {
|
|
129
|
-
"tool_name": "Bash",
|
|
130
|
-
"tool_input": {"command": "echo $(( (1+2) * 3 ))"},
|
|
131
|
-
}
|
|
132
|
-
assert _run_hook(payload).stdout == ""
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def test_allows_arithmetic_expansion_whose_body_bash_itself_rejects() -> None:
|
|
136
|
-
payload = {
|
|
137
|
-
"tool_name": "Bash",
|
|
138
|
-
"tool_input": {"command": "echo $((echo hi))"},
|
|
139
|
-
}
|
|
140
|
-
assert _run_hook(payload).stdout == ""
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def test_allows_arithmetic_lookalike_inside_single_quotes() -> None:
|
|
144
|
-
payload = {
|
|
145
|
-
"tool_name": "Bash",
|
|
146
|
-
"tool_input": {"command": "echo '$((cd /tmp) && pwd)'"},
|
|
147
|
-
}
|
|
148
|
-
assert _run_hook(payload).stdout == ""
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def test_denies_subshell_disguised_as_arithmetic_expansion() -> None:
|
|
152
|
-
payload = {
|
|
153
|
-
"tool_name": "Bash",
|
|
154
|
-
"tool_input": {"command": "echo $((cd /tmp) && pwd)"},
|
|
155
|
-
}
|
|
156
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
157
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def test_denies_parenthesized_command_word_disguised_as_arithmetic() -> None:
|
|
161
|
-
payload = {
|
|
162
|
-
"tool_name": "Bash",
|
|
163
|
-
"tool_input": {"command": "echo $((1+2) )"},
|
|
164
|
-
}
|
|
165
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
166
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def test_denies_command_substitution_nested_inside_arithmetic_expansion() -> None:
|
|
170
|
-
payload = {
|
|
171
|
-
"tool_name": "Bash",
|
|
172
|
-
"tool_input": {"command": "echo $(( $(id -u) + 1 ))"},
|
|
173
|
-
}
|
|
174
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
175
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def test_denies_backtick_substitution_nested_inside_arithmetic_expansion() -> None:
|
|
179
|
-
payload = {
|
|
180
|
-
"tool_name": "Bash",
|
|
181
|
-
"tool_input": {"command": "echo $(( `id -u` + 1 ))"},
|
|
182
|
-
}
|
|
183
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
184
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def test_denies_nested_command_substitution_without_arithmetic() -> None:
|
|
188
|
-
payload = {
|
|
189
|
-
"tool_name": "Bash",
|
|
190
|
-
"tool_input": {"command": "echo $(echo $(whoami))"},
|
|
191
|
-
}
|
|
192
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
193
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def test_allows_backtick_inside_single_quoted_heredoc_body() -> None:
|
|
197
|
-
payload = {
|
|
198
|
-
"tool_name": "Bash",
|
|
199
|
-
"tool_input": {
|
|
200
|
-
"command": (
|
|
201
|
-
"cat > notes.md <<'EOF'\n"
|
|
202
|
-
"reStructuredText markup: ``inert literal``\n"
|
|
203
|
-
"EOF"
|
|
204
|
-
)
|
|
205
|
-
},
|
|
206
|
-
}
|
|
207
|
-
assert _run_hook(payload).stdout == ""
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def test_allows_backtick_inside_tab_stripped_quoted_heredoc_body() -> None:
|
|
211
|
-
"""A ``<<-`` opener closes on a tab-indented terminator, and stays inert.
|
|
212
|
-
|
|
213
|
-
The tab-strip rule decides which line ends the body. Getting it wrong
|
|
214
|
-
swallows the rest of the command or ends the body early, so the branch is
|
|
215
|
-
pinned here as well as in the shared pipeline that owns the rule.
|
|
216
|
-
"""
|
|
217
|
-
payload = {
|
|
218
|
-
"tool_name": "Bash",
|
|
219
|
-
"tool_input": {
|
|
220
|
-
"command": (
|
|
221
|
-
"cat > notes.md <<-'EOF'\n"
|
|
222
|
-
"reStructuredText markup: ``inert literal``\n"
|
|
223
|
-
"\tEOF\n"
|
|
224
|
-
"echo done"
|
|
225
|
-
)
|
|
226
|
-
},
|
|
227
|
-
}
|
|
228
|
-
assert _run_hook(payload).stdout == ""
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def test_denies_backtick_inside_bare_heredoc_body() -> None:
|
|
232
|
-
payload = {
|
|
233
|
-
"tool_name": "Bash",
|
|
234
|
-
"tool_input": {"command": "cat > notes.md <<EOF\nnow: `date`\nEOF"},
|
|
235
|
-
}
|
|
236
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
237
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def test_denies_command_substitution_inside_bare_heredoc_body() -> None:
|
|
241
|
-
payload = {
|
|
242
|
-
"tool_name": "Bash",
|
|
243
|
-
"tool_input": {
|
|
244
|
-
"command": "cat > notes.md <<EOF\nhead: $(git rev-parse HEAD)\nEOF"
|
|
245
|
-
},
|
|
246
|
-
}
|
|
247
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
248
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def test_denies_live_substitution_outside_a_quoted_heredoc() -> None:
|
|
252
|
-
payload = {
|
|
253
|
-
"tool_name": "Bash",
|
|
254
|
-
"tool_input": {
|
|
255
|
-
"command": (
|
|
256
|
-
"cat > notes.md <<'EOF'\n"
|
|
257
|
-
"inert ``literal``\n"
|
|
258
|
-
"EOF\n"
|
|
259
|
-
'echo "$(git rev-parse HEAD)"'
|
|
260
|
-
)
|
|
261
|
-
},
|
|
262
|
-
}
|
|
263
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
264
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def test_allows_backslash_escaped_heredoc_delimiter_body() -> None:
|
|
268
|
-
payload = {
|
|
269
|
-
"tool_name": "Bash",
|
|
270
|
-
"tool_input": {
|
|
271
|
-
"command": "cat > notes.md <<\\EOF\ninert `text`\nEOF"
|
|
272
|
-
},
|
|
273
|
-
}
|
|
274
|
-
assert _run_hook(payload).stdout == ""
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def test_denies_a_substitution_below_an_opener_inside_a_quoted_string() -> None:
|
|
278
|
-
"""An opener spelled inside a quoted string opens no heredoc.
|
|
279
|
-
|
|
280
|
-
Bash runs the second line here. Reading the first line as an opener and
|
|
281
|
-
dropping everything below it hides a live substitution from the scan.
|
|
282
|
-
"""
|
|
283
|
-
payload = {
|
|
284
|
-
"tool_name": "Bash",
|
|
285
|
-
"tool_input": {"command": 'echo "<<\'EOF\'"\necho $(whoami)'},
|
|
286
|
-
}
|
|
287
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
288
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def test_denies_that_bypass_even_when_a_terminator_line_follows() -> None:
|
|
292
|
-
"""A terminator line below the substitution does not make the strip safe."""
|
|
293
|
-
payload = {
|
|
294
|
-
"tool_name": "Bash",
|
|
295
|
-
"tool_input": {"command": 'echo "<<\'EOF\'"\necho $(whoami)\nEOF'},
|
|
296
|
-
}
|
|
297
|
-
response = json.loads(_run_hook(payload).stdout)
|
|
298
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
"""Tests for unscoped_search_blocker hook."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
import pytest
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
SCRIPT_PATH = Path(__file__).parent / "unscoped_search_blocker.py"
|
|
12
|
-
|
|
13
|
-
ALL_DENIED_COMMANDS = [
|
|
14
|
-
"find / -iname code_rules_gate.py",
|
|
15
|
-
"find /c -name '*.py'",
|
|
16
|
-
"find ~ -name README.md",
|
|
17
|
-
"find $HOME -type f",
|
|
18
|
-
"ls -R /",
|
|
19
|
-
"ls -laR /",
|
|
20
|
-
"ls -Rl /",
|
|
21
|
-
"find //server/share -name x",
|
|
22
|
-
"find \\\\server\\share -name x",
|
|
23
|
-
"bash -c 'find / -name x'",
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
ALL_ALLOWED_COMMANDS = [
|
|
27
|
-
"find . -iname '*.py'",
|
|
28
|
-
"find packages/claude-dev-env -name code_rules_gate.py",
|
|
29
|
-
"find /c/Users/example/repo -iname SKILL.md",
|
|
30
|
-
"ls -r /",
|
|
31
|
-
"ls -la /tmp/x",
|
|
32
|
-
"find //server/share/project/src -name x",
|
|
33
|
-
"es.exe path:C:\\dev\\repo ext:py gate",
|
|
34
|
-
"git status --short",
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
|
|
39
|
-
return subprocess.run(
|
|
40
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
41
|
-
input=json.dumps(payload),
|
|
42
|
-
text=True,
|
|
43
|
-
capture_output=True,
|
|
44
|
-
check=False,
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _bash_payload(command: str) -> dict:
|
|
49
|
-
return {"tool_name": "Bash", "tool_input": {"command": command}}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _powershell_payload(command: str) -> dict:
|
|
53
|
-
return {"tool_name": "PowerShell", "tool_input": {"command": command}}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@pytest.mark.parametrize("each_command", ALL_DENIED_COMMANDS)
|
|
57
|
-
def test_denies_unscoped_root_walk(each_command: str) -> None:
|
|
58
|
-
response = json.loads(_run_hook(_bash_payload(each_command)).stdout)
|
|
59
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
@pytest.mark.parametrize("each_command", ALL_ALLOWED_COMMANDS)
|
|
63
|
-
def test_allows_scoped_search(each_command: str) -> None:
|
|
64
|
-
assert _run_hook(_bash_payload(each_command)).stdout == ""
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_denies_recursive_listing_on_windows_drive_root() -> None:
|
|
68
|
-
response = json.loads(
|
|
69
|
-
_run_hook(_powershell_payload("Get-ChildItem -Path C:\\ -Recurse")).stdout
|
|
70
|
-
)
|
|
71
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def test_allows_recursive_listing_under_project_directory() -> None:
|
|
75
|
-
assert _run_hook(_powershell_payload("Get-ChildItem -Path .\\src -Recurse")).stdout == ""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def test_allows_recursive_listing_without_recurse_flag() -> None:
|
|
79
|
-
assert _run_hook(_powershell_payload("Get-ChildItem -Path C:\\")).stdout == ""
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_denies_find_root_in_a_chained_segment() -> None:
|
|
83
|
-
response = json.loads(
|
|
84
|
-
_run_hook(_bash_payload("cd /tmp && find / -name x.py")).stdout
|
|
85
|
-
)
|
|
86
|
-
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_ignores_unsupported_tool_name() -> None:
|
|
90
|
-
payload = {"tool_name": "Edit", "tool_input": {"command": "find / -name x"}}
|
|
91
|
-
assert _run_hook(payload).stdout == ""
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def test_ignores_payload_without_a_command() -> None:
|
|
95
|
-
payload = {"tool_name": "Bash", "tool_input": {}}
|
|
96
|
-
assert _run_hook(payload).stdout == ""
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def test_deny_message_names_the_scoped_alternative() -> None:
|
|
100
|
-
response = json.loads(_run_hook(_bash_payload("find / -name x.py")).stdout)
|
|
101
|
-
deny_reason = response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
102
|
-
assert "scope" in deny_reason.lower()
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
"""Tests for the write_existing_file_blocker hook.
|
|
2
|
-
|
|
3
|
-
Each case drives the real hook's ``main()`` through its production stdin path,
|
|
4
|
-
feeding it the PreToolUse JSON payload Claude Code sends and reading the
|
|
5
|
-
permission decision back off stdout.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import contextlib
|
|
11
|
-
import io
|
|
12
|
-
import json
|
|
13
|
-
from collections.abc import Mapping
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
import pytest
|
|
17
|
-
|
|
18
|
-
import write_existing_file_blocker
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _run_payload(
|
|
22
|
-
payload: Mapping[str, object],
|
|
23
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
24
|
-
capsys: pytest.CaptureFixture[str],
|
|
25
|
-
) -> str:
|
|
26
|
-
"""Run the real hook entry point and return its stdout."""
|
|
27
|
-
monkeypatch.setattr(write_existing_file_blocker.sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
28
|
-
with contextlib.suppress(SystemExit):
|
|
29
|
-
write_existing_file_blocker.main()
|
|
30
|
-
return capsys.readouterr().out
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _is_deny(stdout_text: str) -> bool:
|
|
34
|
-
if not stdout_text.strip():
|
|
35
|
-
return False
|
|
36
|
-
decision = json.loads(stdout_text)
|
|
37
|
-
return decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_write_denies_an_existing_target(
|
|
41
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
42
|
-
) -> None:
|
|
43
|
-
"""A Write onto a path that already exists is denied."""
|
|
44
|
-
existing_path = tmp_path / "already_here.py"
|
|
45
|
-
existing_path.write_text("x = 1\n", encoding="utf-8")
|
|
46
|
-
payload = {
|
|
47
|
-
"tool_name": "Write",
|
|
48
|
-
"tool_input": {"file_path": str(existing_path), "content": "x = 2\n"},
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
stdout = _run_payload(payload, monkeypatch, capsys)
|
|
52
|
-
|
|
53
|
-
assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_apply_patch_add_denies_an_existing_target(
|
|
57
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
58
|
-
) -> None:
|
|
59
|
-
"""An apply_patch 'Add File' section targeting an existing path is denied."""
|
|
60
|
-
existing_path = tmp_path / "already_here.py"
|
|
61
|
-
existing_path.write_text("x = 1\n", encoding="utf-8")
|
|
62
|
-
payload = {
|
|
63
|
-
"tool_name": "apply_patch",
|
|
64
|
-
"cwd": str(tmp_path),
|
|
65
|
-
"tool_input": {
|
|
66
|
-
"command": ("*** Begin Patch\n*** Add File: already_here.py\n+x = 2\n*** End Patch")
|
|
67
|
-
},
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
stdout = _run_payload(payload, monkeypatch, capsys)
|
|
71
|
-
|
|
72
|
-
assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_apply_patch_update_on_an_existing_target_is_allowed(
|
|
76
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
77
|
-
) -> None:
|
|
78
|
-
"""An 'Update File' section on an existing path is not an unread overwrite."""
|
|
79
|
-
existing_path = tmp_path / "already_here.py"
|
|
80
|
-
existing_path.write_text("x = 1\n", encoding="utf-8")
|
|
81
|
-
payload = {
|
|
82
|
-
"tool_name": "apply_patch",
|
|
83
|
-
"cwd": str(tmp_path),
|
|
84
|
-
"tool_input": {
|
|
85
|
-
"command": (
|
|
86
|
-
"*** Begin Patch\n"
|
|
87
|
-
"*** Update File: already_here.py\n"
|
|
88
|
-
"@@\n"
|
|
89
|
-
"-x = 1\n"
|
|
90
|
-
"+x = 2\n"
|
|
91
|
-
"*** End Patch"
|
|
92
|
-
)
|
|
93
|
-
},
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
stdout = _run_payload(payload, monkeypatch, capsys)
|
|
97
|
-
|
|
98
|
-
assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def test_multi_edit_on_an_existing_target_is_allowed(
|
|
102
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
103
|
-
) -> None:
|
|
104
|
-
"""A MultiEdit always targets a file already on disk, so it is never denied here.
|
|
105
|
-
|
|
106
|
-
Every MultiEdit edit must match an ``old_string`` already present in the
|
|
107
|
-
file, so MultiEdit carries no blind create-or-clobber path the way a Write
|
|
108
|
-
or an apply_patch 'Add File' does — this hook allows it unconditionally.
|
|
109
|
-
"""
|
|
110
|
-
existing_path = tmp_path / "already_here.py"
|
|
111
|
-
existing_path.write_text("x = 1\n", encoding="utf-8")
|
|
112
|
-
payload = {
|
|
113
|
-
"tool_name": "MultiEdit",
|
|
114
|
-
"tool_input": {
|
|
115
|
-
"file_path": str(existing_path),
|
|
116
|
-
"edits": [{"old_string": "x = 1", "new_string": "x = 2"}],
|
|
117
|
-
},
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
stdout = _run_payload(payload, monkeypatch, capsys)
|
|
121
|
-
|
|
122
|
-
assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"
|