claude-dev-env 2.2.1 → 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.
- package/CLAUDE.md +3 -2
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
- package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +1 -0
- package/agents/code-verifier.md +4 -4
- package/agents/skill-writer-agent.md +84 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/docs/CLAUDE.md +1 -0
- package/docs/references/CLAUDE.md +1 -0
- package/docs/references/code-review-enforcement.md +97 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +8 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +194 -0
- package/hooks/blocking/code_review_push_gate.py +140 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
- package/hooks/blocking/config/verified_commit_constants.py +24 -9
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
- package/hooks/blocking/test_code_review_push_gate.py +189 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
- package/hooks/blocking/test_verification_verdict_store.py +920 -810
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verification_verdict_store.py +118 -5
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +103 -0
- package/hooks/hooks.json +16 -1
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +211 -23
- package/hooks/validators/run_all_validators.py +21 -14
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_ruff_integration.py +68 -1
- package/hooks/validators/test_run_all_validators.py +25 -0
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +20 -43
- package/rules/durable-post-artifacts.md +7 -0
- package/scripts/CLAUDE.md +2 -1
- package/scripts/claude-chain.example.json +15 -3
- package/scripts/claude_chain_runner.py +130 -27
- package/scripts/claude_chain_usage.py +346 -0
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +8 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
- package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/dev_env_scripts_constants/timing.py +1 -1
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +8 -1
- package/scripts/test_claude_chain_runner.py +412 -6
- package/scripts/test_claude_chain_usage.py +534 -0
- package/scripts/test_invoke_code_review.py +298 -4
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
- package/skills/autoconverge/workflow/converge.mjs +24 -8
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/orchestrator/SKILL.md +1 -20
- package/skills/orchestrator-refresh/SKILL.md +1 -1
- package/skills/pr-converge/SKILL.md +3 -3
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +3 -3
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- package/skills/team-advisor/SKILL.md +5 -4
|
@@ -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()
|