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.
Files changed (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +189 -0
  6. package/.agents/skills/session-continuity/SKILL.md +114 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +375 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +78 -0
  20. package/bin/install-session-continuity.test.mjs +42 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -1,211 +0,0 @@
1
- """Behavior tests for the Codex Luna fast-mode spawn gate."""
2
-
3
- from __future__ import annotations
4
-
5
- import importlib.util
6
- import io
7
- import json
8
- import pathlib
9
- import subprocess
10
- import sys
11
- from typing import Any
12
- from unittest import mock
13
-
14
- _HOOK_DIR = pathlib.Path(__file__).parent
15
- _HOOKS_TREE = _HOOK_DIR.parent
16
- for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
17
- if each_path not in sys.path:
18
- sys.path.insert(0, each_path)
19
-
20
- hook_spec = importlib.util.spec_from_file_location(
21
- "luna_fast_mode_gate",
22
- _HOOK_DIR / "luna_fast_mode_gate.py",
23
- )
24
- assert hook_spec is not None
25
- assert hook_spec.loader is not None
26
- hook_module = importlib.util.module_from_spec(hook_spec)
27
- hook_spec.loader.exec_module(hook_module)
28
-
29
- from hooks_constants.luna_fast_mode_gate_constants import ( # noqa: E402
30
- AGENT_TOOL_NAME,
31
- CALLING_HOOK_NAME,
32
- CODEX_AGENT_TOOL_NAME,
33
- FAST_SERVICE_TIER,
34
- TASK_TOOL_NAME,
35
- )
36
-
37
- _MODEL_ID = "gpt-5.6-luna"
38
- _NON_LUNA_MODEL_ID = "gpt-5.6-sol"
39
- _UNRELATED_TOOL_NAME = "Write"
40
- _SERVICE_TIER_FIELD_NAME = "service_tier"
41
- _MODEL_FIELD_NAME = "model"
42
- _DENY_DECISION = "deny"
43
-
44
-
45
- def _spawn_payload(
46
- *,
47
- tool_name: str = AGENT_TOOL_NAME,
48
- model: object = _MODEL_ID,
49
- service_tier: object = None,
50
- include_service_tier: bool = True,
51
- ) -> dict[str, Any]:
52
- """Build one PreToolUse payload for a spawn surface."""
53
- tool_input: dict[str, object] = {
54
- _MODEL_FIELD_NAME: model,
55
- }
56
- if include_service_tier:
57
- tool_input[_SERVICE_TIER_FIELD_NAME] = service_tier
58
- return {"tool_name": tool_name, "tool_input": tool_input}
59
-
60
-
61
- def _run_main(payload: object) -> str:
62
- """Run the production hook and return its stdout."""
63
- with mock.patch("sys.stdin", io.StringIO(json.dumps(payload))):
64
- with mock.patch("sys.stdout", new_callable=io.StringIO) as captured_stdout:
65
- try:
66
- hook_module.main()
67
- except SystemExit:
68
- pass
69
- return captured_stdout.getvalue()
70
-
71
-
72
- def _decision(payload: object) -> str:
73
- """Return the hook decision for a payload."""
74
- return json.loads(_run_main(payload))["hookSpecificOutput"]["permissionDecision"]
75
-
76
-
77
- def test_blocks_luna_without_fast_for_headless_and_in_session_surfaces() -> None:
78
- all_tool_names = (AGENT_TOOL_NAME, TASK_TOOL_NAME, CODEX_AGENT_TOOL_NAME)
79
- for each_tool_name in all_tool_names:
80
- payload = _spawn_payload(tool_name=each_tool_name, service_tier="flex")
81
- assert _decision(payload) == _DENY_DECISION
82
-
83
-
84
- def test_allows_luna_with_exact_fast_service_tier() -> None:
85
- for each_tool_name in (AGENT_TOOL_NAME, TASK_TOOL_NAME, CODEX_AGENT_TOOL_NAME):
86
- payload = _spawn_payload(
87
- tool_name=each_tool_name,
88
- service_tier=FAST_SERVICE_TIER,
89
- )
90
- assert _run_main(payload) == ""
91
-
92
-
93
- def test_allows_luna_with_native_priority_service_tier() -> None:
94
- payload = _spawn_payload(
95
- tool_name=CODEX_AGENT_TOOL_NAME,
96
- service_tier="priority",
97
- )
98
- assert _run_main(payload) == ""
99
-
100
-
101
- def test_blocks_luna_with_priority_for_agent_and_task_surfaces() -> None:
102
- for each_tool_name in (AGENT_TOOL_NAME, TASK_TOOL_NAME):
103
- payload = _spawn_payload(
104
- tool_name=each_tool_name,
105
- service_tier="priority",
106
- )
107
- assert _decision(payload) == _DENY_DECISION
108
-
109
-
110
- def test_blocks_missing_and_non_string_service_tier() -> None:
111
- assert _decision(_spawn_payload(include_service_tier=False)) == _DENY_DECISION
112
- assert _decision(_spawn_payload(service_tier=None)) == _DENY_DECISION
113
- assert _decision(_spawn_payload(service_tier=True)) == _DENY_DECISION
114
-
115
-
116
- def test_blocks_default_and_wrong_case_fast_service_tiers() -> None:
117
- assert _decision(_spawn_payload(service_tier="default")) == _DENY_DECISION
118
- assert _decision(_spawn_payload(service_tier="FAST")) == _DENY_DECISION
119
-
120
-
121
- def test_allows_non_luna_and_unrelated_tool_payloads() -> None:
122
- assert _run_main(_spawn_payload(model=_NON_LUNA_MODEL_ID)) == ""
123
- assert _run_main(
124
- _spawn_payload(tool_name=_UNRELATED_TOOL_NAME, service_tier="flex")
125
- ) == ""
126
-
127
-
128
- def test_recognizes_case_insensitive_luna_model_segments() -> None:
129
- assert _decision(_spawn_payload(model="GPT-5.6-LUNA", service_tier="flex")) == (
130
- _DENY_DECISION
131
- )
132
- assert _run_main(_spawn_payload(model="gpt-5.6-lunacy", service_tier="flex")) == ""
133
-
134
-
135
- def test_malformed_outer_payload_allows() -> None:
136
- assert _run_main({"tool_name": AGENT_TOOL_NAME}) == ""
137
- assert _run_main({"tool_name": AGENT_TOOL_NAME, "tool_input": []}) == ""
138
- assert _run_main("not an object") == ""
139
- assert _run_main({"tool_name": AGENT_TOOL_NAME, "tool_input": {_MODEL_FIELD_NAME: 7}}) == ""
140
-
141
-
142
- def test_deny_payload_and_log_do_not_include_a_prompt() -> None:
143
- payload = _spawn_payload(service_tier="default")
144
- with mock.patch.object(hook_module, "log_hook_block") as recorded_block:
145
- output = _run_main(payload)
146
- parsed_output = json.loads(output)
147
- assert parsed_output["hookSpecificOutput"]["permissionDecision"] == _DENY_DECISION
148
- assert recorded_block.call_args.kwargs["calling_hook_name"] == CALLING_HOOK_NAME
149
- assert "prompt" not in recorded_block.call_args.kwargs["offending_input_preview"]
150
-
151
-
152
- def test_hooks_json_registers_all_spawn_surfaces() -> None:
153
- hooks_configuration = json.loads(
154
- (_HOOKS_TREE / "hooks.json").read_text(encoding="utf-8")
155
- )
156
- matching_groups = [
157
- each_group
158
- for each_group in hooks_configuration["hooks"]["PreToolUse"]
159
- if CALLING_HOOK_NAME
160
- in " ".join(each_hook["command"] for each_hook in each_group["hooks"])
161
- ]
162
- assert len(matching_groups) == 1
163
- assert matching_groups[0]["matcher"] == (
164
- f"{AGENT_TOOL_NAME}|{TASK_TOOL_NAME}|{CODEX_AGENT_TOOL_NAME}"
165
- )
166
- registered_command = matching_groups[0]["hooks"][0]["command"]
167
- assert registered_command.endswith(
168
- f"/hooks/blocking/{CALLING_HOOK_NAME}"
169
- )
170
- assert (_HOOKS_TREE / "blocking" / CALLING_HOOK_NAME).is_file()
171
-
172
-
173
- def test_registered_command_runs_under_windows_cmd_for_allow_and_deny() -> None:
174
- hooks_configuration = json.loads(
175
- (_HOOKS_TREE / "hooks.json").read_text(encoding="utf-8")
176
- )
177
- matching_group = next(
178
- each_group
179
- for each_group in hooks_configuration["hooks"]["PreToolUse"]
180
- if CALLING_HOOK_NAME
181
- in " ".join(each_hook["command"] for each_hook in each_group["hooks"])
182
- )
183
- registered_command = matching_group["hooks"][0]["command"]
184
- assert registered_command.startswith("python ")
185
- command_line = registered_command.replace(
186
- "${CLAUDE_PLUGIN_ROOT}",
187
- str(_HOOKS_TREE.parent).replace("\\", "/"),
188
- )
189
-
190
- for each_service_tier, expected_output in (
191
- (FAST_SERVICE_TIER, ""),
192
- ("flex", _DENY_DECISION),
193
- ):
194
- completed_process = subprocess.run(
195
- command_line,
196
- input=json.dumps(_spawn_payload(service_tier=each_service_tier)),
197
- capture_output=True,
198
- shell=True,
199
- text=True,
200
- check=False,
201
- )
202
- assert completed_process.returncode == 0, completed_process.stderr
203
- if expected_output == "":
204
- assert completed_process.stdout == ""
205
- else:
206
- assert (
207
- json.loads(completed_process.stdout)["hookSpecificOutput"][
208
- "permissionDecision"
209
- ]
210
- == expected_output
211
- )
@@ -1,225 +0,0 @@
1
- """Unit tests for nas-ssh-binary-enforcer PreToolUse hook.
2
-
3
- The real NAS host, ssh user, and port are private, so these tests set them via
4
- the ``CLAUDE_NAS_*`` environment variables and drive the real hook, asserting
5
- the runtime-built host pattern and deny messages.
6
- """
7
-
8
- import importlib.util
9
- import os
10
- import pathlib
11
- import sys
12
- from collections.abc import Iterator
13
- from unittest.mock import patch
14
-
15
- import pytest
16
-
17
- _HOOK_DIR = pathlib.Path(__file__).parent
18
- if str(_HOOK_DIR) not in sys.path:
19
- sys.path.insert(0, str(_HOOK_DIR))
20
- if str(_HOOK_DIR.parent) not in sys.path:
21
- sys.path.insert(0, str(_HOOK_DIR.parent))
22
-
23
- hook_spec = importlib.util.spec_from_file_location(
24
- "nas_ssh_binary_enforcer",
25
- _HOOK_DIR / "nas_ssh_binary_enforcer.py",
26
- )
27
- assert hook_spec is not None
28
- assert hook_spec.loader is not None
29
- hook_module = importlib.util.module_from_spec(hook_spec)
30
- hook_spec.loader.exec_module(hook_module)
31
- _find_nas_ssh_violation = hook_module._find_nas_ssh_violation
32
-
33
- from hooks_constants.local_identity import ( # noqa: E402
34
- bare_ssh_binary_deny_message,
35
- missing_batch_mode_deny_message,
36
- )
37
-
38
- NAS_HOST = "test-nas.example.net"
39
- NAS_SSH_USER = "tester"
40
- NAS_SSH_PORT = "2222"
41
-
42
-
43
- @pytest.fixture(autouse=True)
44
- def _set_nas_identity_env() -> Iterator[None]:
45
- with patch.dict(
46
- os.environ,
47
- {
48
- "CLAUDE_NAS_HOST": NAS_HOST,
49
- "CLAUDE_NAS_SSH_USER": NAS_SSH_USER,
50
- "CLAUDE_NAS_SSH_PORT": NAS_SSH_PORT,
51
- },
52
- clear=False,
53
- ):
54
- yield
55
-
56
-
57
- def test_bare_ssh_to_nas_is_denied_with_binary_message() -> None:
58
- assert (
59
- _find_nas_ssh_violation(
60
- f'ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "ls /volume1"'
61
- )
62
- == bare_ssh_binary_deny_message()
63
- )
64
-
65
-
66
- def test_bare_scp_to_nas_is_denied() -> None:
67
- assert (
68
- _find_nas_ssh_violation(
69
- f"scp -P {NAS_SSH_PORT} file.txt {NAS_SSH_USER}@{NAS_HOST}:/volume1/"
70
- )
71
- == bare_ssh_binary_deny_message()
72
- )
73
-
74
-
75
- def test_bare_sftp_to_nas_is_denied() -> None:
76
- assert (
77
- _find_nas_ssh_violation(f"sftp -P {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST}")
78
- == bare_ssh_binary_deny_message()
79
- )
80
-
81
-
82
- def test_bare_ssh_to_nas_behind_launcher_wrapper_is_denied() -> None:
83
- assert (
84
- _find_nas_ssh_violation(
85
- f'timeout 10 ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
86
- )
87
- == bare_ssh_binary_deny_message()
88
- )
89
-
90
-
91
- def test_bare_ssh_to_nas_after_shell_operator_is_denied() -> None:
92
- assert (
93
- _find_nas_ssh_violation(
94
- f'cd /tmp && ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
95
- )
96
- == bare_ssh_binary_deny_message()
97
- )
98
-
99
-
100
- def test_bare_ssh_to_nas_after_glued_operator_is_denied() -> None:
101
- assert (
102
- _find_nas_ssh_violation(
103
- f'true;ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
104
- )
105
- == bare_ssh_binary_deny_message()
106
- )
107
-
108
-
109
- def test_bare_ssh_to_nas_after_pipe_ampersand_operator_is_denied() -> None:
110
- assert (
111
- _find_nas_ssh_violation(
112
- f'echo start |& ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "echo hi"'
113
- )
114
- == bare_ssh_binary_deny_message()
115
- )
116
-
117
-
118
- def test_full_openssh_binary_to_nas_without_batchmode_is_denied() -> None:
119
- assert (
120
- _find_nas_ssh_violation(
121
- '"/c/Windows/System32/OpenSSH/ssh.exe" -o ConnectTimeout=10 '
122
- f'-p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
123
- )
124
- == missing_batch_mode_deny_message()
125
- )
126
-
127
-
128
- def test_full_openssh_scp_to_nas_without_batchmode_is_denied() -> None:
129
- assert (
130
- _find_nas_ssh_violation(
131
- '"/c/Windows/System32/OpenSSH/scp.exe" '
132
- f"-P {NAS_SSH_PORT} file.txt {NAS_SSH_USER}@{NAS_HOST}:/volume1/"
133
- )
134
- == missing_batch_mode_deny_message()
135
- )
136
-
137
-
138
- def test_full_openssh_binary_to_nas_with_batchmode_is_allowed() -> None:
139
- assert (
140
- _find_nas_ssh_violation(
141
- '"/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes '
142
- f'-o ConnectTimeout=10 -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
143
- )
144
- is None
145
- )
146
-
147
-
148
- def test_full_openssh_binary_glued_batchmode_flag_is_allowed() -> None:
149
- assert (
150
- _find_nas_ssh_violation(
151
- '"/c/Windows/System32/OpenSSH/ssh.exe" -oBatchMode=yes '
152
- f'-p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
153
- )
154
- is None
155
- )
156
-
157
-
158
- def test_bare_ssh_to_other_host_is_allowed() -> None:
159
- assert _find_nas_ssh_violation(f'ssh -p 22 {NAS_SSH_USER}@example.com "uptime"') is None
160
-
161
-
162
- def test_full_openssh_binary_to_other_host_without_batchmode_is_allowed() -> None:
163
- assert (
164
- _find_nas_ssh_violation(
165
- f'"/c/Windows/System32/OpenSSH/ssh.exe" -p 22 {NAS_SSH_USER}@example.com "uptime"'
166
- )
167
- is None
168
- )
169
-
170
-
171
- def test_nas_host_as_echoed_data_is_allowed() -> None:
172
- assert _find_nas_ssh_violation(f'echo "connect via ssh to {NAS_HOST}"') is None
173
-
174
-
175
- def test_nas_host_mentioned_without_ssh_command_word_is_allowed() -> None:
176
- assert _find_nas_ssh_violation(f"ping -c 1 {NAS_HOST}") is None
177
-
178
-
179
- def test_ssh_family_word_only_in_remote_command_argument_is_allowed() -> None:
180
- assert (
181
- _find_nas_ssh_violation(
182
- '"/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes '
183
- f'-p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "grep ssh /etc/config"'
184
- )
185
- is None
186
- )
187
-
188
-
189
- def test_nas_host_in_different_segment_than_ssh_is_allowed() -> None:
190
- assert (
191
- _find_nas_ssh_violation(
192
- f'echo {NAS_HOST} && ssh -p 22 {NAS_SSH_USER}@example.com "id"'
193
- )
194
- is None
195
- )
196
-
197
-
198
- def test_ssh_to_other_host_with_nas_host_in_remote_command_is_allowed() -> None:
199
- assert (
200
- _find_nas_ssh_violation(
201
- f'ssh -p 22 {NAS_SSH_USER}@example.com "ping -c1 {NAS_HOST}"'
202
- )
203
- is None
204
- )
205
-
206
-
207
- def test_scp_to_other_host_with_nas_host_in_remote_path_is_allowed() -> None:
208
- assert (
209
- _find_nas_ssh_violation(
210
- f"scp -P 22 f.txt {NAS_SSH_USER}@example.com:/backup/{NAS_HOST}/"
211
- )
212
- is None
213
- )
214
-
215
-
216
- def test_similar_host_suffix_is_not_matched() -> None:
217
- assert _find_nas_ssh_violation(f'ssh -p 22 {NAS_SSH_USER}@{NAS_HOST}x "id"') is None
218
-
219
-
220
- def test_empty_command_is_allowed() -> None:
221
- assert _find_nas_ssh_violation("") is None
222
-
223
-
224
- def test_unbalanced_quotes_command_is_allowed() -> None:
225
- assert _find_nas_ssh_violation(f"ssh 'unterminated {NAS_HOST}") is None
@@ -1,231 +0,0 @@
1
- """Tests for orchestrator_refresh_reschedule_gate PreToolUse hook."""
2
-
3
- from __future__ import annotations
4
-
5
- import importlib.util
6
- import io
7
- import json
8
- import sys
9
- from pathlib import Path
10
- from types import ModuleType
11
-
12
- import pytest
13
-
14
- BLOCKING_DIRECTORY = Path(__file__).resolve().parent
15
- HOOKS_DIRECTORY = BLOCKING_DIRECTORY.parent
16
- if str(HOOKS_DIRECTORY) not in sys.path:
17
- sys.path.insert(0, str(HOOKS_DIRECTORY))
18
-
19
- SKILL_SCRIPTS = HOOKS_DIRECTORY.parent / ".agents" / "skills" / "orchestrator" / "scripts"
20
- if str(SKILL_SCRIPTS) not in sys.path:
21
- sys.path.insert(0, str(SKILL_SCRIPTS))
22
-
23
- from status_gate_constants.config.constants import RUN_STATUS_ACTIVE, RUN_STATUS_DONE # noqa: E402
24
- from status_gate import write_status_file # noqa: E402
25
-
26
-
27
- def load_gate_module() -> ModuleType:
28
- module_path = BLOCKING_DIRECTORY / "orchestrator_refresh_reschedule_gate.py"
29
- spec = importlib.util.spec_from_file_location(
30
- "orchestrator_refresh_reschedule_gate",
31
- module_path,
32
- )
33
- assert spec is not None
34
- assert spec.loader is not None
35
- gate_module = importlib.util.module_from_spec(spec)
36
- sys.modules[spec.name] = gate_module
37
- spec.loader.exec_module(gate_module)
38
- return gate_module
39
-
40
-
41
- def run_main_with_payload(
42
- gate_module: ModuleType,
43
- payload: dict[str, object],
44
- monkeypatch: pytest.MonkeyPatch,
45
- ) -> tuple[int, str]:
46
- stdin_buffer = io.StringIO(json.dumps(payload))
47
- stdout_buffer = io.StringIO()
48
- monkeypatch.setattr(sys, "stdin", stdin_buffer)
49
- monkeypatch.setattr(sys, "stdout", stdout_buffer)
50
- exit_code = 0
51
- try:
52
- gate_module.main()
53
- except SystemExit as system_exit:
54
- exit_code = int(system_exit.code or 0)
55
- return exit_code, stdout_buffer.getvalue()
56
-
57
-
58
- class TestOrchestratorRefreshRescheduleGate:
59
- def should_allow_when_status_is_active(
60
- self,
61
- temporary_directory: Path,
62
- monkeypatch: pytest.MonkeyPatch,
63
- ) -> None:
64
- status_file_path = temporary_directory / "status.json"
65
- write_status_file(status_file_path, RUN_STATUS_ACTIVE, "", is_rearm_pending=False)
66
- monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(status_file_path))
67
- gate_module = load_gate_module()
68
- payload = {
69
- "tool_name": "ScheduleWakeup",
70
- "tool_input": {"prompt": "/orchestrator-refresh", "delaySeconds": 1200},
71
- "cwd": str(temporary_directory),
72
- }
73
- exit_code, stdout_text = run_main_with_payload(
74
- gate_module, payload, monkeypatch
75
- )
76
- assert exit_code == 0
77
- assert stdout_text == ""
78
-
79
- def should_deny_when_status_is_done(
80
- self,
81
- temporary_directory: Path,
82
- monkeypatch: pytest.MonkeyPatch,
83
- ) -> None:
84
- status_file_path = temporary_directory / "status.json"
85
- write_status_file(status_file_path, RUN_STATUS_DONE, "", is_rearm_pending=False)
86
- monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(status_file_path))
87
- gate_module = load_gate_module()
88
- payload = {
89
- "tool_name": "ScheduleWakeup",
90
- "tool_input": {"prompt": "/orchestrator-refresh re-arm"},
91
- "cwd": str(temporary_directory),
92
- }
93
- exit_code, stdout_text = run_main_with_payload(
94
- gate_module, payload, monkeypatch
95
- )
96
- assert exit_code == 0
97
- denial = json.loads(stdout_text)
98
- assert denial["hookSpecificOutput"]["permissionDecision"] == "deny"
99
-
100
- def should_deny_when_status_file_missing(
101
- self,
102
- temporary_directory: Path,
103
- monkeypatch: pytest.MonkeyPatch,
104
- ) -> None:
105
- missing_path = temporary_directory / "nope.json"
106
- monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(missing_path))
107
- gate_module = load_gate_module()
108
- payload = {
109
- "tool_name": "ScheduleWakeup",
110
- "tool_input": {"prompt": "/orchestrator-refresh now"},
111
- "cwd": str(temporary_directory),
112
- }
113
- _exit_code, stdout_text = run_main_with_payload(
114
- gate_module, payload, monkeypatch
115
- )
116
- denial = json.loads(stdout_text)
117
- assert denial["hookSpecificOutput"]["permissionDecision"] == "deny"
118
-
119
- def should_deny_when_rearm_slot_already_pending(
120
- self,
121
- temporary_directory: Path,
122
- monkeypatch: pytest.MonkeyPatch,
123
- ) -> None:
124
- status_file_path = temporary_directory / "status.json"
125
- write_status_file(
126
- status_file_path, RUN_STATUS_ACTIVE, "", is_rearm_pending=True
127
- )
128
- monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(status_file_path))
129
- gate_module = load_gate_module()
130
- payload = {
131
- "tool_name": "ScheduleWakeup",
132
- "tool_input": {"prompt": "/orchestrator-refresh", "delaySeconds": 1200},
133
- "cwd": str(temporary_directory),
134
- }
135
- _exit_code, stdout_text = run_main_with_payload(
136
- gate_module, payload, monkeypatch
137
- )
138
- denial = json.loads(stdout_text)
139
- assert denial["hookSpecificOutput"]["permissionDecision"] == "deny"
140
- assert "rearm_already_pending" in denial["hookSpecificOutput"][
141
- "permissionDecisionReason"
142
- ]
143
-
144
- def should_always_deny_cron_create_for_orchestrator_refresh(
145
- self,
146
- temporary_directory: Path,
147
- monkeypatch: pytest.MonkeyPatch,
148
- ) -> None:
149
- status_file_path = temporary_directory / "status.json"
150
- write_status_file(status_file_path, RUN_STATUS_ACTIVE, "", is_rearm_pending=False)
151
- monkeypatch.setenv("ORCHESTRATOR_RUN_STATUS_FILE", str(status_file_path))
152
- gate_module = load_gate_module()
153
- payload = {
154
- "tool_name": "CronCreate",
155
- "tool_input": {"prompt": "/orchestrator-refresh"},
156
- "cwd": str(temporary_directory),
157
- }
158
- _exit_code, stdout_text = run_main_with_payload(
159
- gate_module, payload, monkeypatch
160
- )
161
- denial = json.loads(stdout_text)
162
- assert denial["hookSpecificOutput"]["permissionDecision"] == "deny"
163
- assert "cron_create_forbidden" in denial["hookSpecificOutput"][
164
- "permissionDecisionReason"
165
- ]
166
-
167
- def should_ignore_unrelated_schedule_prompts(
168
- self,
169
- temporary_directory: Path,
170
- monkeypatch: pytest.MonkeyPatch,
171
- ) -> None:
172
- gate_module = load_gate_module()
173
- payload = {
174
- "tool_name": "ScheduleWakeup",
175
- "tool_input": {"prompt": "/pr-converge"},
176
- "cwd": str(temporary_directory),
177
- }
178
- exit_code, stdout_text = run_main_with_payload(
179
- gate_module, payload, monkeypatch
180
- )
181
- assert exit_code == 0
182
- assert stdout_text == ""
183
-
184
- def should_ignore_prose_mention_of_orchestrator_refresh(
185
- self,
186
- temporary_directory: Path,
187
- monkeypatch: pytest.MonkeyPatch,
188
- ) -> None:
189
- gate_module = load_gate_module()
190
- payload = {
191
- "tool_name": "ScheduleWakeup",
192
- "tool_input": {
193
- "prompt": "See docs about /orchestrator-refresh for later"
194
- },
195
- "cwd": str(temporary_directory),
196
- }
197
- exit_code, stdout_text = run_main_with_payload(
198
- gate_module, payload, monkeypatch
199
- )
200
- assert exit_code == 0
201
- assert stdout_text == ""
202
-
203
- def should_allow_when_prompt_carries_run_slug_for_scoped_status(
204
- self,
205
- temporary_directory: Path,
206
- monkeypatch: pytest.MonkeyPatch,
207
- ) -> None:
208
- run_slug = "demo-run"
209
- status_file_path = temporary_directory.joinpath(
210
- "docs", "plans", run_slug, ".orchestrator-run-status.json"
211
- )
212
- write_status_file(status_file_path, RUN_STATUS_ACTIVE, run_slug, is_rearm_pending=False)
213
- monkeypatch.delenv("ORCHESTRATOR_RUN_STATUS_FILE", raising=False)
214
- gate_module = load_gate_module()
215
- payload = {
216
- "tool_name": "ScheduleWakeup",
217
- "tool_input": {
218
- "prompt": f"/orchestrator-refresh --run-slug {run_slug}"
219
- },
220
- "cwd": str(temporary_directory),
221
- }
222
- exit_code, stdout_text = run_main_with_payload(
223
- gate_module, payload, monkeypatch
224
- )
225
- assert exit_code == 0
226
- assert stdout_text == ""
227
-
228
-
229
- @pytest.fixture
230
- def temporary_directory(tmp_path: Path) -> Path:
231
- return tmp_path