claude-dev-env 2.3.0 → 2.4.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 (136) hide show
  1. package/CLAUDE.md +3 -2
  2. package/agents/CLAUDE.md +1 -0
  3. package/agents/code-verifier.md +3 -3
  4. package/agents/skill-writer-agent.md +84 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  7. package/docs/CLAUDE.md +1 -0
  8. package/docs/references/CLAUDE.md +1 -0
  9. package/docs/references/code-review-enforcement.md +97 -0
  10. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  11. package/hooks/blocking/CLAUDE.md +8 -1
  12. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  13. package/hooks/blocking/code_review_gate_deny.py +74 -0
  14. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  15. package/hooks/blocking/code_review_push_gate.py +140 -0
  16. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  17. package/hooks/blocking/code_review_stamp_store.py +233 -0
  18. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  19. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  20. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  21. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  22. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  23. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  24. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  25. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  26. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  27. package/hooks/blocking/config/verified_commit_constants.py +13 -9
  28. package/hooks/blocking/conftest.py +2 -0
  29. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  30. package/hooks/blocking/destructive_command_blocker.py +19 -6
  31. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  33. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  34. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  35. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  36. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  37. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  38. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  39. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  40. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  41. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  42. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  43. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  44. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  45. package/hooks/blocking/test_verification_verdict_store.py +920 -903
  46. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  47. package/hooks/blocking/verification_verdict_store.py +27 -5
  48. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  49. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  50. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  51. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  52. package/hooks/git-hooks/pre_push.py +89 -2
  53. package/hooks/git-hooks/test_pre_push.py +103 -0
  54. package/hooks/hooks.json +16 -1
  55. package/hooks/hooks_constants/CLAUDE.md +1 -0
  56. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  57. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  58. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  59. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  60. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  61. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  62. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  63. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  64. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  65. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  66. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  67. package/hooks/validators/CLAUDE.md +1 -0
  68. package/hooks/validators/mypy_integration.py +63 -50
  69. package/hooks/validators/pyproject_config_discovery.py +101 -0
  70. package/hooks/validators/ruff_integration.py +211 -23
  71. package/hooks/validators/run_all_validators.py +21 -14
  72. package/hooks/validators/test_mypy_integration.py +32 -0
  73. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  74. package/hooks/validators/test_ruff_integration.py +68 -1
  75. package/hooks/validators/test_run_all_validators.py +25 -0
  76. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  77. package/package.json +1 -1
  78. package/rules/docstring-prose-matches-implementation.md +20 -43
  79. package/rules/durable-post-artifacts.md +7 -0
  80. package/scripts/codec_forwarding_test_support.py +83 -0
  81. package/scripts/conftest.py +8 -0
  82. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  83. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
  84. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  85. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  86. package/scripts/invoke_code_review.py +550 -38
  87. package/scripts/resolve_worker_spawn.py +8 -1
  88. package/scripts/test_invoke_code_review.py +298 -4
  89. package/scripts/test_invoke_code_review_codec.py +77 -0
  90. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  91. package/skills/autoconverge/SKILL.md +9 -3
  92. package/skills/autoconverge/reference/convergence.md +2 -1
  93. package/skills/autoconverge/reference/multi-pr.md +6 -1
  94. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  95. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  96. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  98. package/skills/autoconverge/workflow/converge.mjs +24 -8
  99. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  100. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  101. package/skills/fresh-branch/CLAUDE.md +2 -0
  102. package/skills/fresh-branch/SKILL.md +2 -0
  103. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  104. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  105. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  106. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  107. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  108. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  109. package/skills/prototype/SKILL.md +86 -0
  110. package/skills/prototype/reference/honest-limitations.md +23 -0
  111. package/skills/prototype/reference/promotion-tasks.md +23 -0
  112. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  113. package/skills/prototype/scripts/conftest.py +15 -0
  114. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  115. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  116. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  117. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  118. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  119. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  120. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  121. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  122. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  123. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  124. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  125. package/skills/prototype/workflows/promotion.md +27 -0
  126. package/skills/prototype/workflows/sandbox.md +35 -0
  127. package/skills/skill-builder/CLAUDE.md +3 -3
  128. package/skills/skill-builder/SKILL.md +5 -5
  129. package/skills/skill-builder/references/CLAUDE.md +1 -1
  130. package/skills/skill-builder/references/delegation-map.md +3 -3
  131. package/skills/skill-builder/references/description-field.md +1 -1
  132. package/skills/skill-builder/references/skill-modularity.md +2 -3
  133. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  134. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  135. package/skills/skill-builder/workflows/new-skill.md +2 -2
  136. package/skills/team-advisor/SKILL.md +2 -2
@@ -0,0 +1,275 @@
1
+ """Tests for the sandbox safety-settings builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ from pathlib import Path
8
+ from types import ModuleType
9
+
10
+ from prototype_scripts_constants.config.build_sandbox_settings_constants import (
11
+ ALL_DESTRUCTIVE_REQUIRED_MATCHERS,
12
+ ALL_PII_REQUIRED_MATCHERS,
13
+ ALL_SAFETY_HOOK_SCRIPT_BASENAMES,
14
+ BUILD_SUCCESS_EXIT_CODE,
15
+ COMMAND_KEY,
16
+ HOOKS_KEY,
17
+ MATCHER_KEY,
18
+ PRE_TOOL_USE_KEY,
19
+ SETTINGS_MISSING_SAFETY_HOOK_EXIT_CODE,
20
+ )
21
+
22
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
23
+ BUILDER_PATH = SCRIPTS_DIRECTORY / "build_sandbox_settings.py"
24
+
25
+ PII_HOOK_BASENAME = ALL_SAFETY_HOOK_SCRIPT_BASENAMES[0]
26
+ DESTRUCTIVE_HOOK_BASENAME = ALL_SAFETY_HOOK_SCRIPT_BASENAMES[1]
27
+
28
+ CODE_RULES_HOOK_BASENAME = "code_rules_enforcer.py"
29
+ TDD_HOOK_BASENAME = "tdd_enforcer.py"
30
+ PLAIN_LANGUAGE_HOOK_BASENAME = "plain_language_blocker.py"
31
+
32
+ BASH_MATCHER = "Bash"
33
+ WRITE_MATCHER = "Write"
34
+ EDIT_MATCHER = "Edit"
35
+ MULTI_EDIT_MATCHER = "MultiEdit"
36
+ NARROW_PII_MATCHER = "mcp__plugin_github_github__.*"
37
+
38
+ SUBHOOK_TIMEOUT = 15
39
+ PII_COMMAND = f"python hooks/blocking/{PII_HOOK_BASENAME}"
40
+ DESTRUCTIVE_COMMAND = f"python hooks/blocking/{DESTRUCTIVE_HOOK_BASENAME}"
41
+ CODE_RULES_COMMAND = f"python hooks/blocking/{CODE_RULES_HOOK_BASENAME}"
42
+
43
+
44
+ def load_builder_module() -> ModuleType:
45
+ spec = importlib.util.spec_from_file_location(
46
+ "build_sandbox_settings", BUILDER_PATH
47
+ )
48
+ assert spec is not None
49
+ assert spec.loader is not None
50
+ builder_module = importlib.util.module_from_spec(spec)
51
+ spec.loader.exec_module(builder_module)
52
+ return builder_module
53
+
54
+
55
+ def make_subhook(command: str) -> dict:
56
+ return {"type": "command", "command": command, "timeout": SUBHOOK_TIMEOUT}
57
+
58
+
59
+ def sample_settings_document() -> dict:
60
+ """Build a settings document that registers pii on a narrow matcher only.
61
+
62
+ ::
63
+
64
+ Write block -> [code_rules_enforcer]
65
+ Bash block -> [destructive_command_blocker]
66
+ GitHub block -> [pii_prevention_blocker] (narrow matcher)
67
+
68
+ The live pii gate is scoped to the GitHub MCP tool family, so a
69
+ presence-only copy would leave disk writes ungated.
70
+ """
71
+ write_block = {
72
+ MATCHER_KEY: WRITE_MATCHER,
73
+ HOOKS_KEY: [make_subhook(CODE_RULES_COMMAND)],
74
+ }
75
+ bash_block = {
76
+ MATCHER_KEY: BASH_MATCHER,
77
+ HOOKS_KEY: [make_subhook(DESTRUCTIVE_COMMAND)],
78
+ }
79
+ github_block = {
80
+ MATCHER_KEY: NARROW_PII_MATCHER,
81
+ HOOKS_KEY: [make_subhook(PII_COMMAND)],
82
+ }
83
+ return {HOOKS_KEY: {PRE_TOOL_USE_KEY: [write_block, bash_block, github_block]}}
84
+
85
+
86
+ def document_without_destructive() -> dict:
87
+ """Build a settings document that registers pii but no destructive hook."""
88
+ github_block = {
89
+ MATCHER_KEY: NARROW_PII_MATCHER,
90
+ HOOKS_KEY: [make_subhook(PII_COMMAND)],
91
+ }
92
+ return {HOOKS_KEY: {PRE_TOOL_USE_KEY: [github_block]}}
93
+
94
+
95
+ def minimal_settings_for(builder: ModuleType, settings_document: dict) -> dict:
96
+ entry_by_basename = builder.resolve_safety_hook_entries(settings_document)
97
+ return builder.build_minimal_settings(entry_by_basename)
98
+
99
+
100
+ def blocks_of(minimal_settings: dict) -> list[dict]:
101
+ return minimal_settings[HOOKS_KEY][PRE_TOOL_USE_KEY]
102
+
103
+
104
+ def commands_by_matcher(minimal_settings: dict) -> dict[str, list[str]]:
105
+ commands_by_each_matcher: dict[str, list[str]] = {}
106
+ for each_block in blocks_of(minimal_settings):
107
+ commands_by_each_matcher[each_block[MATCHER_KEY]] = [
108
+ each_subhook[COMMAND_KEY] for each_subhook in each_block[HOOKS_KEY]
109
+ ]
110
+ return commands_by_each_matcher
111
+
112
+
113
+ def test_read_settings_document_parses_the_source_json(tmp_path: Path) -> None:
114
+ builder = load_builder_module()
115
+ source_path = tmp_path / "settings.json"
116
+ source_path.write_text(json.dumps(sample_settings_document()), encoding="utf-8")
117
+ parsed_document = builder.read_settings_document(source_path)
118
+ assert parsed_document == sample_settings_document()
119
+
120
+
121
+ def test_resolve_safety_hook_entries_copies_each_live_command_through() -> None:
122
+ builder = load_builder_module()
123
+ entry_by_basename = builder.resolve_safety_hook_entries(sample_settings_document())
124
+ assert set(entry_by_basename) == set(ALL_SAFETY_HOOK_SCRIPT_BASENAMES)
125
+ assert entry_by_basename[PII_HOOK_BASENAME][COMMAND_KEY] == PII_COMMAND
126
+ assert (
127
+ entry_by_basename[DESTRUCTIVE_HOOK_BASENAME][COMMAND_KEY] == DESTRUCTIVE_COMMAND
128
+ )
129
+ assert entry_by_basename[PII_HOOK_BASENAME]["timeout"] == SUBHOOK_TIMEOUT
130
+
131
+
132
+ def test_find_unresolved_safety_hook_basenames_names_the_missing_hook() -> None:
133
+ builder = load_builder_module()
134
+ entry_by_basename = builder.resolve_safety_hook_entries(
135
+ document_without_destructive()
136
+ )
137
+ assert builder.find_unresolved_safety_hook_basenames(entry_by_basename) == [
138
+ DESTRUCTIVE_HOOK_BASENAME
139
+ ]
140
+
141
+
142
+ def test_find_unresolved_safety_hook_basenames_empty_when_both_resolve() -> None:
143
+ builder = load_builder_module()
144
+ entry_by_basename = builder.resolve_safety_hook_entries(sample_settings_document())
145
+ assert builder.find_unresolved_safety_hook_basenames(entry_by_basename) == []
146
+
147
+
148
+ def test_build_registers_pii_on_every_required_write_and_command_matcher() -> None:
149
+ builder = load_builder_module()
150
+ commands = commands_by_matcher(
151
+ minimal_settings_for(builder, sample_settings_document())
152
+ )
153
+ assert set(commands) == set(ALL_PII_REQUIRED_MATCHERS)
154
+ for each_matcher in ALL_PII_REQUIRED_MATCHERS:
155
+ assert PII_COMMAND in commands[each_matcher]
156
+
157
+
158
+ def test_build_registers_destructive_on_the_command_matcher_only() -> None:
159
+ builder = load_builder_module()
160
+ commands = commands_by_matcher(
161
+ minimal_settings_for(builder, sample_settings_document())
162
+ )
163
+ for each_matcher, each_command_list in commands.items():
164
+ has_destructive = DESTRUCTIVE_COMMAND in each_command_list
165
+ assert has_destructive == (each_matcher in ALL_DESTRUCTIVE_REQUIRED_MATCHERS)
166
+
167
+
168
+ def test_build_bash_block_carries_both_safety_hooks() -> None:
169
+ builder = load_builder_module()
170
+ commands = commands_by_matcher(
171
+ minimal_settings_for(builder, sample_settings_document())
172
+ )
173
+ assert commands[BASH_MATCHER] == [PII_COMMAND, DESTRUCTIVE_COMMAND]
174
+
175
+
176
+ def test_build_excludes_every_non_safety_hook() -> None:
177
+ builder = load_builder_module()
178
+ commands = commands_by_matcher(
179
+ minimal_settings_for(builder, sample_settings_document())
180
+ )
181
+ all_commands = [
182
+ each_command
183
+ for each_command_list in commands.values()
184
+ for each_command in each_command_list
185
+ ]
186
+ for each_excluded in (
187
+ CODE_RULES_HOOK_BASENAME,
188
+ TDD_HOOK_BASENAME,
189
+ PLAIN_LANGUAGE_HOOK_BASENAME,
190
+ ):
191
+ assert all(each_excluded not in each_command for each_command in all_commands)
192
+
193
+
194
+ def test_narrow_live_pii_matcher_does_not_leak_into_output() -> None:
195
+ builder = load_builder_module()
196
+ all_matchers = set(
197
+ commands_by_matcher(minimal_settings_for(builder, sample_settings_document()))
198
+ )
199
+ assert NARROW_PII_MATCHER not in all_matchers
200
+ assert all_matchers.issuperset(
201
+ {WRITE_MATCHER, EDIT_MATCHER, MULTI_EDIT_MATCHER, BASH_MATCHER}
202
+ )
203
+
204
+
205
+ def test_build_emits_blocks_in_sorted_matcher_order() -> None:
206
+ builder = load_builder_module()
207
+ minimal_settings = minimal_settings_for(builder, sample_settings_document())
208
+ emitted_matchers = [
209
+ each_block[MATCHER_KEY] for each_block in blocks_of(minimal_settings)
210
+ ]
211
+ assert emitted_matchers == sorted(emitted_matchers)
212
+
213
+
214
+ def test_build_emits_the_deny_mode_env_block() -> None:
215
+ builder = load_builder_module()
216
+ minimal_settings = minimal_settings_for(builder, sample_settings_document())
217
+ assert minimal_settings[builder.ENV_KEY] == {
218
+ builder.DESTRUCTIVE_DENY_MODE_ENV_VAR: builder.DESTRUCTIVE_DENY_MODE_ENV_VALUE
219
+ }
220
+
221
+
222
+ def test_write_minimal_settings_round_trips_the_document(tmp_path: Path) -> None:
223
+ builder = load_builder_module()
224
+ minimal_settings = minimal_settings_for(builder, sample_settings_document())
225
+ out_path = tmp_path / "sandbox-settings.json"
226
+ builder.write_minimal_settings(minimal_settings, out_path)
227
+ assert json.loads(out_path.read_text(encoding="utf-8")) == minimal_settings
228
+
229
+
230
+ def test_main_writes_minimal_settings_and_succeeds(tmp_path: Path) -> None:
231
+ builder = load_builder_module()
232
+ source_path = tmp_path / "settings.json"
233
+ source_path.write_text(json.dumps(sample_settings_document()), encoding="utf-8")
234
+ out_path = tmp_path / "sandbox-settings.json"
235
+ exit_code = builder.main(
236
+ ["--out", str(out_path), "--settings-source", str(source_path)]
237
+ )
238
+ assert exit_code == BUILD_SUCCESS_EXIT_CODE
239
+ written = json.loads(out_path.read_text(encoding="utf-8"))
240
+ assert set(commands_by_matcher(written)) == set(ALL_PII_REQUIRED_MATCHERS)
241
+
242
+
243
+ def test_main_exits_when_the_settings_source_is_unreadable(tmp_path: Path) -> None:
244
+ builder = load_builder_module()
245
+ absent_source_path = tmp_path / "does-not-exist.json"
246
+ out_path = tmp_path / "sandbox-settings.json"
247
+ exit_code = builder.main(
248
+ ["--out", str(out_path), "--settings-source", str(absent_source_path)]
249
+ )
250
+ assert exit_code == builder.SETTINGS_SOURCE_UNREADABLE_EXIT_CODE
251
+ assert not out_path.exists()
252
+
253
+
254
+ def test_main_exits_when_the_settings_source_is_invalid_json(tmp_path: Path) -> None:
255
+ builder = load_builder_module()
256
+ source_path = tmp_path / "settings.json"
257
+ source_path.write_text("{not valid json", encoding="utf-8")
258
+ out_path = tmp_path / "sandbox-settings.json"
259
+ exit_code = builder.main(
260
+ ["--out", str(out_path), "--settings-source", str(source_path)]
261
+ )
262
+ assert exit_code == builder.SETTINGS_SOURCE_UNREADABLE_EXIT_CODE
263
+ assert not out_path.exists()
264
+
265
+
266
+ def test_main_exits_when_a_safety_hook_is_missing(tmp_path: Path) -> None:
267
+ builder = load_builder_module()
268
+ source_path = tmp_path / "settings.json"
269
+ source_path.write_text(json.dumps(document_without_destructive()), encoding="utf-8")
270
+ out_path = tmp_path / "sandbox-settings.json"
271
+ exit_code = builder.main(
272
+ ["--out", str(out_path), "--settings-source", str(source_path)]
273
+ )
274
+ assert exit_code == SETTINGS_MISSING_SAFETY_HOOK_EXIT_CODE
275
+ assert not out_path.exists()
@@ -0,0 +1,303 @@
1
+ """Tests for the hookless sandbox launcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ import pytest
10
+
11
+ from prototype_scripts_constants.config.launch_sandbox_constants import (
12
+ BARE_FLAG,
13
+ CLAUDE_EXECUTABLE_NAME,
14
+ LAUNCH_MISSING_PATH_EXIT_CODE,
15
+ PROMPT_FLAG,
16
+ SETTINGS_FLAG,
17
+ SKIP_PERMISSIONS_FLAG,
18
+ )
19
+
20
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
21
+ LAUNCHER_PATH = SCRIPTS_DIRECTORY / "launch_sandbox.py"
22
+
23
+ SANDBOX_TASK_TEXT = "Build a proof of concept for the widget cache."
24
+ SANDBOX_TIMEOUT_SECONDS = 42
25
+ FAKE_RUNNER_EXIT_CODE = 7
26
+
27
+
28
+ def load_launcher_module() -> ModuleType:
29
+ spec = importlib.util.spec_from_file_location("launch_sandbox", LAUNCHER_PATH)
30
+ assert spec is not None
31
+ assert spec.loader is not None
32
+ launcher_module = importlib.util.module_from_spec(spec)
33
+ spec.loader.exec_module(launcher_module)
34
+ return launcher_module
35
+
36
+
37
+ class RecordingCommandRunner:
38
+ def __init__(self, exit_code: int) -> None:
39
+ self.exit_code = exit_code
40
+ self.recorded_tokens: list[str] = []
41
+ self.recorded_working_directory: Path | None = None
42
+ self.recorded_timeout_seconds: int | None = None
43
+ self.call_count = 0
44
+
45
+ def __call__(
46
+ self,
47
+ all_command_tokens: list[str],
48
+ working_directory: Path,
49
+ timeout_seconds: int | None,
50
+ ) -> int:
51
+ self.call_count += 1
52
+ self.recorded_tokens = all_command_tokens
53
+ self.recorded_working_directory = working_directory
54
+ self.recorded_timeout_seconds = timeout_seconds
55
+ return self.exit_code
56
+
57
+
58
+ def make_sandbox_worktree(tmp_path: Path) -> tuple[Path, Path]:
59
+ worktree_path = tmp_path / "worktree"
60
+ worktree_path.mkdir()
61
+ settings_path = tmp_path / "sandbox-settings.json"
62
+ settings_path.write_text("{}", encoding="utf-8")
63
+ return worktree_path, settings_path
64
+
65
+
66
+ def make_task_file(tmp_path: Path) -> Path:
67
+ task_file_path = tmp_path / "task.txt"
68
+ task_file_path.write_text(SANDBOX_TASK_TEXT, encoding="utf-8")
69
+ return task_file_path
70
+
71
+
72
+ def test_build_sandbox_command_has_the_exact_ordered_argv() -> None:
73
+ launcher = load_launcher_module()
74
+ settings_path = Path("/tmp/sandbox-settings.json")
75
+ sandbox_command = launcher.build_sandbox_command(SANDBOX_TASK_TEXT, settings_path)
76
+ assert sandbox_command == [
77
+ CLAUDE_EXECUTABLE_NAME,
78
+ PROMPT_FLAG,
79
+ SANDBOX_TASK_TEXT,
80
+ BARE_FLAG,
81
+ SKIP_PERMISSIONS_FLAG,
82
+ SETTINGS_FLAG,
83
+ str(settings_path),
84
+ ]
85
+
86
+
87
+ def test_run_sandbox_records_cwd_and_argv_without_launching_claude(
88
+ tmp_path: Path,
89
+ ) -> None:
90
+ launcher = load_launcher_module()
91
+ worktree_path, settings_path = make_sandbox_worktree(tmp_path)
92
+ recording_runner = RecordingCommandRunner(FAKE_RUNNER_EXIT_CODE)
93
+ exit_code = launcher.run_sandbox(
94
+ worktree_path,
95
+ settings_path,
96
+ SANDBOX_TASK_TEXT,
97
+ SANDBOX_TIMEOUT_SECONDS,
98
+ recording_runner,
99
+ )
100
+ assert exit_code == FAKE_RUNNER_EXIT_CODE
101
+ assert recording_runner.call_count == 1
102
+ assert recording_runner.recorded_working_directory == worktree_path
103
+ assert recording_runner.recorded_tokens == launcher.build_sandbox_command(
104
+ SANDBOX_TASK_TEXT, settings_path
105
+ )
106
+ assert recording_runner.recorded_timeout_seconds == SANDBOX_TIMEOUT_SECONDS
107
+
108
+
109
+ def test_validate_sandbox_paths_passes_when_every_path_exists(tmp_path: Path) -> None:
110
+ launcher = load_launcher_module()
111
+ worktree_path, settings_path = make_sandbox_worktree(tmp_path)
112
+ task_file_path = make_task_file(tmp_path)
113
+ error_message = launcher.validate_sandbox_paths(
114
+ worktree_path, settings_path, task_file_path
115
+ )
116
+ assert error_message is None
117
+
118
+
119
+ def test_validate_sandbox_paths_reports_a_missing_worktree(tmp_path: Path) -> None:
120
+ launcher = load_launcher_module()
121
+ _, settings_path = make_sandbox_worktree(tmp_path)
122
+ task_file_path = make_task_file(tmp_path)
123
+ error_message = launcher.validate_sandbox_paths(
124
+ tmp_path / "absent-worktree", settings_path, task_file_path
125
+ )
126
+ assert error_message is not None
127
+ assert "worktree" in error_message
128
+
129
+
130
+ def test_validate_sandbox_paths_reports_a_missing_task_file(tmp_path: Path) -> None:
131
+ launcher = load_launcher_module()
132
+ worktree_path, settings_path = make_sandbox_worktree(tmp_path)
133
+ error_message = launcher.validate_sandbox_paths(
134
+ worktree_path, settings_path, tmp_path / "absent-task.txt"
135
+ )
136
+ assert error_message is not None
137
+ assert "task file" in error_message
138
+
139
+
140
+ def test_validate_sandbox_paths_reports_a_missing_settings_file(tmp_path: Path) -> None:
141
+ launcher = load_launcher_module()
142
+ worktree_path, _ = make_sandbox_worktree(tmp_path)
143
+ task_file_path = make_task_file(tmp_path)
144
+ error_message = launcher.validate_sandbox_paths(
145
+ worktree_path, tmp_path / "absent-settings.json", task_file_path
146
+ )
147
+ assert error_message is not None
148
+ assert "settings" in error_message
149
+
150
+
151
+ def test_main_returns_the_missing_path_exit_code_for_an_absent_worktree(
152
+ tmp_path: Path,
153
+ ) -> None:
154
+ launcher = load_launcher_module()
155
+ _, settings_path = make_sandbox_worktree(tmp_path)
156
+ task_file_path = make_task_file(tmp_path)
157
+ exit_code = launcher.main(
158
+ [
159
+ "--worktree",
160
+ str(tmp_path / "absent-worktree"),
161
+ "--settings",
162
+ str(settings_path),
163
+ "--task-file",
164
+ str(task_file_path),
165
+ ]
166
+ )
167
+ assert exit_code == LAUNCH_MISSING_PATH_EXIT_CODE
168
+
169
+
170
+ def test_main_runs_the_sandbox_and_returns_the_runner_exit_code(
171
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
172
+ ) -> None:
173
+ launcher = load_launcher_module()
174
+ worktree_path, settings_path = make_sandbox_worktree(tmp_path)
175
+ task_file_path = make_task_file(tmp_path)
176
+ recording_runner = RecordingCommandRunner(FAKE_RUNNER_EXIT_CODE)
177
+ monkeypatch.setattr(launcher, "_run_via_subprocess", recording_runner)
178
+ exit_code = launcher.main(
179
+ [
180
+ "--worktree",
181
+ str(worktree_path),
182
+ "--settings",
183
+ str(settings_path),
184
+ "--task-file",
185
+ str(task_file_path),
186
+ ]
187
+ )
188
+ assert exit_code == FAKE_RUNNER_EXIT_CODE
189
+ assert recording_runner.call_count == 1
190
+ assert recording_runner.recorded_working_directory == worktree_path.resolve()
191
+
192
+
193
+ def test_run_via_subprocess_returns_the_timeout_code_when_the_session_outruns(
194
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
195
+ ) -> None:
196
+ launcher = load_launcher_module()
197
+ worktree_path, _ = make_sandbox_worktree(tmp_path)
198
+
199
+ def raise_timeout(*_args: object, **_kwargs: object) -> object:
200
+ raise launcher.subprocess.TimeoutExpired(
201
+ cmd=[CLAUDE_EXECUTABLE_NAME], timeout=SANDBOX_TIMEOUT_SECONDS
202
+ )
203
+
204
+ monkeypatch.setattr(launcher.subprocess, "run", raise_timeout)
205
+ exit_code = launcher._run_via_subprocess(
206
+ [CLAUDE_EXECUTABLE_NAME], worktree_path, SANDBOX_TIMEOUT_SECONDS
207
+ )
208
+ assert exit_code == launcher.LAUNCH_TIMEOUT_EXIT_CODE
209
+
210
+
211
+ def test_run_via_subprocess_returns_the_missing_path_code_when_claude_is_absent(
212
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
213
+ ) -> None:
214
+ launcher = load_launcher_module()
215
+ worktree_path, _ = make_sandbox_worktree(tmp_path)
216
+
217
+ def raise_file_not_found(*_args: object, **_kwargs: object) -> object:
218
+ raise FileNotFoundError(CLAUDE_EXECUTABLE_NAME)
219
+
220
+ monkeypatch.setattr(launcher.subprocess, "run", raise_file_not_found)
221
+ exit_code = launcher._run_via_subprocess(
222
+ [CLAUDE_EXECUTABLE_NAME], worktree_path, SANDBOX_TIMEOUT_SECONDS
223
+ )
224
+ assert exit_code == LAUNCH_MISSING_PATH_EXIT_CODE
225
+
226
+
227
+ def test_main_returns_the_missing_path_code_when_claude_is_absent(
228
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
229
+ ) -> None:
230
+ launcher = load_launcher_module()
231
+ worktree_path, settings_path = make_sandbox_worktree(tmp_path)
232
+ task_file_path = make_task_file(tmp_path)
233
+
234
+ def raise_file_not_found(*_args: object, **_kwargs: object) -> object:
235
+ raise FileNotFoundError(CLAUDE_EXECUTABLE_NAME)
236
+
237
+ monkeypatch.setattr(launcher.subprocess, "run", raise_file_not_found)
238
+ exit_code = launcher.main(
239
+ [
240
+ "--worktree",
241
+ str(worktree_path),
242
+ "--settings",
243
+ str(settings_path),
244
+ "--task-file",
245
+ str(task_file_path),
246
+ ]
247
+ )
248
+ assert exit_code == LAUNCH_MISSING_PATH_EXIT_CODE
249
+ emitted_summary = launcher.json.loads(capsys.readouterr().out)
250
+ assert emitted_summary["exit_code"] == LAUNCH_MISSING_PATH_EXIT_CODE
251
+
252
+
253
+ def test_main_returns_the_timeout_code_and_emits_the_summary_on_timeout(
254
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
255
+ ) -> None:
256
+ launcher = load_launcher_module()
257
+ worktree_path, settings_path = make_sandbox_worktree(tmp_path)
258
+ task_file_path = make_task_file(tmp_path)
259
+
260
+ def raise_timeout(*_args: object, **_kwargs: object) -> object:
261
+ raise launcher.subprocess.TimeoutExpired(
262
+ cmd=[CLAUDE_EXECUTABLE_NAME], timeout=SANDBOX_TIMEOUT_SECONDS
263
+ )
264
+
265
+ monkeypatch.setattr(launcher.subprocess, "run", raise_timeout)
266
+ exit_code = launcher.main(
267
+ [
268
+ "--worktree",
269
+ str(worktree_path),
270
+ "--settings",
271
+ str(settings_path),
272
+ "--task-file",
273
+ str(task_file_path),
274
+ ]
275
+ )
276
+ assert exit_code == launcher.LAUNCH_TIMEOUT_EXIT_CODE
277
+ emitted_summary = launcher.json.loads(capsys.readouterr().out)
278
+ assert emitted_summary["exit_code"] == launcher.LAUNCH_TIMEOUT_EXIT_CODE
279
+
280
+
281
+ def test_main_resolves_a_relative_settings_path_to_absolute(
282
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
283
+ ) -> None:
284
+ launcher = load_launcher_module()
285
+ worktree_path, settings_path = make_sandbox_worktree(tmp_path)
286
+ task_file_path = make_task_file(tmp_path)
287
+ recording_runner = RecordingCommandRunner(FAKE_RUNNER_EXIT_CODE)
288
+ monkeypatch.setattr(launcher, "_run_via_subprocess", recording_runner)
289
+ monkeypatch.chdir(tmp_path)
290
+ launcher.main(
291
+ [
292
+ "--worktree",
293
+ worktree_path.name,
294
+ "--settings",
295
+ settings_path.name,
296
+ "--task-file",
297
+ task_file_path.name,
298
+ ]
299
+ )
300
+ recorded_settings_token = recording_runner.recorded_tokens[-1]
301
+ assert Path(recorded_settings_token).is_absolute()
302
+ assert Path(recorded_settings_token) == settings_path.resolve()
303
+ assert recording_runner.recorded_working_directory == worktree_path.resolve()