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,233 @@
|
|
|
1
|
+
"""Read and write the per-work-tree code-review stamp files.
|
|
2
|
+
|
|
3
|
+
A stamp records that a clean ``/code-review`` pass ran against an exact branch
|
|
4
|
+
surface at a given effort. Each work tree keeps one JSON file under
|
|
5
|
+
``~/.claude/code-review-stamps/`` mapping an effort token to the surface hash it
|
|
6
|
+
was earned against, so a later push or pull-request gate can ask whether a
|
|
7
|
+
clean review at the needed effort already covers the live surface. This module
|
|
8
|
+
only reads and writes those files; it runs no review.
|
|
9
|
+
|
|
10
|
+
::
|
|
11
|
+
|
|
12
|
+
record_clean_stamp(root, hash_a, "low")
|
|
13
|
+
stamp_covers_surface(root, hash_a, "low") -> True (recorded, matching)
|
|
14
|
+
stamp_covers_surface(root, hash_a, "xhigh") -> False (low is below xhigh)
|
|
15
|
+
stamp_covers_surface(root, hash_b, "low") -> False (surface hash differs)
|
|
16
|
+
|
|
17
|
+
Coverage fails closed: a missing, unreadable, or malformed stamp file, a hash
|
|
18
|
+
that does not match, and an effort below the threshold all read as not covered.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from code_review_enforcement_config_bootstrap import (
|
|
32
|
+
register_code_review_enforcement_constants,
|
|
33
|
+
)
|
|
34
|
+
from verified_commit_config_bootstrap import register_verified_commit_constants
|
|
35
|
+
except ModuleNotFoundError:
|
|
36
|
+
if _blocking_directory not in sys.path:
|
|
37
|
+
sys.path.insert(0, _blocking_directory)
|
|
38
|
+
from code_review_enforcement_config_bootstrap import (
|
|
39
|
+
register_code_review_enforcement_constants,
|
|
40
|
+
)
|
|
41
|
+
from verified_commit_config_bootstrap import register_verified_commit_constants
|
|
42
|
+
|
|
43
|
+
register_code_review_enforcement_constants()
|
|
44
|
+
register_verified_commit_constants()
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from config.code_review_enforcement_constants import (
|
|
48
|
+
STAMP_DIRECTORY_NAME,
|
|
49
|
+
STAMP_KEY_EFFORT,
|
|
50
|
+
STAMP_KEY_MANIFEST_SHA256,
|
|
51
|
+
STAMP_KEY_RECORDED_AT_EPOCH,
|
|
52
|
+
effort_meets_threshold,
|
|
53
|
+
)
|
|
54
|
+
from config.verified_commit_constants import (
|
|
55
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
56
|
+
VERDICT_JSON_INDENT,
|
|
57
|
+
)
|
|
58
|
+
from verification_verdict_store import (
|
|
59
|
+
branch_surface_manifest,
|
|
60
|
+
empty_surface_hash,
|
|
61
|
+
manifest_sha256,
|
|
62
|
+
resolve_merge_base,
|
|
63
|
+
resolve_repo_root,
|
|
64
|
+
root_key_for_repo,
|
|
65
|
+
)
|
|
66
|
+
except ModuleNotFoundError:
|
|
67
|
+
if _blocking_directory not in sys.path:
|
|
68
|
+
sys.path.insert(0, _blocking_directory)
|
|
69
|
+
from config.code_review_enforcement_constants import (
|
|
70
|
+
STAMP_DIRECTORY_NAME,
|
|
71
|
+
STAMP_KEY_EFFORT,
|
|
72
|
+
STAMP_KEY_MANIFEST_SHA256,
|
|
73
|
+
STAMP_KEY_RECORDED_AT_EPOCH,
|
|
74
|
+
effort_meets_threshold,
|
|
75
|
+
)
|
|
76
|
+
from config.verified_commit_constants import (
|
|
77
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
78
|
+
VERDICT_JSON_INDENT,
|
|
79
|
+
)
|
|
80
|
+
from verification_verdict_store import (
|
|
81
|
+
branch_surface_manifest,
|
|
82
|
+
empty_surface_hash,
|
|
83
|
+
manifest_sha256,
|
|
84
|
+
resolve_merge_base,
|
|
85
|
+
resolve_repo_root,
|
|
86
|
+
root_key_for_repo,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def stamp_directory() -> Path:
|
|
91
|
+
"""Return the shared directory holding every work tree's stamp file.
|
|
92
|
+
|
|
93
|
+
Stamps live outside any repository (under the user's Claude home) so no repo
|
|
94
|
+
accumulates untracked stamp files; every work tree's stamp shares this one
|
|
95
|
+
directory, distinguished by file name.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The stamp directory under the user's Claude home.
|
|
99
|
+
"""
|
|
100
|
+
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / STAMP_DIRECTORY_NAME
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def stamp_path_for_repo(repo_root: str) -> Path:
|
|
104
|
+
"""Derive the stamp file path for a repository work tree.
|
|
105
|
+
|
|
106
|
+
Keyed by the shared ``root_key_for_repo`` derivation, so every work tree
|
|
107
|
+
gets its own stamp file and a subdirectory of a work tree resolves to the
|
|
108
|
+
same key as its root.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
repo_root: The repository top-level directory.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The stamp file path for this work tree.
|
|
115
|
+
"""
|
|
116
|
+
return stamp_directory() / f"{root_key_for_repo(repo_root)}.json"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_stamps_by_effort(stamp_file: Path) -> dict[str, object]:
|
|
120
|
+
"""Read the per-effort stamp records for a work tree, or an empty map.
|
|
121
|
+
|
|
122
|
+
::
|
|
123
|
+
|
|
124
|
+
<root-key>.json holds {"low": {...}, "xhigh": {...}} -> that map
|
|
125
|
+
file missing / unreadable / not a JSON object -> {}
|
|
126
|
+
|
|
127
|
+
A missing, unreadable, or malformed stamp file yields an empty map, so a
|
|
128
|
+
caller reading it sees no coverage and fails closed.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
stamp_file: The per-work-tree stamp file path.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The mapping of effort token to its stored record, or an empty map when
|
|
135
|
+
the file is absent, unreadable, or not a JSON object.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
parsed_stamps = json.loads(stamp_file.read_text(encoding="utf-8"))
|
|
139
|
+
except (OSError, json.JSONDecodeError):
|
|
140
|
+
return {}
|
|
141
|
+
if not isinstance(parsed_stamps, dict):
|
|
142
|
+
return {}
|
|
143
|
+
return parsed_stamps
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def record_clean_stamp(repo_root: str, bound_manifest_sha256: str, effort: str) -> Path:
|
|
147
|
+
"""Write one effort's clean-review stamp for a work tree.
|
|
148
|
+
|
|
149
|
+
Reads any existing per-effort records, sets this effort's entry to the given
|
|
150
|
+
surface hash and the current epoch, and writes the map back. Recording a
|
|
151
|
+
second effort keeps the first; recording the same effort again overwrites
|
|
152
|
+
its bound hash.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
repo_root: The repository top-level directory.
|
|
156
|
+
bound_manifest_sha256: Hash of the branch surface the clean review covered.
|
|
157
|
+
effort: The effort token the clean review ran at.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The stamp file path the record was written to.
|
|
161
|
+
"""
|
|
162
|
+
stamp_file = stamp_path_for_repo(repo_root)
|
|
163
|
+
stamp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
all_stamps_by_effort = _read_stamps_by_effort(stamp_file)
|
|
165
|
+
all_stamps_by_effort[effort] = {
|
|
166
|
+
STAMP_KEY_EFFORT: effort,
|
|
167
|
+
STAMP_KEY_MANIFEST_SHA256: bound_manifest_sha256,
|
|
168
|
+
STAMP_KEY_RECORDED_AT_EPOCH: int(time.time()),
|
|
169
|
+
}
|
|
170
|
+
stamp_file.write_text(
|
|
171
|
+
json.dumps(all_stamps_by_effort, indent=VERDICT_JSON_INDENT), encoding="utf-8"
|
|
172
|
+
)
|
|
173
|
+
return stamp_file
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def stamp_covers_surface(repo_root: str, live_manifest_sha256: str, required_effort: str) -> bool:
|
|
177
|
+
"""Decide whether a clean stamp covers the live surface at the needed effort.
|
|
178
|
+
|
|
179
|
+
A stored entry covers the surface when its bound hash equals the live hash
|
|
180
|
+
exactly and its effort ranks at or above the required effort. Any missing,
|
|
181
|
+
unreadable, or malformed stamp reads as not covered.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
repo_root: The repository top-level directory.
|
|
185
|
+
live_manifest_sha256: Hash of the live branch surface the gate checks.
|
|
186
|
+
required_effort: The effort the gate demands for the action.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
True as soon as one stored entry matches the live hash and meets the
|
|
190
|
+
required effort; False when none match.
|
|
191
|
+
"""
|
|
192
|
+
all_stamps_by_effort = _read_stamps_by_effort(stamp_path_for_repo(repo_root))
|
|
193
|
+
for each_stamp_record in all_stamps_by_effort.values():
|
|
194
|
+
if not isinstance(each_stamp_record, dict):
|
|
195
|
+
continue
|
|
196
|
+
if each_stamp_record.get(STAMP_KEY_MANIFEST_SHA256) != live_manifest_sha256:
|
|
197
|
+
continue
|
|
198
|
+
stored_effort = each_stamp_record.get(STAMP_KEY_EFFORT)
|
|
199
|
+
if not isinstance(stored_effort, str):
|
|
200
|
+
continue
|
|
201
|
+
if effort_meets_threshold(stored_effort, required_effort):
|
|
202
|
+
return True
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def live_surface_hash(work_tree_directory: str) -> str | None:
|
|
207
|
+
"""Compute the live branch-surface manifest hash for a work tree.
|
|
208
|
+
|
|
209
|
+
Resolves the work tree's repo root and merge base, then hashes the current
|
|
210
|
+
change surface — every changed path and untracked file bound by its content
|
|
211
|
+
digest — so the hash tracks the exact bytes under review.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
work_tree_directory: A directory inside the work tree to hash.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
The surface manifest hash, or None when the directory is outside a work
|
|
218
|
+
tree, the merge base or manifest cannot be resolved, or the change
|
|
219
|
+
surface is empty (HEAD equals the merge base, so nothing is under review).
|
|
220
|
+
"""
|
|
221
|
+
repo_root = resolve_repo_root(work_tree_directory)
|
|
222
|
+
if repo_root is None:
|
|
223
|
+
return None
|
|
224
|
+
merge_base_sha = resolve_merge_base(repo_root)
|
|
225
|
+
if merge_base_sha is None:
|
|
226
|
+
return None
|
|
227
|
+
surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
|
|
228
|
+
if surface_manifest_text is None:
|
|
229
|
+
return None
|
|
230
|
+
surface_hash = manifest_sha256(surface_manifest_text)
|
|
231
|
+
if surface_hash == empty_surface_hash():
|
|
232
|
+
return None
|
|
233
|
+
return surface_hash
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Concern modules of the code-review stamp write blocker, wired by the entry hook.
|
|
2
|
+
|
|
3
|
+
The entry hook ``code_review_stamp_directory_write_blocker.py`` imports two
|
|
4
|
+
public matchers from this package to keep its own file short: a split
|
|
5
|
+
directory-change matcher and an obfuscated-path-write matcher. Each mirrors the
|
|
6
|
+
sibling verdict-directory guard's logic against the code-review stamp segments.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Put the blocking and hooks directories on sys.path for the parts tests.
|
|
2
|
+
|
|
3
|
+
The concern modules import their shared constants as ``from config...`` against
|
|
4
|
+
the sibling ``blocking/config/`` package, so the tests need the blocking
|
|
5
|
+
directory (which holds that package) and the hooks directory on the path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_blocking_directory = str(Path(__file__).resolve().parents[1])
|
|
12
|
+
_hooks_directory = str(Path(__file__).resolve().parents[2])
|
|
13
|
+
for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
|
|
14
|
+
if each_bootstrap_directory not in sys.path:
|
|
15
|
+
sys.path.insert(0, each_bootstrap_directory)
|
package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Decide whether a command builds a stamp path with obfuscation and writes.
|
|
2
|
+
|
|
3
|
+
A path-obfuscation forge hides ``.claude`` or ``code-review-stamps`` inside hex
|
|
4
|
+
digits, a base64 token, a comma-separated character-code sequence, or a run of
|
|
5
|
+
``+``-joined ``chr(<int>)`` calls, then pairs the assembled path with a
|
|
6
|
+
non-redirect file write (``open(``, ``write_text``, ``Out-File``,
|
|
7
|
+
``Set-Content``, ``Add-Content``, ``tee``). The literal path matchers miss such
|
|
8
|
+
a path because it carries no plain ``code-review-stamps`` substring, so this
|
|
9
|
+
module decodes each embedded token and looks for a stamp-path segment either in
|
|
10
|
+
the decoded text or inside the write call's argument region. A bare ``>``
|
|
11
|
+
redirect to the stamp path is caught by the literal-path matchers, so a command
|
|
12
|
+
whose only write is a redirect is left alone here.
|
|
13
|
+
|
|
14
|
+
The entry hook registers the constant packages before importing this module, so
|
|
15
|
+
the ``from config...`` imports below resolve against the sibling ``config/``
|
|
16
|
+
package without a bootstrap call here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import binascii
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
from config.code_review_enforcement_constants import (
|
|
26
|
+
ALL_STAMP_PATH_SEGMENT_BODIES,
|
|
27
|
+
ALL_STAMP_PATH_SEGMENT_NAMES,
|
|
28
|
+
)
|
|
29
|
+
from config.verified_commit_constants import (
|
|
30
|
+
BASE64_TOKEN_PATTERN,
|
|
31
|
+
CHARACTER_CODE_SEQUENCE_PATTERN,
|
|
32
|
+
CHR_CALL_CHAIN_PATTERN,
|
|
33
|
+
CHR_CALL_CODE_PATTERN,
|
|
34
|
+
HEX_DIGITS_PER_BYTE,
|
|
35
|
+
HEX_TOKEN_PATTERN,
|
|
36
|
+
NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN,
|
|
37
|
+
PATH_OBFUSCATION_PRIMITIVE_PATTERN,
|
|
38
|
+
WRITE_CALL_REGION_PATTERN,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_ASCII_ENCODING = "ascii"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _decode_hex_token(hex_token: str) -> str:
|
|
45
|
+
"""Decode a hex-digit token to text, returning empty text on failure.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
hex_token: A run of hex digits taken from the command.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The decoded ASCII text, or empty text when the token is odd-length,
|
|
52
|
+
not valid hex, or not decodable as ASCII.
|
|
53
|
+
"""
|
|
54
|
+
odd_hex_token_remainder = 1
|
|
55
|
+
if len(hex_token) % HEX_DIGITS_PER_BYTE == odd_hex_token_remainder:
|
|
56
|
+
return ""
|
|
57
|
+
try:
|
|
58
|
+
return bytes.fromhex(hex_token).decode(_ASCII_ENCODING)
|
|
59
|
+
except (ValueError, binascii.Error):
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _decode_base64_token(base64_token: str) -> str:
|
|
64
|
+
"""Decode a base64 token to text, returning empty text on failure.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
base64_token: A base64-shaped token taken from the command.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The decoded ASCII text, or empty text when the token is not valid
|
|
71
|
+
base64 or not decodable as ASCII.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
return base64.b64decode(base64_token, validate=True).decode(_ASCII_ENCODING)
|
|
75
|
+
except (ValueError, binascii.Error):
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _decode_character_code_sequence(code_sequence: str) -> str:
|
|
80
|
+
"""Decode a comma-separated character-code sequence to text.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
code_sequence: A comma-separated run of decimal character codes, each
|
|
84
|
+
within the three-digit range the source pattern admits.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The decoded text.
|
|
88
|
+
"""
|
|
89
|
+
character_code_separator = ","
|
|
90
|
+
decoded_characters: list[str] = []
|
|
91
|
+
for each_code in code_sequence.split(character_code_separator):
|
|
92
|
+
code_point = int(each_code.strip())
|
|
93
|
+
decoded_characters.append(chr(code_point))
|
|
94
|
+
return "".join(decoded_characters)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _decode_chr_call_chain(chr_chain: str) -> str:
|
|
98
|
+
"""Decode a run of ``+``-joined ``chr(<int>)`` calls to text.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
chr_chain: A run of two or more ``+``-joined ``chr(<int>)`` calls taken
|
|
102
|
+
from the command, each code within the three-digit source range.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The decoded text.
|
|
106
|
+
"""
|
|
107
|
+
decoded_characters: list[str] = []
|
|
108
|
+
for each_code in re.findall(CHR_CALL_CODE_PATTERN, chr_chain, re.IGNORECASE):
|
|
109
|
+
decoded_characters.append(chr(int(each_code)))
|
|
110
|
+
return "".join(decoded_characters)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _append_when_present(all_decoded_texts: list[str], decoded_text: str) -> None:
|
|
114
|
+
"""Append the lowercase decoded text when it is non-empty.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
all_decoded_texts: The running list of decoded texts to extend.
|
|
118
|
+
decoded_text: One token's decoded text, empty when the token failed.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
None. The list is extended in place when the text is non-empty.
|
|
122
|
+
"""
|
|
123
|
+
if decoded_text:
|
|
124
|
+
all_decoded_texts.append(decoded_text.lower())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _decoded_texts_from_command(command_text: str) -> list[str]:
|
|
128
|
+
"""Decode every hex, base64, character-code, and chr-chain token a command embeds.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
command_text: The raw command string from the tool payload.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The lowercase decoded text for every token that decodes cleanly;
|
|
135
|
+
tokens that fail to decode or carry an out-of-range code are skipped.
|
|
136
|
+
"""
|
|
137
|
+
all_decoded_texts: list[str] = []
|
|
138
|
+
for each_hex_token in re.findall(HEX_TOKEN_PATTERN, command_text, re.IGNORECASE):
|
|
139
|
+
_append_when_present(all_decoded_texts, _decode_hex_token(each_hex_token))
|
|
140
|
+
for each_base64_token in re.findall(BASE64_TOKEN_PATTERN, command_text):
|
|
141
|
+
_append_when_present(all_decoded_texts, _decode_base64_token(each_base64_token))
|
|
142
|
+
for each_code_sequence in re.findall(CHARACTER_CODE_SEQUENCE_PATTERN, command_text):
|
|
143
|
+
_append_when_present(all_decoded_texts, _decode_character_code_sequence(each_code_sequence))
|
|
144
|
+
for each_chr_chain in re.findall(CHR_CALL_CHAIN_PATTERN, command_text, re.IGNORECASE):
|
|
145
|
+
_append_when_present(all_decoded_texts, _decode_chr_call_chain(each_chr_chain))
|
|
146
|
+
return all_decoded_texts
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _decoded_token_references_stamp_segment(command_text: str) -> bool:
|
|
150
|
+
"""Decide whether a decodable token in a command hides a stamp-path segment.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
command_text: The raw command string from the tool payload.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True when a decodable token decodes to a stamp-path segment body
|
|
157
|
+
(``claude`` or ``code-review-stamps``).
|
|
158
|
+
"""
|
|
159
|
+
all_decoded_texts = _decoded_texts_from_command(command_text)
|
|
160
|
+
return any(
|
|
161
|
+
each_segment_body in each_decoded_text
|
|
162
|
+
for each_decoded_text in all_decoded_texts
|
|
163
|
+
for each_segment_body in ALL_STAMP_PATH_SEGMENT_BODIES
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _write_call_region_names_stamp_segment(command_text: str) -> bool:
|
|
168
|
+
"""Decide whether a write call's argument region names a stamp-path segment.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
command_text: The raw command string from the tool payload.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
True when a stamp-path segment name appears inside a non-redirect write
|
|
175
|
+
call's argument region.
|
|
176
|
+
"""
|
|
177
|
+
write_call_region_pattern = re.compile(WRITE_CALL_REGION_PATTERN, re.IGNORECASE)
|
|
178
|
+
for each_region_match in write_call_region_pattern.finditer(command_text):
|
|
179
|
+
lowercased_region = each_region_match.group(0).lower()
|
|
180
|
+
if any(each_name in lowercased_region for each_name in ALL_STAMP_PATH_SEGMENT_NAMES):
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def references_obfuscated_stamp_path(command_text: str) -> bool:
|
|
186
|
+
"""Decide whether a command pairs path obfuscation with a stamp-path write.
|
|
187
|
+
|
|
188
|
+
::
|
|
189
|
+
|
|
190
|
+
python -c "open(bytes.fromhex('...code-review-stamps...'),'w')" -> True
|
|
191
|
+
open(bytes.fromhex('2f746d702f78'),'w') -> False
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
command_text: The raw command string from the tool payload.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
True when the command holds a path-obfuscation primitive, a
|
|
198
|
+
non-redirect file write, and a stamp-path segment reachable by that
|
|
199
|
+
write — either a decoded token anywhere in the command or a plain-text
|
|
200
|
+
segment inside the write call's argument region.
|
|
201
|
+
"""
|
|
202
|
+
has_obfuscation_primitive = (
|
|
203
|
+
re.search(PATH_OBFUSCATION_PRIMITIVE_PATTERN, command_text) is not None
|
|
204
|
+
)
|
|
205
|
+
has_non_redirect_write = (
|
|
206
|
+
re.search(NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN, command_text) is not None
|
|
207
|
+
)
|
|
208
|
+
if not (has_obfuscation_primitive and has_non_redirect_write):
|
|
209
|
+
return False
|
|
210
|
+
if _decoded_token_references_stamp_segment(command_text):
|
|
211
|
+
return True
|
|
212
|
+
return _write_call_region_names_stamp_segment(command_text)
|
package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Decide whether a split directory change reaches the stamp directory then runs a command.
|
|
2
|
+
|
|
3
|
+
A change into the Claude home (``cd ~/.claude``) followed by a change into a
|
|
4
|
+
relative ``code-review-stamps`` directory (``cd code-review-stamps``) lands in
|
|
5
|
+
the stamp directory without ever naming the two segments adjacently, so the
|
|
6
|
+
absolute-path matcher and the single-step ``cd ~/.claude/code-review-stamps``
|
|
7
|
+
matcher both miss it. The trust contract denies every shell command that
|
|
8
|
+
reaches the stamp directory, so any command after the second change — a
|
|
9
|
+
redirect, ``cp``, ``mv``, ``tee``, ``install``, or a ``python -c`` write — is a
|
|
10
|
+
forge vector. An unrelated second change (``cd hooks``) lands elsewhere and
|
|
11
|
+
passes.
|
|
12
|
+
|
|
13
|
+
The entry hook registers the constant packages before importing this module, so
|
|
14
|
+
the ``from config...`` imports below resolve against the sibling ``config/``
|
|
15
|
+
package without a bootstrap call here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from config.code_review_enforcement_constants import STAMP_DIRECTORY_CHANGE_TARGET_PATTERN
|
|
23
|
+
from config.verified_commit_constants import (
|
|
24
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
25
|
+
CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
|
|
26
|
+
COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN,
|
|
27
|
+
DIRECTORY_CHANGE_OPTION_PREFIX_PATTERN,
|
|
28
|
+
DIRECTORY_CHANGE_OPTION_TERMINATOR,
|
|
29
|
+
DIRECTORY_CHANGE_PATH_OPTIONS,
|
|
30
|
+
DIRECTORY_CHANGE_PATTERN_PREFIX,
|
|
31
|
+
DIRECTORY_CHANGE_PATTERN_SUFFIX,
|
|
32
|
+
DIRECTORY_CHANGE_TARGET_PATTERN,
|
|
33
|
+
DIRECTORY_CHANGE_VERBS,
|
|
34
|
+
)
|
|
35
|
+
from config.verified_commit_gate_output_constants import REGEX_ALTERNATION_SEPARATOR
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _directory_change_verbs_pattern() -> str:
|
|
39
|
+
"""Build the alternation of directory-change verbs for a change matcher.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The regex alternation of escaped directory-change verbs in sorted order.
|
|
43
|
+
"""
|
|
44
|
+
return REGEX_ALTERNATION_SEPARATOR.join(
|
|
45
|
+
re.escape(each_verb) for each_verb in sorted(DIRECTORY_CHANGE_VERBS)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _directory_change_option_prefix_pattern() -> str:
|
|
50
|
+
"""Build the optional path-option prefix that may precede a change target.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The regex matching zero or more leading path-option or terminator
|
|
54
|
+
tokens, ready to sit between the change suffix and target patterns.
|
|
55
|
+
"""
|
|
56
|
+
option_alternation = REGEX_ALTERNATION_SEPARATOR.join(
|
|
57
|
+
re.escape(each_option)
|
|
58
|
+
for each_option in sorted(
|
|
59
|
+
DIRECTORY_CHANGE_PATH_OPTIONS | {DIRECTORY_CHANGE_OPTION_TERMINATOR}
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
return DIRECTORY_CHANGE_OPTION_PREFIX_PATTERN % option_alternation
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def directory_change_prefix() -> str:
|
|
66
|
+
"""Build the change-verb prefix shared by the directory-change matchers.
|
|
67
|
+
|
|
68
|
+
The stamp-directory write-blocker reuses this same prefix, so a single
|
|
69
|
+
builder feeds both this module's split-change matchers and the blocker's
|
|
70
|
+
single-step change matchers, and the two never drift.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The regex prefix matching a directory-change verb and any leading
|
|
74
|
+
path-option tokens, ready for a target pattern to follow.
|
|
75
|
+
"""
|
|
76
|
+
return (
|
|
77
|
+
DIRECTORY_CHANGE_PATTERN_PREFIX
|
|
78
|
+
+ _directory_change_verbs_pattern()
|
|
79
|
+
+ DIRECTORY_CHANGE_PATTERN_SUFFIX
|
|
80
|
+
+ _directory_change_option_prefix_pattern()
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _change_into_claude_pattern() -> re.Pattern[str]:
|
|
85
|
+
"""Build the compiled matcher for a directory change into the Claude home.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The compiled, case-insensitive pattern matching a change verb into any
|
|
89
|
+
path ending in ``.claude``.
|
|
90
|
+
"""
|
|
91
|
+
return re.compile(
|
|
92
|
+
directory_change_prefix()
|
|
93
|
+
+ DIRECTORY_CHANGE_TARGET_PATTERN
|
|
94
|
+
+ re.escape(CLAUDE_HOME_DIRECTORY_NAME)
|
|
95
|
+
+ CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _change_into_stamp_pattern() -> re.Pattern[str]:
|
|
101
|
+
"""Build the compiled matcher for a directory change into the stamp directory.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The compiled, case-insensitive pattern matching a change verb into a
|
|
105
|
+
relative ``code-review-stamps`` directory.
|
|
106
|
+
"""
|
|
107
|
+
return re.compile(
|
|
108
|
+
directory_change_prefix() + STAMP_DIRECTORY_CHANGE_TARGET_PATTERN, re.IGNORECASE
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def changes_through_split_directory_into_stamp(command_text: str) -> bool:
|
|
113
|
+
"""Decide whether a split change reaches the stamp directory then runs a command.
|
|
114
|
+
|
|
115
|
+
::
|
|
116
|
+
|
|
117
|
+
cd ~/.claude && cd code-review-stamps && echo x > f.json -> True
|
|
118
|
+
cd ~/.claude && cd hooks && echo x > f.json -> False
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
command_text: The raw command string from the tool payload.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True when a change into the Claude home precedes a change into a
|
|
125
|
+
relative stamp directory that is itself followed by any further command.
|
|
126
|
+
"""
|
|
127
|
+
change_into_claude_match = _change_into_claude_pattern().search(command_text)
|
|
128
|
+
if change_into_claude_match is None:
|
|
129
|
+
return False
|
|
130
|
+
change_into_stamp_match = _change_into_stamp_pattern().search(
|
|
131
|
+
command_text, change_into_claude_match.end()
|
|
132
|
+
)
|
|
133
|
+
if change_into_stamp_match is None:
|
|
134
|
+
return False
|
|
135
|
+
command_after_change_pattern = re.compile(COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN)
|
|
136
|
+
return (
|
|
137
|
+
command_after_change_pattern.search(command_text, change_into_stamp_match.end()) is not None
|
|
138
|
+
)
|
package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Behavioral tests for the obfuscated-stamp-path matcher.
|
|
2
|
+
|
|
3
|
+
The matcher blocks a command that assembles a stamp path from hex, base64, or
|
|
4
|
+
``chr(<int>)`` codes and pairs it with a non-redirect file write, while a
|
|
5
|
+
decode-to-other-path one-liner passes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from code_review_stamp_write_blocker_parts.obfuscated_stamp_path_reference import (
|
|
11
|
+
references_obfuscated_stamp_path,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
STAMP_PATH_TEXT = "/.claude/code-review-stamps/a.json"
|
|
15
|
+
BENIGN_PATH_TEXT = "/tmp/x"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _hex_forge_command(path_text: str) -> str:
|
|
19
|
+
return f"python -c \"open(bytes.fromhex('{path_text.encode().hex()}'),'w').write('x')\""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _chr_chain_forge_command(path_text: str) -> str:
|
|
23
|
+
chr_chain = "+".join(f"chr({ord(each_character)})" for each_character in path_text)
|
|
24
|
+
return f"python -c \"open({chr_chain},'w').write('x')\""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _character_code_forge_command(path_text: str) -> str:
|
|
28
|
+
comma_separated_codes = ",".join(str(ord(each_character)) for each_character in path_text)
|
|
29
|
+
return f"python -c \"open(bytes([{comma_separated_codes}]),'w').write('x')\""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_hex_assembled_stamp_path_write_is_blocked() -> None:
|
|
33
|
+
assert references_obfuscated_stamp_path(_hex_forge_command(STAMP_PATH_TEXT))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_character_code_assembled_stamp_path_write_is_blocked() -> None:
|
|
37
|
+
assert references_obfuscated_stamp_path(_character_code_forge_command(STAMP_PATH_TEXT))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_chr_chain_assembled_stamp_path_write_is_blocked() -> None:
|
|
41
|
+
assert references_obfuscated_stamp_path(_chr_chain_forge_command(STAMP_PATH_TEXT))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_decode_to_other_path_write_is_not_blocked() -> None:
|
|
45
|
+
assert not references_obfuscated_stamp_path(_hex_forge_command(BENIGN_PATH_TEXT))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_redirect_only_stamp_reference_is_left_to_literal_matchers() -> None:
|
|
49
|
+
assert not references_obfuscated_stamp_path("echo x > ~/.claude/code-review-stamps/a.json")
|