claude-dev-env 2.3.0 → 2.5.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 +53 -48
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +3 -1
- package/agents/caveman.md +0 -1
- package/agents/clasp-deployment-orchestrator.md +0 -1
- package/agents/clean-coder.md +0 -1
- package/agents/code-advisor.md +0 -1
- package/agents/code-quality-agent.md +1 -2
- package/agents/code-verifier.md +3 -4
- package/agents/deep-research.md +0 -1
- package/agents/docs-agent.md +0 -1
- package/agents/git-commit-crafter.md +0 -1
- package/agents/issue-tracker.md +42 -0
- package/agents/plan-packet-validator.md +0 -1
- package/agents/pr-description-writer.md +0 -1
- package/agents/skill-writer-agent.md +84 -0
- package/agents/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/bin/CLAUDE.md +68 -5
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install-constants.mjs +88 -0
- package/bin/install.mjs +1138 -114
- package/bin/install.prune.test.mjs +869 -19
- package/bin/install.test.mjs +906 -2
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +2 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/references/CLAUDE.md +5 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +107 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +9 -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 +198 -0
- package/hooks/blocking/code_review_push_gate.py +145 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -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/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -155
- 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/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- 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 +199 -0
- package/hooks/blocking/test_code_review_push_gate.py +205 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -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_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_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
- package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
- package/hooks/blocking/test_verification_verdict_store.py +974 -903
- package/hooks/blocking/test_verified_commit_gate.py +581 -581
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1014
- 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/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- 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 +128 -0
- package/hooks/hooks.json +26 -1
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- 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/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- 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/ruff_integration_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/lifecycle/enter_worktree_origin_prefetch.py +163 -146
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
- package/hooks/pyproject.toml +1 -0
- package/hooks/validators/CLAUDE.md +2 -0
- package/hooks/validators/config/__init__.py +0 -0
- package/hooks/validators/config/directory_exemption_constants.py +183 -0
- package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
- package/hooks/validators/conftest.py +4 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +257 -25
- package/hooks/validators/run_all_validators.py +223 -19
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +160 -2
- package/hooks/validators/test_run_all_validators.py +140 -68
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +10 -2
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +45 -67
- package/rules/durable-post-artifacts.md +7 -0
- package/rules/state-what-is.md +25 -0
- package/rules/verified-commit-gate-skip.md +1 -1
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +23 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
- 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/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +626 -619
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review_chain.py +70 -0
- package/scripts/test_invoke_code_review_cli.py +192 -0
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_invoke_code_review_contract.py +256 -0
- package/scripts/test_invoke_code_review_git.py +123 -0
- package/scripts/test_invoke_code_review_mode.py +99 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -1014
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -421
- package/skills/autoconverge/reference/convergence.md +26 -4
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
- package/skills/autoconverge/workflow/converge.mjs +191 -8
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
- package/skills/closeout/SKILL.md +33 -50
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
- package/skills/codex-review/scripts/run_codex_review.py +233 -1
- package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
- package/skills/condensing-instructions/SKILL.md +81 -0
- package/skills/copilot-review/SKILL.md +119 -119
- package/skills/e-code-review/SKILL.md +52 -0
- package/skills/e-code-review/reference/fix.md +54 -0
- package/skills/e-code-review/reference/loop.md +43 -0
- package/skills/e-code-review/reference/low.md +57 -0
- package/skills/e-code-review/reference/medium.md +153 -0
- package/skills/e-code-review/reference/xhigh.md +182 -0
- package/skills/e-simplify/SKILL.md +97 -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/issue-tracker/SKILL.md +92 -0
- package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
- package/skills/issue-tracker/reference/handoff-schema.md +64 -0
- package/skills/issue-tracker/reference/operation-matrix.md +41 -0
- package/skills/orchestrator/SKILL.md +162 -21
- package/skills/orchestrator/scripts/status_gate.py +625 -0
- package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
- package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
- package/skills/orchestrator/scripts/test_status_gate.py +439 -0
- package/skills/orchestrator-refresh/SKILL.md +110 -35
- package/skills/plan-to-pr/SKILL.md +155 -0
- package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
- package/skills/plan-to-pr/reference/model-routing.md +36 -0
- package/skills/plan-to-pr/reference/packet-contract.md +43 -0
- package/skills/plan-to-pr/reference/packet-schema.json +57 -0
- package/skills/plan-to-pr/reference/process-inventory.md +22 -0
- package/skills/plan-to-pr/reference/review-loop.md +33 -0
- package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
- package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
- package/skills/plan-to-pr/reference/task-seeds.md +14 -0
- package/skills/plan-to-pr/reference/task-ticket.md +38 -0
- package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
- package/skills/plan-to-pr/scripts/config/constants.py +193 -0
- package/skills/plan-to-pr/scripts/create_packet.py +173 -0
- package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
- package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
- package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
- package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
- package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
- package/skills/plan-to-pr/scripts/validate_run.py +173 -0
- package/skills/plan-to-pr/test_skill_contract.py +207 -0
- package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
- package/skills/pr-converge/SKILL.md +472 -469
- 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 +7 -4
- package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/pr-fix-protocol/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -68
- 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/release-notes-html/SKILL.md +164 -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/task-build/CLAUDE.md +8 -7
- package/skills/task-build/SKILL.md +16 -8
- package/skills/task-build/reference/tool-routing.md +19 -0
- package/skills/team-advisor/SKILL.md +2 -2
- package/scripts/test_invoke_code_review.py +0 -672
- package/skills/closeout/reference/issue-body-templates.md +0 -108
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Behavioral tests for the per-work-tree code-review stamp store.
|
|
2
|
+
|
|
3
|
+
Each test builds a real git repository with a real origin remote and records or
|
|
4
|
+
reads real stamp files under an isolated home, so it exercises the same store
|
|
5
|
+
surface the push and PR-create gates call: record a clean stamp, then ask
|
|
6
|
+
whether it covers the live branch surface at a given effort.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import pathlib
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
17
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
19
|
+
|
|
20
|
+
_store_spec = importlib.util.spec_from_file_location(
|
|
21
|
+
"code_review_stamp_store",
|
|
22
|
+
_HOOK_DIR / "code_review_stamp_store.py",
|
|
23
|
+
)
|
|
24
|
+
assert _store_spec is not None
|
|
25
|
+
assert _store_spec.loader is not None
|
|
26
|
+
_store_module = importlib.util.module_from_spec(_store_spec)
|
|
27
|
+
_store_spec.loader.exec_module(_store_module)
|
|
28
|
+
|
|
29
|
+
record_clean_stamp = _store_module.record_clean_stamp
|
|
30
|
+
stamp_covers_surface = _store_module.stamp_covers_surface
|
|
31
|
+
live_surface_hash = _store_module.live_surface_hash
|
|
32
|
+
stamp_path_for_repo = _store_module.stamp_path_for_repo
|
|
33
|
+
|
|
34
|
+
_verdict_store_module = sys.modules["verification_verdict_store"]
|
|
35
|
+
root_key_for_repo = _verdict_store_module.root_key_for_repo
|
|
36
|
+
|
|
37
|
+
_constants_spec = importlib.util.spec_from_file_location(
|
|
38
|
+
"code_review_enforcement_constants",
|
|
39
|
+
_HOOK_DIR / "config" / "code_review_enforcement_constants.py",
|
|
40
|
+
)
|
|
41
|
+
assert _constants_spec is not None
|
|
42
|
+
assert _constants_spec.loader is not None
|
|
43
|
+
_constants_module = importlib.util.module_from_spec(_constants_spec)
|
|
44
|
+
_constants_spec.loader.exec_module(_constants_module)
|
|
45
|
+
PUSH_REQUIRED_EFFORT = _constants_module.PUSH_REQUIRED_EFFORT
|
|
46
|
+
PR_CREATE_REQUIRED_EFFORT = _constants_module.PR_CREATE_REQUIRED_EFFORT
|
|
47
|
+
ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER = _constants_module.ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
|
|
48
|
+
|
|
49
|
+
PRODUCTION_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
|
|
50
|
+
CHANGED_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
|
|
51
|
+
FURTHER_CHANGED_SOURCE = "def add(left: int, right: int) -> int:\n return left * right\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_git(repo_dir: pathlib.Path, *git_arguments: str) -> None:
|
|
55
|
+
subprocess.run(
|
|
56
|
+
["git", "-C", str(repo_dir), *git_arguments],
|
|
57
|
+
check=True,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _make_repo_with_origin(tmp_path: pathlib.Path) -> pathlib.Path:
|
|
64
|
+
origin_dir = tmp_path / "origin.git"
|
|
65
|
+
work_dir = tmp_path / "work"
|
|
66
|
+
work_dir.mkdir()
|
|
67
|
+
subprocess.run(
|
|
68
|
+
["git", "init", "--bare", "--initial-branch=main", str(origin_dir)],
|
|
69
|
+
check=True,
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
)
|
|
73
|
+
empty_hooks_dir = tmp_path / "nohooks"
|
|
74
|
+
empty_hooks_dir.mkdir()
|
|
75
|
+
_run_git(work_dir, "init", "--initial-branch=main")
|
|
76
|
+
_run_git(work_dir, "config", "user.email", "tests@example.com")
|
|
77
|
+
_run_git(work_dir, "config", "user.name", "Stamp Store Tests")
|
|
78
|
+
_run_git(work_dir, "config", "core.hooksPath", str(empty_hooks_dir))
|
|
79
|
+
(work_dir / "src").mkdir()
|
|
80
|
+
(work_dir / "src" / "app.py").write_text(PRODUCTION_SOURCE, encoding="utf-8")
|
|
81
|
+
_run_git(work_dir, "add", "-A")
|
|
82
|
+
_run_git(work_dir, "commit", "-m", "base")
|
|
83
|
+
_run_git(work_dir, "remote", "add", "origin", str(origin_dir))
|
|
84
|
+
_run_git(work_dir, "push", "-u", "origin", "main")
|
|
85
|
+
return work_dir
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: pathlib.Path) -> None:
|
|
89
|
+
home_text = str(fake_home)
|
|
90
|
+
monkeypatch.setenv("HOME", home_text)
|
|
91
|
+
monkeypatch.setenv("USERPROFILE", home_text)
|
|
92
|
+
monkeypatch.delenv("HOMEDRIVE", raising=False)
|
|
93
|
+
monkeypatch.delenv("HOMEPATH", raising=False)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _repo_with_change(tmp_path: pathlib.Path, changed_source: str) -> pathlib.Path:
|
|
97
|
+
work_dir = _make_repo_with_origin(tmp_path)
|
|
98
|
+
(work_dir / "src" / "app.py").write_text(changed_source, encoding="utf-8")
|
|
99
|
+
return work_dir
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_recorded_low_stamp_covers_the_push_surface(
|
|
103
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
104
|
+
) -> None:
|
|
105
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
106
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
107
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
108
|
+
assert surface_hash is not None
|
|
109
|
+
record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
|
|
110
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_low_stamp_does_not_cover_the_pr_create_surface(
|
|
114
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
115
|
+
) -> None:
|
|
116
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
117
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
118
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
119
|
+
assert surface_hash is not None
|
|
120
|
+
record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
|
|
121
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT) is False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_xhigh_stamp_covers_both_push_and_pr_create(
|
|
125
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
126
|
+
) -> None:
|
|
127
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
128
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
129
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
130
|
+
assert surface_hash is not None
|
|
131
|
+
record_clean_stamp(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT)
|
|
132
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is True
|
|
133
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT) is True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_stamp_bound_to_one_hash_does_not_cover_a_different_hash(
|
|
137
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
138
|
+
) -> None:
|
|
139
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
140
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
141
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
142
|
+
assert surface_hash is not None
|
|
143
|
+
record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
|
|
144
|
+
other_hash = "f" * len(surface_hash)
|
|
145
|
+
assert stamp_covers_surface(str(work_dir), other_hash, PUSH_REQUIRED_EFFORT) is False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_editing_a_file_after_minting_drops_coverage(
|
|
149
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
150
|
+
) -> None:
|
|
151
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
152
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
153
|
+
minted_hash = live_surface_hash(str(work_dir))
|
|
154
|
+
assert minted_hash is not None
|
|
155
|
+
record_clean_stamp(str(work_dir), minted_hash, PUSH_REQUIRED_EFFORT)
|
|
156
|
+
(work_dir / "src" / "app.py").write_text(FURTHER_CHANGED_SOURCE, encoding="utf-8")
|
|
157
|
+
live_hash_after_edit = live_surface_hash(str(work_dir))
|
|
158
|
+
assert live_hash_after_edit is not None
|
|
159
|
+
assert live_hash_after_edit != minted_hash
|
|
160
|
+
assert stamp_covers_surface(str(work_dir), live_hash_after_edit, PUSH_REQUIRED_EFFORT) is False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_no_stamp_file_reads_as_not_covered(
|
|
164
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
165
|
+
) -> None:
|
|
166
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
167
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
168
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
169
|
+
assert surface_hash is not None
|
|
170
|
+
assert not stamp_path_for_repo(str(work_dir)).exists()
|
|
171
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_recording_a_second_effort_keeps_the_first(
|
|
175
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
176
|
+
) -> None:
|
|
177
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
178
|
+
work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
|
|
179
|
+
surface_hash = live_surface_hash(str(work_dir))
|
|
180
|
+
assert surface_hash is not None
|
|
181
|
+
record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
|
|
182
|
+
record_clean_stamp(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT)
|
|
183
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is True
|
|
184
|
+
assert stamp_covers_surface(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT) is True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_live_surface_hash_is_none_when_nothing_changed(
|
|
188
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
189
|
+
) -> None:
|
|
190
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
191
|
+
work_dir = _make_repo_with_origin(tmp_path)
|
|
192
|
+
assert live_surface_hash(str(work_dir)) is None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_push_required_effort_is_the_lowest_known_token() -> None:
|
|
196
|
+
assert PUSH_REQUIRED_EFFORT == ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER[0]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_stamp_path_for_repo_stem_matches_the_shared_root_key(
|
|
200
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
201
|
+
) -> None:
|
|
202
|
+
_isolate_home(monkeypatch, tmp_path / "home")
|
|
203
|
+
work_dir = tmp_path / "work-tree"
|
|
204
|
+
work_dir.mkdir()
|
|
205
|
+
assert stamp_path_for_repo(str(work_dir)).stem == root_key_for_repo(str(work_dir))
|
|
@@ -183,6 +183,20 @@ def other_value() -> int:
|
|
|
183
183
|
return 42
|
|
184
184
|
'''
|
|
185
185
|
|
|
186
|
+
BROKEN_ENFORCER_SOURCE = (
|
|
187
|
+
'"""Enforcer stub that raises a non-import error at load time."""\n'
|
|
188
|
+
"\n"
|
|
189
|
+
"raise RuntimeError('deliberate enforcer load failure')\n"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
RE_ERROR_ENFORCER_SOURCE = (
|
|
193
|
+
'"""Enforcer stub whose module-scope regex compile raises re.error."""\n'
|
|
194
|
+
"\n"
|
|
195
|
+
"import re\n"
|
|
196
|
+
"\n"
|
|
197
|
+
"re.compile('(')\n"
|
|
198
|
+
)
|
|
199
|
+
|
|
186
200
|
|
|
187
201
|
def run_git(repository_root: Path, *git_arguments: str) -> str:
|
|
188
202
|
completed = subprocess.run(
|
|
@@ -240,16 +254,24 @@ def advance_origin_main_divergent(
|
|
|
240
254
|
run_git(repository_root, "checkout", "feature")
|
|
241
255
|
|
|
242
256
|
|
|
243
|
-
def
|
|
257
|
+
def _spawn_payload(tool_name: str, subagent_type: str, prompt: str, cwd: Path) -> str:
|
|
244
258
|
return json.dumps(
|
|
245
259
|
{
|
|
246
|
-
"tool_name":
|
|
260
|
+
"tool_name": tool_name,
|
|
247
261
|
"tool_input": {"subagent_type": subagent_type, "prompt": prompt},
|
|
248
262
|
"cwd": str(cwd),
|
|
249
263
|
}
|
|
250
264
|
)
|
|
251
265
|
|
|
252
266
|
|
|
267
|
+
def write_agent_payload(subagent_type: str, prompt: str, cwd: Path) -> str:
|
|
268
|
+
return _spawn_payload("Agent", subagent_type, prompt, cwd)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def write_task_payload(subagent_type: str, prompt: str, cwd: Path) -> str:
|
|
272
|
+
return _spawn_payload("Task", subagent_type, prompt, cwd)
|
|
273
|
+
|
|
274
|
+
|
|
253
275
|
def run_hook(payload: str, cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
254
276
|
return subprocess.run(
|
|
255
277
|
[sys.executable, str(HOOK_PATH)],
|
|
@@ -586,3 +608,103 @@ def test_hook_imports_real_config_when_parent_holds_shadowing_config(
|
|
|
586
608
|
|
|
587
609
|
assert "ModuleNotFoundError" not in completed.stderr
|
|
588
610
|
assert completed.returncode == 0
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def test_task_tool_code_verifier_spawn_is_gated_like_agent(tmp_path: Path) -> None:
|
|
614
|
+
repository_root = tmp_path / "repo"
|
|
615
|
+
repository_root.mkdir()
|
|
616
|
+
initialize_repository(repository_root)
|
|
617
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
618
|
+
write_working_tree_file(repository_root, "fresh.py", VIOLATING_MODULE_SOURCE)
|
|
619
|
+
task_payload = write_task_payload("code-verifier", "verify the change", repository_root)
|
|
620
|
+
task_result = run_hook(task_payload, repository_root)
|
|
621
|
+
assert not is_allow(task_result)
|
|
622
|
+
task_reason = deny_reason(task_result)
|
|
623
|
+
assert "fresh.py" in task_reason
|
|
624
|
+
assert "CODE_RULES violations on changed lines:" in task_reason
|
|
625
|
+
clean_coder_payload = write_task_payload("clean-coder", "write code", repository_root)
|
|
626
|
+
clean_coder_result = run_hook(clean_coder_payload, repository_root)
|
|
627
|
+
assert is_allow(clean_coder_result)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def stage_package_and_feature_repo(tmp_path: Path) -> tuple[Path, Path]:
|
|
631
|
+
"""Copy the hook package and build a clean feature repo for load-failure tests.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
tmp_path: The pytest temporary directory the staged copy lands under.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
A ``(staged_enforcer_path, repository_root)`` pair: the copied
|
|
638
|
+
``code_rules_enforcer.py`` a test renders unloadable, and the clean
|
|
639
|
+
feature repository the staged hook runs against.
|
|
640
|
+
"""
|
|
641
|
+
real_hooks_directory = HOOK_PATH.parent.parent
|
|
642
|
+
real_package_directory = real_hooks_directory.parent
|
|
643
|
+
staged_package_directory = tmp_path / "claude-dev-env"
|
|
644
|
+
shutil.copytree(real_hooks_directory, staged_package_directory / "hooks")
|
|
645
|
+
shutil.copytree(
|
|
646
|
+
real_package_directory / "_shared", staged_package_directory / "_shared"
|
|
647
|
+
)
|
|
648
|
+
enforcer_path = staged_package_directory / "hooks" / "blocking" / "code_rules_enforcer.py"
|
|
649
|
+
repository_root = tmp_path / "repo"
|
|
650
|
+
repository_root.mkdir()
|
|
651
|
+
initialize_repository(repository_root)
|
|
652
|
+
run_git(repository_root, "checkout", "-b", "feature")
|
|
653
|
+
write_working_tree_file(repository_root, "feature.py", CLEAN_MODULE_SOURCE)
|
|
654
|
+
return enforcer_path, repository_root
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def run_staged_preflight_hook(
|
|
658
|
+
enforcer_path: Path, repository_root: Path
|
|
659
|
+
) -> subprocess.CompletedProcess[str]:
|
|
660
|
+
"""Run the staged preflight hook that sits beside *enforcer_path*.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
enforcer_path: The staged ``code_rules_enforcer.py`` path; the staged
|
|
664
|
+
hook is its sibling in the same ``hooks/blocking`` directory.
|
|
665
|
+
repository_root: The feature repository the hook inspects.
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
The completed hook subprocess run.
|
|
669
|
+
"""
|
|
670
|
+
staged_hook = enforcer_path.parent / "code_verifier_spawn_preflight_gate.py"
|
|
671
|
+
payload = write_agent_payload("code-verifier", "verify the change", repository_root)
|
|
672
|
+
return subprocess.run(
|
|
673
|
+
[sys.executable, str(staged_hook)],
|
|
674
|
+
check=False,
|
|
675
|
+
input=payload,
|
|
676
|
+
capture_output=True,
|
|
677
|
+
text=True,
|
|
678
|
+
cwd=str(repository_root),
|
|
679
|
+
timeout=120,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def test_engine_load_failure_denies_with_named_reason(tmp_path: Path) -> None:
|
|
684
|
+
enforcer_path, repository_root = stage_package_and_feature_repo(tmp_path)
|
|
685
|
+
enforcer_path.unlink()
|
|
686
|
+
completed = run_staged_preflight_hook(enforcer_path, repository_root)
|
|
687
|
+
assert not is_allow(completed)
|
|
688
|
+
reason = deny_reason(completed)
|
|
689
|
+
assert "CODE_RULES engine failed to load" in reason
|
|
690
|
+
assert "load_validate_content" in reason
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def test_engine_import_error_denies_with_named_reason(tmp_path: Path) -> None:
|
|
694
|
+
enforcer_path, repository_root = stage_package_and_feature_repo(tmp_path)
|
|
695
|
+
enforcer_path.write_text(BROKEN_ENFORCER_SOURCE, encoding="utf-8")
|
|
696
|
+
completed = run_staged_preflight_hook(enforcer_path, repository_root)
|
|
697
|
+
assert not is_allow(completed)
|
|
698
|
+
reason = deny_reason(completed)
|
|
699
|
+
assert "CODE_RULES engine failed to load" in reason
|
|
700
|
+
assert "load_validate_content" in reason
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def test_engine_re_error_denies_with_named_reason(tmp_path: Path) -> None:
|
|
704
|
+
enforcer_path, repository_root = stage_package_and_feature_repo(tmp_path)
|
|
705
|
+
enforcer_path.write_text(RE_ERROR_ENFORCER_SOURCE, encoding="utf-8")
|
|
706
|
+
completed = run_staged_preflight_hook(enforcer_path, repository_root)
|
|
707
|
+
assert not is_allow(completed)
|
|
708
|
+
reason = deny_reason(completed)
|
|
709
|
+
assert "CODE_RULES engine failed to load" in reason
|
|
710
|
+
assert "load_validate_content" in reason
|
|
@@ -4,6 +4,7 @@ import importlib.util
|
|
|
4
4
|
import io
|
|
5
5
|
import json
|
|
6
6
|
import pathlib
|
|
7
|
+
import re
|
|
7
8
|
import subprocess
|
|
8
9
|
import sys
|
|
9
10
|
|
|
@@ -13,8 +14,6 @@ _HOOK_DIR = pathlib.Path(__file__).parent
|
|
|
13
14
|
if str(_HOOK_DIR) not in sys.path:
|
|
14
15
|
sys.path.insert(0, str(_HOOK_DIR))
|
|
15
16
|
|
|
16
|
-
import re
|
|
17
|
-
|
|
18
17
|
_GH_PR_READY_PATTERN = re.compile(r"\bgh\s+pr\s+ready\b(?![^&|;\n]*--undo)")
|
|
19
18
|
|
|
20
19
|
hook_spec = importlib.util.spec_from_file_location(
|
|
@@ -53,15 +52,44 @@ def test_does_not_match_gh_issue_close() -> None:
|
|
|
53
52
|
|
|
54
53
|
|
|
55
54
|
def test_extracts_pr_number_from_command() -> None:
|
|
56
|
-
assert _resolve_pr_number("gh pr ready 418", None) == 418
|
|
55
|
+
assert _resolve_pr_number("gh pr ready 418", None, None, None) == 418
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_extracts_pr_number_when_repo_flag_precedes_number() -> None:
|
|
59
|
+
assert (
|
|
60
|
+
_resolve_pr_number(
|
|
61
|
+
"gh pr ready --repo sample-owner/target-repo 161",
|
|
62
|
+
None,
|
|
63
|
+
"sample-owner",
|
|
64
|
+
"target-repo",
|
|
65
|
+
)
|
|
66
|
+
== 161
|
|
67
|
+
)
|
|
57
68
|
|
|
58
69
|
|
|
59
70
|
def test_extracts_pr_number_with_flags() -> None:
|
|
60
|
-
assert _resolve_pr_number("gh pr ready 99 --undo", None) == 99
|
|
71
|
+
assert _resolve_pr_number("gh pr ready 99 --undo", None, None, None) == 99
|
|
61
72
|
|
|
62
73
|
|
|
63
74
|
def test_returns_none_when_no_number_and_no_repo() -> None:
|
|
64
|
-
assert _resolve_pr_number("gh pr ready", "/nonexistent/path") is None
|
|
75
|
+
assert _resolve_pr_number("gh pr ready", "/nonexistent/path", None, None) is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_number_resolution_binds_gh_view_to_named_repo(
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
80
|
+
) -> None:
|
|
81
|
+
captured_commands: list[list[str]] = []
|
|
82
|
+
|
|
83
|
+
def fake_run(all_arguments: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
84
|
+
captured_commands.append(all_arguments)
|
|
85
|
+
return subprocess.CompletedProcess(args=all_arguments, returncode=0, stdout="500\n", stderr="")
|
|
86
|
+
|
|
87
|
+
monkeypatch.setattr(hook_module.subprocess, "run", fake_run)
|
|
88
|
+
assert _resolve_pr_number("gh pr ready", None, "flag-owner", "flag-repo") == 500
|
|
89
|
+
assert captured_commands
|
|
90
|
+
for each_command in captured_commands:
|
|
91
|
+
assert "--repo" in each_command
|
|
92
|
+
assert "flag-owner/flag-repo" in each_command
|
|
65
93
|
|
|
66
94
|
|
|
67
95
|
def test_matches_gh_pr_ready_in_compound_command() -> None:
|
|
@@ -226,6 +254,13 @@ def test_parse_repo_flag_short_alias() -> None:
|
|
|
226
254
|
)
|
|
227
255
|
|
|
228
256
|
|
|
257
|
+
def test_parse_repo_flag_short_alias_attached_value() -> None:
|
|
258
|
+
assert hook_module._parse_repo_flag("gh pr ready 161 -Rsample-owner/target-repo") == (
|
|
259
|
+
"sample-owner",
|
|
260
|
+
"target-repo",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
229
264
|
def test_parse_repo_flag_absent_returns_none() -> None:
|
|
230
265
|
assert hook_module._parse_repo_flag("gh pr ready 418") is None
|
|
231
266
|
|
|
@@ -249,6 +284,26 @@ def test_ready_segment_clips_at_command_separator() -> None:
|
|
|
249
284
|
)
|
|
250
285
|
|
|
251
286
|
|
|
287
|
+
def test_ready_segment_keeps_repo_flag_on_a_continued_line() -> None:
|
|
288
|
+
segment = hook_module._ready_command_segment(
|
|
289
|
+
"gh pr ready 161 \\\n --repo target-owner/target-repo"
|
|
290
|
+
)
|
|
291
|
+
assert "--repo" in segment
|
|
292
|
+
assert "target-owner/target-repo" in segment
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_continued_repo_flag_binds_the_gate_to_the_named_repo(
|
|
296
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
297
|
+
) -> None:
|
|
298
|
+
identity = _capture_convergence_identity(
|
|
299
|
+
monkeypatch,
|
|
300
|
+
tmp_path,
|
|
301
|
+
"gh pr ready 161 \\\n --repo target-owner/target-repo",
|
|
302
|
+
("cwd-owner", "cwd-repo"),
|
|
303
|
+
)
|
|
304
|
+
assert identity == ("target-owner", "target-repo", 161)
|
|
305
|
+
|
|
306
|
+
|
|
252
307
|
def test_chained_repo_flag_does_not_misbind_the_gate(
|
|
253
308
|
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
254
309
|
) -> None:
|
|
@@ -278,3 +333,96 @@ def test_ready_segment_skips_a_leading_undo_invocation() -> None:
|
|
|
278
333
|
hook_module._ready_command_segment("gh pr ready --undo && gh pr ready 161")
|
|
279
334
|
== "gh pr ready 161"
|
|
280
335
|
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_parse_repo_flag_full_url_form() -> None:
|
|
339
|
+
assert hook_module._parse_repo_flag(
|
|
340
|
+
"gh pr ready 161 --repo https://github.com/other-owner/other-repo"
|
|
341
|
+
) == ("other-owner", "other-repo")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_parse_repo_flag_short_alias_full_url_form() -> None:
|
|
345
|
+
assert hook_module._parse_repo_flag(
|
|
346
|
+
"gh pr ready 161 -R https://github.com/other-owner/other-repo"
|
|
347
|
+
) == ("other-owner", "other-repo")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_parse_repo_flag_ssh_form() -> None:
|
|
351
|
+
assert hook_module._parse_repo_flag(
|
|
352
|
+
"gh pr ready 161 --repo git@github.com:other-owner/other-repo"
|
|
353
|
+
) == ("other-owner", "other-repo")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_parse_repo_flag_ssh_form_strips_git_suffix() -> None:
|
|
357
|
+
assert hook_module._parse_repo_flag(
|
|
358
|
+
"gh pr ready 161 --repo git@github.com:other-owner/other-repo.git"
|
|
359
|
+
) == ("other-owner", "other-repo")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_parse_repo_flag_url_form_strips_git_suffix() -> None:
|
|
363
|
+
assert hook_module._parse_repo_flag(
|
|
364
|
+
"gh pr ready 161 --repo https://github.com/other-owner/other-repo.git"
|
|
365
|
+
) == ("other-owner", "other-repo")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_parse_pr_url_accepts_enterprise_host() -> None:
|
|
369
|
+
assert hook_module._parse_pr_url(
|
|
370
|
+
"gh pr ready https://github.example.com/other-owner/other-repo/pull/161"
|
|
371
|
+
) == ("other-owner", "other-repo", 161)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_background_operator_clips_chained_repo_flag(
|
|
375
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
376
|
+
) -> None:
|
|
377
|
+
identity = _capture_convergence_identity(
|
|
378
|
+
monkeypatch,
|
|
379
|
+
tmp_path,
|
|
380
|
+
"gh pr ready 161 & gh pr comment 999 --repo other-owner/other-repo",
|
|
381
|
+
("cwd-owner", "cwd-repo"),
|
|
382
|
+
)
|
|
383
|
+
assert identity == ("cwd-owner", "cwd-repo", 161)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_trailing_redirect_keeps_earlier_repo_flag(
|
|
387
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
388
|
+
) -> None:
|
|
389
|
+
identity = _capture_convergence_identity(
|
|
390
|
+
monkeypatch,
|
|
391
|
+
tmp_path,
|
|
392
|
+
"gh pr ready 161 --repo other-owner/other-repo 2>&1",
|
|
393
|
+
("cwd-owner", "cwd-repo"),
|
|
394
|
+
)
|
|
395
|
+
assert identity == ("other-owner", "other-repo", 161)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_stderr_redirect_dup_keeps_repo_flag() -> None:
|
|
399
|
+
segment = hook_module._ready_command_segment(
|
|
400
|
+
"gh pr ready 161 >&2 --repo other-owner/other-repo"
|
|
401
|
+
)
|
|
402
|
+
assert hook_module._parse_repo_flag(segment) == ("other-owner", "other-repo")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_stderr_to_stdout_redirect_keeps_repo_flag() -> None:
|
|
406
|
+
segment = hook_module._ready_command_segment(
|
|
407
|
+
"gh pr ready 2>&1 --repo other-owner/other-repo"
|
|
408
|
+
)
|
|
409
|
+
assert hook_module._parse_repo_flag(segment) == ("other-owner", "other-repo")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_append_all_output_redirect_keeps_repo_flag() -> None:
|
|
413
|
+
segment = hook_module._ready_command_segment(
|
|
414
|
+
"gh pr ready 161 &> log --repo other-owner/other-repo"
|
|
415
|
+
)
|
|
416
|
+
assert hook_module._parse_repo_flag(segment) == ("other-owner", "other-repo")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_stderr_redirect_binds_repo_end_to_end(
|
|
420
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
|
421
|
+
) -> None:
|
|
422
|
+
identity = _capture_convergence_identity(
|
|
423
|
+
monkeypatch,
|
|
424
|
+
tmp_path,
|
|
425
|
+
"gh pr ready 161 >&2 --repo other-owner/other-repo",
|
|
426
|
+
("cwd-owner", "cwd-repo"),
|
|
427
|
+
)
|
|
428
|
+
assert identity == ("other-owner", "other-repo", 161)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tests for the destructive-command-blocker deny mode.
|
|
2
|
+
|
|
3
|
+
The sandbox runs under ``--dangerously-skip-permissions``, which auto-resolves
|
|
4
|
+
an ``ask`` decision, so only a hard ``deny`` contains a destructive command.
|
|
5
|
+
Setting ``CLAUDE_DESTRUCTIVE_DENY_MODE`` turns the hook's terminal ``ask`` into
|
|
6
|
+
a ``deny`` so the sandbox can be contained.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SCRIPT_PATH = Path(__file__).parent / "destructive_command_blocker.py"
|
|
18
|
+
DENY_MODE_ENV_VAR = "CLAUDE_DESTRUCTIVE_DENY_MODE"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_hook_with_environment(command: str, extra_environment: dict[str, str]) -> dict:
|
|
22
|
+
child_environment = os.environ.copy()
|
|
23
|
+
child_environment.pop(DENY_MODE_ENV_VAR, None)
|
|
24
|
+
child_environment.update(extra_environment)
|
|
25
|
+
completed_process = subprocess.run(
|
|
26
|
+
[sys.executable, str(SCRIPT_PATH)],
|
|
27
|
+
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}),
|
|
28
|
+
text=True,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
check=False,
|
|
31
|
+
env=child_environment,
|
|
32
|
+
)
|
|
33
|
+
return json.loads(completed_process.stdout)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_rm_rf_denies_when_deny_mode_env_is_set() -> None:
|
|
37
|
+
response = _run_hook_with_environment("rm -rf /var/log/myapp", {DENY_MODE_ENV_VAR: "1"})
|
|
38
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
39
|
+
assert "rm -rf" in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_rm_rf_asks_when_deny_mode_env_is_absent() -> None:
|
|
43
|
+
response = _run_hook_with_environment("rm -rf /var/log/myapp", {})
|
|
44
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
45
|
+
assert "rm -rf" in response["hookSpecificOutput"]["permissionDecisionReason"]
|