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,365 +0,0 @@
|
|
|
1
|
-
"""Decision tests for the sensitive-file filename guard.
|
|
2
|
-
|
|
3
|
-
Each case drives the real hook script through its production ``__main__``
|
|
4
|
-
stdin path, feeding it the PreToolUse JSON payload Claude Code sends and
|
|
5
|
-
reading the permission decision back off stdout.
|
|
6
|
-
|
|
7
|
-
The guard denies a filename that names a live secret or a lock file, and
|
|
8
|
-
steps aside for a placeholders-only committed template (``.env.example``
|
|
9
|
-
and its ``.sample`` / ``.template`` spellings). The template exemption is
|
|
10
|
-
narrow: ``.env.local`` is a ``.env.*`` filename that is not a template, and
|
|
11
|
-
it stays denied.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import json
|
|
17
|
-
import shlex
|
|
18
|
-
import subprocess
|
|
19
|
-
import sys
|
|
20
|
-
from functools import cache
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
import pytest
|
|
24
|
-
|
|
25
|
-
BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
26
|
-
HOOKS_DIRECTORY = BLOCKING_DIRECTORY.parent
|
|
27
|
-
CLAUDE_DEV_ENV_DIRECTORY = BLOCKING_DIRECTORY.parent.parent
|
|
28
|
-
HOOK_SCRIPT_PATH = BLOCKING_DIRECTORY / "sensitive_file_protector.py"
|
|
29
|
-
DISPATCHER_SCRIPT_PATH = BLOCKING_DIRECTORY / "pre_tool_use_dispatcher.py"
|
|
30
|
-
HOOKS_JSON_PATH = HOOKS_DIRECTORY / "hooks.json"
|
|
31
|
-
DIRECT_HOOK_TIMEOUT_SECONDS = 10
|
|
32
|
-
|
|
33
|
-
READ_TOOL_NAME = "Read"
|
|
34
|
-
|
|
35
|
-
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
36
|
-
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
37
|
-
|
|
38
|
-
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
39
|
-
DENY_DECISION,
|
|
40
|
-
EDIT_TOOL_NAME,
|
|
41
|
-
MULTI_EDIT_TOOL_NAME,
|
|
42
|
-
WRITE_TOOL_NAME,
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
PLACEHOLDER_TEMPLATE_BODY = "API_TOKEN=your-token-here\nDATABASE_HOST=localhost\n"
|
|
46
|
-
ORDINARY_SOURCE_BODY = "def add(left: int, right: int) -> int:\n return left + right\n"
|
|
47
|
-
|
|
48
|
-
ALL_TEMPLATE_FILENAMES = (
|
|
49
|
-
".env.example",
|
|
50
|
-
".env.sample",
|
|
51
|
-
".env.template",
|
|
52
|
-
".ENV.Example",
|
|
53
|
-
"settings.json.template",
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
ALL_DENIED_FILENAMES = (
|
|
57
|
-
".env",
|
|
58
|
-
".env.local",
|
|
59
|
-
".env.production",
|
|
60
|
-
"credentials.json",
|
|
61
|
-
"secrets.json",
|
|
62
|
-
"id_rsa",
|
|
63
|
-
"id_ed25519",
|
|
64
|
-
"server.pem",
|
|
65
|
-
"signing.key",
|
|
66
|
-
"bundle.p12",
|
|
67
|
-
"bundle.pfx",
|
|
68
|
-
"package-lock.json",
|
|
69
|
-
"yarn.lock",
|
|
70
|
-
"Pipfile.lock",
|
|
71
|
-
"poetry.lock",
|
|
72
|
-
"pnpm-lock.yaml",
|
|
73
|
-
"composer.lock",
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
ALL_UPPERCASE_DENIED_FILENAMES = (".ENV", "ID_RSA", "CREDENTIALS.JSON", "SERVER.PEM")
|
|
77
|
-
|
|
78
|
-
ALL_ORDINARY_FILENAMES = ("main.py", "README.md", "settings.json")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@cache
|
|
82
|
-
def _registered_dispatcher_details(
|
|
83
|
-
hooks_json_path: Path = HOOKS_JSON_PATH,
|
|
84
|
-
) -> tuple[list[str], int]:
|
|
85
|
-
hooks_configuration = json.loads(hooks_json_path.read_text(encoding="utf-8"))
|
|
86
|
-
pre_tool_use_registrations = hooks_configuration["hooks"]["PreToolUse"]
|
|
87
|
-
dispatcher_registrations = []
|
|
88
|
-
for each_registration in pre_tool_use_registrations:
|
|
89
|
-
for each_hook in each_registration["hooks"]:
|
|
90
|
-
command_parts = shlex.split(each_hook["command"])
|
|
91
|
-
if Path(command_parts[-1]).name != "pre_tool_use_dispatcher.py":
|
|
92
|
-
continue
|
|
93
|
-
dispatcher_registrations.append(
|
|
94
|
-
(each_registration, each_hook, command_parts)
|
|
95
|
-
)
|
|
96
|
-
assert len(dispatcher_registrations) == 1, (
|
|
97
|
-
"hooks.json must contain exactly one pre_tool_use_dispatcher.py registration; "
|
|
98
|
-
f"found {len(dispatcher_registrations)}"
|
|
99
|
-
)
|
|
100
|
-
each_registration, each_hook, command_parts = dispatcher_registrations[0]
|
|
101
|
-
assert each_registration["matcher"] == "Write|Edit|MultiEdit|apply_patch", (
|
|
102
|
-
"dispatcher registration must cover Write, Edit, MultiEdit, and apply_patch; "
|
|
103
|
-
f"got matcher {each_registration['matcher']!r}"
|
|
104
|
-
)
|
|
105
|
-
assert command_parts[0] == "python3"
|
|
106
|
-
dispatcher_script_path = Path(
|
|
107
|
-
command_parts[-1].replace(
|
|
108
|
-
"${CLAUDE_PLUGIN_ROOT}", str(CLAUDE_DEV_ENV_DIRECTORY)
|
|
109
|
-
)
|
|
110
|
-
)
|
|
111
|
-
assert dispatcher_script_path.resolve() == DISPATCHER_SCRIPT_PATH.resolve()
|
|
112
|
-
registered_timeout_seconds = each_hook["timeout"]
|
|
113
|
-
assert isinstance(registered_timeout_seconds, int)
|
|
114
|
-
return (
|
|
115
|
-
[sys.executable, str(dispatcher_script_path)],
|
|
116
|
-
registered_timeout_seconds,
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def _build_file_tool_input(
|
|
121
|
-
tool_name: str, file_path: Path, replacement_content: str
|
|
122
|
-
) -> dict[str, str | list[dict[str, str]]]:
|
|
123
|
-
file_path_text = str(file_path)
|
|
124
|
-
if tool_name == EDIT_TOOL_NAME:
|
|
125
|
-
return {
|
|
126
|
-
"file_path": file_path_text,
|
|
127
|
-
"old_string": "API_TOKEN=old-token\n",
|
|
128
|
-
"new_string": replacement_content,
|
|
129
|
-
}
|
|
130
|
-
if tool_name == MULTI_EDIT_TOOL_NAME:
|
|
131
|
-
return {
|
|
132
|
-
"file_path": file_path_text,
|
|
133
|
-
"edits": [
|
|
134
|
-
{
|
|
135
|
-
"old_string": "API_TOKEN=old-token\n",
|
|
136
|
-
"new_string": replacement_content,
|
|
137
|
-
}
|
|
138
|
-
],
|
|
139
|
-
}
|
|
140
|
-
return {"file_path": file_path_text, "content": replacement_content}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _run_hook(
|
|
144
|
-
tool_name: str, file_path: Path, replacement_content: str
|
|
145
|
-
) -> subprocess.CompletedProcess:
|
|
146
|
-
payload = json.dumps(
|
|
147
|
-
{
|
|
148
|
-
"tool_name": tool_name,
|
|
149
|
-
"tool_input": _build_file_tool_input(
|
|
150
|
-
tool_name, file_path, replacement_content
|
|
151
|
-
),
|
|
152
|
-
}
|
|
153
|
-
)
|
|
154
|
-
return _run_hook_with_stdin(payload)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def _run_hook_with_stdin(stdin_text: str) -> subprocess.CompletedProcess:
|
|
158
|
-
return subprocess.run(
|
|
159
|
-
[sys.executable, str(HOOK_SCRIPT_PATH)],
|
|
160
|
-
input=stdin_text,
|
|
161
|
-
capture_output=True,
|
|
162
|
-
text=True,
|
|
163
|
-
check=False,
|
|
164
|
-
timeout=DIRECT_HOOK_TIMEOUT_SECONDS,
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def _run_dispatcher(
|
|
169
|
-
tool_name: str, tool_input: dict[str, str | list[dict[str, str]]]
|
|
170
|
-
) -> subprocess.CompletedProcess[str]:
|
|
171
|
-
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
172
|
-
dispatcher_command, dispatcher_timeout_seconds = _registered_dispatcher_details()
|
|
173
|
-
return subprocess.run(
|
|
174
|
-
dispatcher_command,
|
|
175
|
-
input=payload,
|
|
176
|
-
capture_output=True,
|
|
177
|
-
text=True,
|
|
178
|
-
check=False,
|
|
179
|
-
timeout=dispatcher_timeout_seconds,
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def _permission_decision(completed: subprocess.CompletedProcess) -> str | None:
|
|
184
|
-
if not completed.stdout.strip():
|
|
185
|
-
return None
|
|
186
|
-
parsed_decision = json.loads(completed.stdout)
|
|
187
|
-
return parsed_decision["hookSpecificOutput"]["permissionDecision"]
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _deny_reason(completed: subprocess.CompletedProcess) -> str:
|
|
191
|
-
parsed_decision = json.loads(completed.stdout)
|
|
192
|
-
return parsed_decision["hookSpecificOutput"]["permissionDecisionReason"]
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
@pytest.mark.parametrize("template_filename", ALL_TEMPLATE_FILENAMES)
|
|
196
|
-
def test_template_filename_is_allowed(tmp_path: Path, template_filename: str) -> None:
|
|
197
|
-
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / template_filename, PLACEHOLDER_TEMPLATE_BODY)
|
|
198
|
-
assert completed.returncode == 0, completed.stderr
|
|
199
|
-
assert _permission_decision(completed) is None, (
|
|
200
|
-
f"{template_filename} is a committed template and must not be denied by filename; "
|
|
201
|
-
f"got stdout {completed.stdout!r}"
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
@pytest.mark.parametrize("sensitive_filename", ALL_DENIED_FILENAMES)
|
|
206
|
-
def test_sensitive_filename_is_denied(tmp_path: Path, sensitive_filename: str) -> None:
|
|
207
|
-
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / sensitive_filename, PLACEHOLDER_TEMPLATE_BODY)
|
|
208
|
-
assert completed.returncode == 0, completed.stderr
|
|
209
|
-
assert _permission_decision(completed) == DENY_DECISION, (
|
|
210
|
-
f"{sensitive_filename} must stay denied; got stdout {completed.stdout!r}"
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
@pytest.mark.parametrize("uppercase_filename", ALL_UPPERCASE_DENIED_FILENAMES)
|
|
215
|
-
def test_uppercase_sensitive_filename_is_denied(tmp_path: Path, uppercase_filename: str) -> None:
|
|
216
|
-
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / uppercase_filename, PLACEHOLDER_TEMPLATE_BODY)
|
|
217
|
-
assert completed.returncode == 0, completed.stderr
|
|
218
|
-
assert _permission_decision(completed) == DENY_DECISION, (
|
|
219
|
-
f"{uppercase_filename} names a secret in an uppercase spelling and must stay denied like "
|
|
220
|
-
f"its lowercase form; got stdout {completed.stdout!r}"
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
@pytest.mark.parametrize("ordinary_filename", ALL_ORDINARY_FILENAMES)
|
|
225
|
-
def test_ordinary_source_file_is_allowed(tmp_path: Path, ordinary_filename: str) -> None:
|
|
226
|
-
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ordinary_filename, ORDINARY_SOURCE_BODY)
|
|
227
|
-
assert completed.returncode == 0, completed.stderr
|
|
228
|
-
assert _permission_decision(completed) is None
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def test_dot_env_local_deny_reason_names_the_file_and_the_pattern(tmp_path: Path) -> None:
|
|
232
|
-
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ".env.local", PLACEHOLDER_TEMPLATE_BODY)
|
|
233
|
-
assert _permission_decision(completed) == DENY_DECISION
|
|
234
|
-
deny_reason = _deny_reason(completed)
|
|
235
|
-
assert ".env.local" in deny_reason
|
|
236
|
-
assert ".env.*" in deny_reason
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def test_edit_to_a_template_is_allowed(tmp_path: Path) -> None:
|
|
240
|
-
completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env.example", PLACEHOLDER_TEMPLATE_BODY)
|
|
241
|
-
assert completed.returncode == 0, completed.stderr
|
|
242
|
-
assert _permission_decision(completed) is None
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def test_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
|
|
246
|
-
completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
|
|
247
|
-
assert completed.returncode == 0, completed.stderr
|
|
248
|
-
assert _permission_decision(completed) == DENY_DECISION
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def test_multi_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
|
|
252
|
-
completed = _run_hook(
|
|
253
|
-
MULTI_EDIT_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY
|
|
254
|
-
)
|
|
255
|
-
assert completed.returncode == 0, completed.stderr
|
|
256
|
-
assert _permission_decision(completed) == DENY_DECISION
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def test_apply_patch_add_targeting_a_secrets_file_is_denied(tmp_path: Path) -> None:
|
|
260
|
-
payload = json.dumps(
|
|
261
|
-
{
|
|
262
|
-
"tool_name": "apply_patch",
|
|
263
|
-
"cwd": str(tmp_path),
|
|
264
|
-
"tool_input": {
|
|
265
|
-
"command": (
|
|
266
|
-
"*** Begin Patch\n"
|
|
267
|
-
"*** Add File: .env\n"
|
|
268
|
-
"+API_TOKEN=your-token-here\n"
|
|
269
|
-
"*** End Patch"
|
|
270
|
-
)
|
|
271
|
-
},
|
|
272
|
-
}
|
|
273
|
-
)
|
|
274
|
-
completed = _run_hook_with_stdin(payload)
|
|
275
|
-
assert completed.returncode == 0, completed.stderr
|
|
276
|
-
assert _permission_decision(completed) == DENY_DECISION
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def test_apply_patch_add_targeting_an_ordinary_file_is_allowed(tmp_path: Path) -> None:
|
|
280
|
-
payload = json.dumps(
|
|
281
|
-
{
|
|
282
|
-
"tool_name": "apply_patch",
|
|
283
|
-
"cwd": str(tmp_path),
|
|
284
|
-
"tool_input": {
|
|
285
|
-
"command": (
|
|
286
|
-
"*** Begin Patch\n"
|
|
287
|
-
"*** Add File: main.py\n"
|
|
288
|
-
"+x = 1\n"
|
|
289
|
-
"*** End Patch"
|
|
290
|
-
)
|
|
291
|
-
},
|
|
292
|
-
}
|
|
293
|
-
)
|
|
294
|
-
completed = _run_hook_with_stdin(payload)
|
|
295
|
-
assert completed.returncode == 0, completed.stderr
|
|
296
|
-
assert _permission_decision(completed) is None
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def test_hooks_json_registers_exact_dispatcher_matcher_and_timeout() -> None:
|
|
300
|
-
dispatcher_command, dispatcher_timeout_seconds = _registered_dispatcher_details()
|
|
301
|
-
assert dispatcher_command[1] == str(DISPATCHER_SCRIPT_PATH)
|
|
302
|
-
assert dispatcher_timeout_seconds == 60
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
@pytest.mark.parametrize(
|
|
306
|
-
("tool_name", "target_filename", "expected_permission_decision"),
|
|
307
|
-
[
|
|
308
|
-
pytest.param(WRITE_TOOL_NAME, ".env", DENY_DECISION, id="write-secret-denied"),
|
|
309
|
-
pytest.param(EDIT_TOOL_NAME, ".env", DENY_DECISION, id="edit-secret-denied"),
|
|
310
|
-
pytest.param(
|
|
311
|
-
MULTI_EDIT_TOOL_NAME, ".env", DENY_DECISION, id="multi-edit-secret-denied"
|
|
312
|
-
),
|
|
313
|
-
pytest.param(WRITE_TOOL_NAME, ".env.example", None, id="write-template-allowed"),
|
|
314
|
-
pytest.param(EDIT_TOOL_NAME, ".env.example", None, id="edit-template-allowed"),
|
|
315
|
-
],
|
|
316
|
-
)
|
|
317
|
-
def test_dispatcher_applies_sensitive_filename_contract(
|
|
318
|
-
tmp_path: Path,
|
|
319
|
-
tool_name: str,
|
|
320
|
-
target_filename: str,
|
|
321
|
-
expected_permission_decision: str | None,
|
|
322
|
-
) -> None:
|
|
323
|
-
completed = _run_dispatcher(
|
|
324
|
-
tool_name,
|
|
325
|
-
_build_file_tool_input(
|
|
326
|
-
tool_name, tmp_path / target_filename, PLACEHOLDER_TEMPLATE_BODY
|
|
327
|
-
),
|
|
328
|
-
)
|
|
329
|
-
assert completed.returncode == 0, completed.stderr
|
|
330
|
-
assert _permission_decision(completed) == expected_permission_decision, (
|
|
331
|
-
f"{tool_name} to {target_filename} expected "
|
|
332
|
-
f"{expected_permission_decision!r}, got stdout {completed.stdout!r}"
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def test_read_tool_is_not_evaluated(tmp_path: Path) -> None:
|
|
337
|
-
completed = _run_hook(READ_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
|
|
338
|
-
assert completed.returncode == 0, completed.stderr
|
|
339
|
-
assert _permission_decision(completed) is None
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def test_payload_without_a_file_path_is_allowed() -> None:
|
|
343
|
-
completed = _run_hook_with_stdin(json.dumps({"tool_name": WRITE_TOOL_NAME, "tool_input": {}}))
|
|
344
|
-
assert completed.returncode == 0, completed.stderr
|
|
345
|
-
assert _permission_decision(completed) is None
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
def test_malformed_payload_fails_open() -> None:
|
|
349
|
-
completed = _run_hook_with_stdin("{not valid json")
|
|
350
|
-
assert completed.returncode == 0, completed.stderr
|
|
351
|
-
assert completed.stdout.strip() == ""
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
@pytest.mark.parametrize("trailing_suffix_filename", (".env.example.bak", ".env.template.old", ".env.sample.orig"))
|
|
355
|
-
def test_template_suffix_must_be_last_to_earn_the_exemption(
|
|
356
|
-
tmp_path: Path, trailing_suffix_filename: str
|
|
357
|
-
) -> None:
|
|
358
|
-
completed = _run_hook(
|
|
359
|
-
WRITE_TOOL_NAME, tmp_path / trailing_suffix_filename, PLACEHOLDER_TEMPLATE_BODY
|
|
360
|
-
)
|
|
361
|
-
assert completed.returncode == 0, completed.stderr
|
|
362
|
-
assert _permission_decision(completed) == DENY_DECISION, (
|
|
363
|
-
f"{trailing_suffix_filename} is a copy of a live secrets file, not a template; "
|
|
364
|
-
f"only a trailing template suffix earns the exemption, got {completed.stdout!r}"
|
|
365
|
-
)
|