claude-dev-env 2.4.0 → 2.7.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 +26 -59
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- 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/code_rules_gate_constants.py +202 -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 -2
- 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 +36 -8
- 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/test_agent_frontmatter.py +67 -18
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
- package/bin/CLAUDE.md +68 -5
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- 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/codex-capability-map.json +13 -0
- package/commands/implement.md +1 -1
- package/commands/right-size.md +1 -1
- package/docs/CLAUDE.md +1 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/host-pool-health-monitor.md +102 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/CLAUDE.md +4 -2
- package/docs/references/advisor-tool.md +13 -0
- package/docs/references/code-review-enforcement.md +35 -0
- package/docs/references/team-advisor-skill.md +14 -0
- package/hooks/blocking/CLAUDE.md +4 -0
- package/hooks/blocking/code_review_pr_create_gate.py +7 -3
- package/hooks/blocking/code_review_push_gate.py +9 -4
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
- package/hooks/blocking/config/__init__.py +5 -5
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
- package/hooks/blocking/config/verified_commit_constants.py +160 -159
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
- package/hooks/blocking/test_code_review_push_gate.py +16 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
- package/hooks/blocking/test_verification_verdict_store.py +54 -0
- 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/unscoped_search_blocker.py +391 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
- package/hooks/blocking/verification_verdict_store.py +1039 -1036
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
- package/hooks/blocking/verifier_verdict_minter.py +280 -280
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +257 -23
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +10 -12
- package/hooks/hooks_constants/CLAUDE.md +7 -2
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
- package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- 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 +1 -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/ruff_integration.py +49 -5
- package/hooks/validators/run_all_validators.py +206 -9
- package/hooks/validators/test_directory_exemption_constants.py +185 -0
- package/hooks/validators/test_python_antipattern_checks.py +110 -5
- package/hooks/validators/test_ruff_integration.py +92 -1
- package/hooks/validators/test_run_all_validators.py +115 -68
- package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
- package/package.json +13 -3
- package/rules/CLAUDE.md +17 -22
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +53 -44
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Capture-PoolHealth.ps1 +410 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/_code_review_test_support.py +404 -0
- package/scripts/claude_chain_runner.py +141 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/conftest.py +16 -1
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/resolve_worker_spawn.py +626 -626
- package/scripts/spawn_grok_batch.py +672 -672
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_claude_chain_runner.py +131 -0
- package/scripts/test_invoke_code_review.py +85 -908
- 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_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/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/CLAUDE.md +2 -0
- package/skills/auditing-claude-config/SKILL.md +114 -114
- package/skills/autoconverge/SKILL.md +427 -427
- package/skills/autoconverge/reference/convergence.md +24 -3
- package/skills/autoconverge/workflow/CLAUDE.md +1 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
- package/skills/autoconverge/workflow/converge.mjs +168 -1
- package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -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/bugteam/reference/copilot-gap-analysis.md +1 -1
- 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 +72 -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 +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- 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 +177 -22
- 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 +129 -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/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/prototype/workflows/promotion.md +1 -1
- package/skills/release-notes-html/SKILL.md +164 -0
- package/skills/session-log/SKILL.md +1 -1
- 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/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/tdd.md +0 -7
- package/skills/closeout/reference/issue-body-templates.md +0 -108
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
"""Unit tests for the verified-commit-message-accuracy PreToolUse hook."""
|
|
2
|
-
|
|
3
|
-
import importlib.util
|
|
4
|
-
import pathlib
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
8
|
-
if str(_HOOK_DIR) not in sys.path:
|
|
9
|
-
sys.path.insert(0, str(_HOOK_DIR))
|
|
10
|
-
|
|
11
|
-
hook_spec = importlib.util.spec_from_file_location(
|
|
12
|
-
"verified_commit_message_accuracy_blocker",
|
|
13
|
-
_HOOK_DIR / "verified_commit_message_accuracy_blocker.py",
|
|
14
|
-
)
|
|
15
|
-
assert hook_spec is not None
|
|
16
|
-
assert hook_spec.loader is not None
|
|
17
|
-
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
18
|
-
hook_spec.loader.exec_module(hook_module)
|
|
19
|
-
is_guarded_file = hook_module.is_guarded_file
|
|
20
|
-
claims_blanket_comment_exemption = hook_module.claims_blanket_comment_exemption
|
|
21
|
-
extract_written_text = hook_module.extract_written_text
|
|
22
|
-
build_corrective_message = hook_module.build_corrective_message
|
|
23
|
-
|
|
24
|
-
OFFENDING_MESSAGE = (
|
|
25
|
-
"CORRECTIVE_MESSAGE = (\n"
|
|
26
|
-
' "BLOCKED: [VERIFIED_COMMIT_GATE] This branch surface has no passing "\n'
|
|
27
|
-
' "verification verdict. Spawn the code-verifier agent (Agent tool, "\n'
|
|
28
|
-
" \"subagent_type 'code-verifier') with the task texts, the diff scope, \"\n"
|
|
29
|
-
' "and recorded baselines; when it finishes with a clean verdict the "\n'
|
|
30
|
-
' "SubagentStop hook mints the verdict and this command will pass. Any "\n'
|
|
31
|
-
' "file change after verification invalidates the verdict, so verify "\n'
|
|
32
|
-
' "last. Docs-, docstring-, comment-, and test-only surfaces are exempt "\n'
|
|
33
|
-
' "automatically."\n'
|
|
34
|
-
")\n"
|
|
35
|
-
)
|
|
36
|
-
ACCURATE_DOCS_EXEMPTION_MENTIONING_COMMENTS = (
|
|
37
|
-
"Comments inside Python files are stripped; docs are exempt "
|
|
38
|
-
"automatically by extension."
|
|
39
|
-
)
|
|
40
|
-
ACCURATE_MESSAGE = (
|
|
41
|
-
"CORRECTIVE_MESSAGE = (\n"
|
|
42
|
-
' "BLOCKED: [VERIFIED_COMMIT_GATE] ... Docs and images are exempt by "\n'
|
|
43
|
-
' "extension, and Python files whose docstring- and comment-stripped AST "\n'
|
|
44
|
-
' "is unchanged."\n'
|
|
45
|
-
")\n"
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_constants_file_is_guarded() -> None:
|
|
50
|
-
assert is_guarded_file(
|
|
51
|
-
"/repo/.claude/hooks/blocking/config/verified_commit_constants.py"
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def test_unrelated_file_is_not_guarded() -> None:
|
|
56
|
-
assert not is_guarded_file("/repo/.claude/hooks/blocking/gh_body_arg_blocker.py")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_blanket_comment_exemption_claim_is_detected() -> None:
|
|
60
|
-
assert claims_blanket_comment_exemption(OFFENDING_MESSAGE)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def test_blanket_claim_detected_regardless_of_leading_words() -> None:
|
|
64
|
-
assert claims_blanket_comment_exemption(
|
|
65
|
-
"Comment-only surfaces are exempt automatically."
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def test_accurate_exemption_wording_passes() -> None:
|
|
70
|
-
assert not claims_blanket_comment_exemption(ACCURATE_MESSAGE)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def test_accurate_docs_exemption_mentioning_comments_passes() -> None:
|
|
74
|
-
assert not claims_blanket_comment_exemption(
|
|
75
|
-
ACCURATE_DOCS_EXEMPTION_MENTIONING_COMMENTS
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_comma_joined_docs_exemption_mentioning_comments_passes() -> None:
|
|
80
|
-
assert not claims_blanket_comment_exemption(
|
|
81
|
-
"Comments are handled, and docs are exempt automatically."
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def test_python_ast_clause_mentioning_comments_passes() -> None:
|
|
86
|
-
assert not claims_blanket_comment_exemption(
|
|
87
|
-
"Python comment-stripped AST changes and docs are exempt automatically "
|
|
88
|
-
"by extension"
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def test_single_clause_python_ast_exemption_passes() -> None:
|
|
93
|
-
assert not claims_blanket_comment_exemption(
|
|
94
|
-
"Python files whose comment-stripped AST is unchanged are exempt "
|
|
95
|
-
"automatically."
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def test_commentary_word_stem_passes() -> None:
|
|
100
|
-
assert not claims_blanket_comment_exemption(
|
|
101
|
-
"Our commentary on the approach is exempt automatically from blame."
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def test_corrective_message_names_only_the_two_real_exemptions() -> None:
|
|
106
|
-
corrective_message = build_corrective_message()
|
|
107
|
-
assert "exempt by extension" in corrective_message
|
|
108
|
-
assert "docstring- and comment-stripped AST" in corrective_message
|
|
109
|
-
assert "test file" not in corrective_message
|
|
110
|
-
assert "by name convention" not in corrective_message
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def test_message_without_exemption_claim_passes() -> None:
|
|
114
|
-
assert not claims_blanket_comment_exemption(
|
|
115
|
-
'CORRECTIVE_MESSAGE = "Spawn the code-verifier agent to earn a verdict."'
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def test_write_content_is_extracted() -> None:
|
|
120
|
-
written_text = extract_written_text({"content": OFFENDING_MESSAGE})
|
|
121
|
-
assert claims_blanket_comment_exemption(written_text)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_edit_new_string_is_extracted() -> None:
|
|
125
|
-
written_text = extract_written_text({"new_string": OFFENDING_MESSAGE})
|
|
126
|
-
assert claims_blanket_comment_exemption(written_text)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def test_edit_new_string_with_accurate_wording_is_clean() -> None:
|
|
130
|
-
written_text = extract_written_text({"new_string": ACCURATE_MESSAGE})
|
|
131
|
-
assert not claims_blanket_comment_exemption(written_text)
|
|
1
|
+
"""Unit tests for the verified-commit-message-accuracy PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
8
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
10
|
+
|
|
11
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
12
|
+
"verified_commit_message_accuracy_blocker",
|
|
13
|
+
_HOOK_DIR / "verified_commit_message_accuracy_blocker.py",
|
|
14
|
+
)
|
|
15
|
+
assert hook_spec is not None
|
|
16
|
+
assert hook_spec.loader is not None
|
|
17
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
18
|
+
hook_spec.loader.exec_module(hook_module)
|
|
19
|
+
is_guarded_file = hook_module.is_guarded_file
|
|
20
|
+
claims_blanket_comment_exemption = hook_module.claims_blanket_comment_exemption
|
|
21
|
+
extract_written_text = hook_module.extract_written_text
|
|
22
|
+
build_corrective_message = hook_module.build_corrective_message
|
|
23
|
+
|
|
24
|
+
OFFENDING_MESSAGE = (
|
|
25
|
+
"CORRECTIVE_MESSAGE = (\n"
|
|
26
|
+
' "BLOCKED: [VERIFIED_COMMIT_GATE] This branch surface has no passing "\n'
|
|
27
|
+
' "verification verdict. Spawn the code-verifier agent (Agent tool, "\n'
|
|
28
|
+
" \"subagent_type 'code-verifier') with the task texts, the diff scope, \"\n"
|
|
29
|
+
' "and recorded baselines; when it finishes with a clean verdict the "\n'
|
|
30
|
+
' "SubagentStop hook mints the verdict and this command will pass. Any "\n'
|
|
31
|
+
' "file change after verification invalidates the verdict, so verify "\n'
|
|
32
|
+
' "last. Docs-, docstring-, comment-, and test-only surfaces are exempt "\n'
|
|
33
|
+
' "automatically."\n'
|
|
34
|
+
")\n"
|
|
35
|
+
)
|
|
36
|
+
ACCURATE_DOCS_EXEMPTION_MENTIONING_COMMENTS = (
|
|
37
|
+
"Comments inside Python files are stripped; docs are exempt "
|
|
38
|
+
"automatically by extension."
|
|
39
|
+
)
|
|
40
|
+
ACCURATE_MESSAGE = (
|
|
41
|
+
"CORRECTIVE_MESSAGE = (\n"
|
|
42
|
+
' "BLOCKED: [VERIFIED_COMMIT_GATE] ... Docs and images are exempt by "\n'
|
|
43
|
+
' "extension, and Python files whose docstring- and comment-stripped AST "\n'
|
|
44
|
+
' "is unchanged."\n'
|
|
45
|
+
")\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_constants_file_is_guarded() -> None:
|
|
50
|
+
assert is_guarded_file(
|
|
51
|
+
"/repo/.claude/hooks/blocking/config/verified_commit_constants.py"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_unrelated_file_is_not_guarded() -> None:
|
|
56
|
+
assert not is_guarded_file("/repo/.claude/hooks/blocking/gh_body_arg_blocker.py")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_blanket_comment_exemption_claim_is_detected() -> None:
|
|
60
|
+
assert claims_blanket_comment_exemption(OFFENDING_MESSAGE)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_blanket_claim_detected_regardless_of_leading_words() -> None:
|
|
64
|
+
assert claims_blanket_comment_exemption(
|
|
65
|
+
"Comment-only surfaces are exempt automatically."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_accurate_exemption_wording_passes() -> None:
|
|
70
|
+
assert not claims_blanket_comment_exemption(ACCURATE_MESSAGE)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_accurate_docs_exemption_mentioning_comments_passes() -> None:
|
|
74
|
+
assert not claims_blanket_comment_exemption(
|
|
75
|
+
ACCURATE_DOCS_EXEMPTION_MENTIONING_COMMENTS
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_comma_joined_docs_exemption_mentioning_comments_passes() -> None:
|
|
80
|
+
assert not claims_blanket_comment_exemption(
|
|
81
|
+
"Comments are handled, and docs are exempt automatically."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_python_ast_clause_mentioning_comments_passes() -> None:
|
|
86
|
+
assert not claims_blanket_comment_exemption(
|
|
87
|
+
"Python comment-stripped AST changes and docs are exempt automatically "
|
|
88
|
+
"by extension"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_single_clause_python_ast_exemption_passes() -> None:
|
|
93
|
+
assert not claims_blanket_comment_exemption(
|
|
94
|
+
"Python files whose comment-stripped AST is unchanged are exempt "
|
|
95
|
+
"automatically."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_commentary_word_stem_passes() -> None:
|
|
100
|
+
assert not claims_blanket_comment_exemption(
|
|
101
|
+
"Our commentary on the approach is exempt automatically from blame."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_corrective_message_names_only_the_two_real_exemptions() -> None:
|
|
106
|
+
corrective_message = build_corrective_message()
|
|
107
|
+
assert "exempt by extension" in corrective_message
|
|
108
|
+
assert "docstring- and comment-stripped AST" in corrective_message
|
|
109
|
+
assert "test file" not in corrective_message
|
|
110
|
+
assert "by name convention" not in corrective_message
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_message_without_exemption_claim_passes() -> None:
|
|
114
|
+
assert not claims_blanket_comment_exemption(
|
|
115
|
+
'CORRECTIVE_MESSAGE = "Spawn the code-verifier agent to earn a verdict."'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_write_content_is_extracted() -> None:
|
|
120
|
+
written_text = extract_written_text({"content": OFFENDING_MESSAGE})
|
|
121
|
+
assert claims_blanket_comment_exemption(written_text)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_edit_new_string_is_extracted() -> None:
|
|
125
|
+
written_text = extract_written_text({"new_string": OFFENDING_MESSAGE})
|
|
126
|
+
assert claims_blanket_comment_exemption(written_text)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_edit_new_string_with_accurate_wording_is_clean() -> None:
|
|
130
|
+
written_text = extract_written_text({"new_string": ACCURATE_MESSAGE})
|
|
131
|
+
assert not claims_blanket_comment_exemption(written_text)
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: deny drive-root, bare-home, and share-root filesystem searches.
|
|
3
|
+
|
|
4
|
+
Blocks Bash/PowerShell commands that walk from an unscoped root — ``find /``,
|
|
5
|
+
``find C:\\``, ``find ~``, ``find //server/share``, recursive ``Get-ChildItem``
|
|
6
|
+
on a drive root — so a session cannot thrash the host with millions of handles.
|
|
7
|
+
Scoped walks under a project path stay allowed. Parallel shell storms are out of
|
|
8
|
+
scope for this detector; the deny message still steers agents to batch shell work.
|
|
9
|
+
|
|
10
|
+
::
|
|
11
|
+
|
|
12
|
+
find / -iname code_rules_gate.py flag: unscoped root
|
|
13
|
+
find . -iname code_rules_gate.py ok: cwd scope
|
|
14
|
+
find //server/share -name x flag: network share root
|
|
15
|
+
find //server/share/project -name x ok: scoped under the share
|
|
16
|
+
Get-ChildItem -Path C:\\ -Recurse flag: recursive drive root
|
|
17
|
+
Get-ChildItem -Path .\\src -Recurse ok: project scope
|
|
18
|
+
ls -r / ok: reverse sort, not recurse
|
|
19
|
+
ls -R / flag: recursive root listing
|
|
20
|
+
ls -laR / flag: clustered short options
|
|
21
|
+
bash -c 'find / -name x' flag: string-exec unwrap
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import shlex
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
32
|
+
if _hooks_dir not in sys.path:
|
|
33
|
+
sys.path.insert(0, _hooks_dir)
|
|
34
|
+
|
|
35
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
36
|
+
from hooks_constants.shell_command_segments import ( # noqa: E402
|
|
37
|
+
effective_leading_program,
|
|
38
|
+
split_into_segments,
|
|
39
|
+
token_basename,
|
|
40
|
+
)
|
|
41
|
+
from hooks_constants.unscoped_search_blocker_constants import ( # noqa: E402
|
|
42
|
+
ALL_FIND_EXPRESSION_INTRODUCER_TOKENS,
|
|
43
|
+
ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE,
|
|
44
|
+
ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE,
|
|
45
|
+
ALL_FIND_PROGRAM_BASENAMES,
|
|
46
|
+
ALL_LISTING_PROGRAM_BASENAMES,
|
|
47
|
+
ALL_POWERSHELL_PATH_FLAG_PREFIXES,
|
|
48
|
+
ALL_POWERSHELL_PATH_FLAGS,
|
|
49
|
+
ALL_POWERSHELL_RECURSE_FLAG_PREFIXES,
|
|
50
|
+
ALL_POWERSHELL_RECURSE_FLAGS,
|
|
51
|
+
ALL_STRING_EXEC_COMMAND_FLAGS,
|
|
52
|
+
ALL_STRING_EXECUTING_SHELL_BASENAMES,
|
|
53
|
+
ALL_SUPPORTED_TOOL_NAMES,
|
|
54
|
+
ALL_TRUTHY_RECURSE_COLON_VALUES,
|
|
55
|
+
ALL_UNIX_LS_PROGRAM_BASENAMES,
|
|
56
|
+
ALL_UNIX_LS_RECURSE_FLAGS,
|
|
57
|
+
ALL_UNSCOPED_HOME_LITERALS_CASEFOLD,
|
|
58
|
+
CALLING_HOOK_NAME,
|
|
59
|
+
CLUSTERED_SHORT_OPTION_PATTERN,
|
|
60
|
+
CORRECTIVE_MESSAGE,
|
|
61
|
+
DENY_DECISION,
|
|
62
|
+
FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX,
|
|
63
|
+
FLAG_AND_VALUE_TOKEN_STRIDE,
|
|
64
|
+
GIT_BASH_DRIVE_ROOT_PATTERN,
|
|
65
|
+
HOOK_EVENT_NAME,
|
|
66
|
+
POSIX_ROOT_ALIAS_PATTERN,
|
|
67
|
+
UNC_SHARE_ROOT_PATTERN,
|
|
68
|
+
UNIX_LS_RECURSE_SHORT_OPTION_LETTER,
|
|
69
|
+
WINDOWS_DRIVE_ROOT_PATTERN,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_unscoped_search_root(path_token: str) -> bool:
|
|
74
|
+
"""Return True when a path token is a drive, home, or network-share root.
|
|
75
|
+
|
|
76
|
+
::
|
|
77
|
+
|
|
78
|
+
/ flag
|
|
79
|
+
/. flag (posix root alias)
|
|
80
|
+
/c/ flag (Git Bash drive root)
|
|
81
|
+
C:\\ flag
|
|
82
|
+
~ flag
|
|
83
|
+
//server/share flag (UNC share root)
|
|
84
|
+
//server/share/src ok (scoped under the share)
|
|
85
|
+
./src ok
|
|
86
|
+
/c/Users/x ok
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
path_token: One shell token treated as a search start path.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True when the token names an unscoped filesystem root.
|
|
93
|
+
"""
|
|
94
|
+
stripped_path = path_token.strip().strip("\"'")
|
|
95
|
+
if not stripped_path:
|
|
96
|
+
return False
|
|
97
|
+
if stripped_path.casefold() in ALL_UNSCOPED_HOME_LITERALS_CASEFOLD:
|
|
98
|
+
return True
|
|
99
|
+
forward_slash_path = stripped_path.replace("\\", "/")
|
|
100
|
+
if POSIX_ROOT_ALIAS_PATTERN.fullmatch(forward_slash_path):
|
|
101
|
+
return True
|
|
102
|
+
if GIT_BASH_DRIVE_ROOT_PATTERN.fullmatch(forward_slash_path):
|
|
103
|
+
return True
|
|
104
|
+
if UNC_SHARE_ROOT_PATTERN.fullmatch(forward_slash_path):
|
|
105
|
+
return True
|
|
106
|
+
if WINDOWS_DRIVE_ROOT_PATTERN.fullmatch(stripped_path):
|
|
107
|
+
return True
|
|
108
|
+
windows_drive_alias = stripped_path.rstrip(".\\/")
|
|
109
|
+
if WINDOWS_DRIVE_ROOT_PATTERN.fullmatch(windows_drive_alias + "\\"):
|
|
110
|
+
return True
|
|
111
|
+
if WINDOWS_DRIVE_ROOT_PATTERN.fullmatch(windows_drive_alias):
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _tokens_after_leading_program(
|
|
117
|
+
all_segment_tokens: list[str], leading_program: str | None
|
|
118
|
+
) -> list[str]:
|
|
119
|
+
if leading_program is None:
|
|
120
|
+
return []
|
|
121
|
+
leading_index = all_segment_tokens.index(leading_program)
|
|
122
|
+
return all_segment_tokens[leading_index + 1 :]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_find_expression_token(token: str) -> bool:
|
|
126
|
+
if token in ALL_FIND_EXPRESSION_INTRODUCER_TOKENS:
|
|
127
|
+
return True
|
|
128
|
+
return token.startswith("-")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _collect_find_starting_points(all_argument_tokens: list[str]) -> list[str]:
|
|
132
|
+
all_starting_points: list[str] = []
|
|
133
|
+
token_index = 0
|
|
134
|
+
while token_index < len(all_argument_tokens):
|
|
135
|
+
each_token = all_argument_tokens[token_index]
|
|
136
|
+
if each_token in ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE:
|
|
137
|
+
token_index += 1
|
|
138
|
+
continue
|
|
139
|
+
if each_token in ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE:
|
|
140
|
+
if token_index + 1 >= len(all_argument_tokens):
|
|
141
|
+
token_index += 1
|
|
142
|
+
continue
|
|
143
|
+
next_token = all_argument_tokens[token_index + 1]
|
|
144
|
+
if is_unscoped_search_root(next_token) or _is_find_expression_token(
|
|
145
|
+
next_token
|
|
146
|
+
):
|
|
147
|
+
token_index += 1
|
|
148
|
+
continue
|
|
149
|
+
token_index += FLAG_AND_VALUE_TOKEN_STRIDE
|
|
150
|
+
continue
|
|
151
|
+
if each_token.startswith(FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX):
|
|
152
|
+
token_index += 1
|
|
153
|
+
continue
|
|
154
|
+
if _is_find_expression_token(each_token):
|
|
155
|
+
break
|
|
156
|
+
all_starting_points.append(each_token)
|
|
157
|
+
token_index += 1
|
|
158
|
+
return all_starting_points
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _segment_has_unscoped_find(all_segment_tokens: list[str]) -> bool:
|
|
162
|
+
leading_program = effective_leading_program(all_segment_tokens)
|
|
163
|
+
if leading_program is None:
|
|
164
|
+
return False
|
|
165
|
+
if token_basename(leading_program) not in ALL_FIND_PROGRAM_BASENAMES:
|
|
166
|
+
return False
|
|
167
|
+
all_starting_points = _collect_find_starting_points(
|
|
168
|
+
_tokens_after_leading_program(all_segment_tokens, leading_program)
|
|
169
|
+
)
|
|
170
|
+
return any(
|
|
171
|
+
is_unscoped_search_root(each_start) for each_start in all_starting_points
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _token_is_unix_ls_recurse_flag(token: str) -> bool:
|
|
176
|
+
"""Return True for an ls token that turns the listing recursive.
|
|
177
|
+
|
|
178
|
+
::
|
|
179
|
+
|
|
180
|
+
-R flag
|
|
181
|
+
-laR flag (clustered short options)
|
|
182
|
+
-Rl flag
|
|
183
|
+
--recursive flag
|
|
184
|
+
-r ok (reverse sort, not recurse)
|
|
185
|
+
-la ok
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
token: One argument token from an ``ls`` segment.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True when the token carries the recursive option.
|
|
192
|
+
"""
|
|
193
|
+
if token in ALL_UNIX_LS_RECURSE_FLAGS:
|
|
194
|
+
return True
|
|
195
|
+
if not CLUSTERED_SHORT_OPTION_PATTERN.fullmatch(token):
|
|
196
|
+
return False
|
|
197
|
+
return UNIX_LS_RECURSE_SHORT_OPTION_LETTER in token
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _token_is_recurse_flag(token: str, program_basename: str) -> bool:
|
|
201
|
+
if program_basename in ALL_UNIX_LS_PROGRAM_BASENAMES:
|
|
202
|
+
return _token_is_unix_ls_recurse_flag(token)
|
|
203
|
+
lowered_token = token.lower()
|
|
204
|
+
if lowered_token in ALL_POWERSHELL_RECURSE_FLAGS:
|
|
205
|
+
return True
|
|
206
|
+
for each_prefix in ALL_POWERSHELL_RECURSE_FLAG_PREFIXES:
|
|
207
|
+
if lowered_token.startswith(each_prefix):
|
|
208
|
+
colon_value = lowered_token[len(each_prefix) :]
|
|
209
|
+
return colon_value in ALL_TRUTHY_RECURSE_COLON_VALUES
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _path_value_from_path_flag_token(token: str) -> str | None:
|
|
214
|
+
lowered_token = token.lower()
|
|
215
|
+
for each_prefix in ALL_POWERSHELL_PATH_FLAG_PREFIXES:
|
|
216
|
+
if lowered_token.startswith(each_prefix):
|
|
217
|
+
return token[len(each_prefix) :]
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _collect_get_child_item_path_tokens(all_argument_tokens: list[str]) -> list[str]:
|
|
222
|
+
all_path_tokens: list[str] = []
|
|
223
|
+
token_index = 0
|
|
224
|
+
while token_index < len(all_argument_tokens):
|
|
225
|
+
each_token = all_argument_tokens[token_index]
|
|
226
|
+
lowered_token = each_token.lower()
|
|
227
|
+
path_from_glued_flag = _path_value_from_path_flag_token(each_token)
|
|
228
|
+
if path_from_glued_flag is not None:
|
|
229
|
+
if path_from_glued_flag:
|
|
230
|
+
all_path_tokens.append(path_from_glued_flag)
|
|
231
|
+
token_index += 1
|
|
232
|
+
continue
|
|
233
|
+
if lowered_token in ALL_POWERSHELL_PATH_FLAGS:
|
|
234
|
+
if token_index + 1 < len(all_argument_tokens):
|
|
235
|
+
all_path_tokens.append(all_argument_tokens[token_index + 1])
|
|
236
|
+
token_index += FLAG_AND_VALUE_TOKEN_STRIDE
|
|
237
|
+
continue
|
|
238
|
+
if lowered_token == "/s" or each_token.startswith("-"):
|
|
239
|
+
token_index += 1
|
|
240
|
+
continue
|
|
241
|
+
all_path_tokens.append(each_token)
|
|
242
|
+
token_index += 1
|
|
243
|
+
return all_path_tokens
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _segment_has_unscoped_recursive_listing(all_segment_tokens: list[str]) -> bool:
|
|
247
|
+
leading_program = effective_leading_program(all_segment_tokens)
|
|
248
|
+
if leading_program is None:
|
|
249
|
+
return False
|
|
250
|
+
program_basename = token_basename(leading_program)
|
|
251
|
+
if program_basename not in ALL_LISTING_PROGRAM_BASENAMES:
|
|
252
|
+
return False
|
|
253
|
+
all_argument_tokens = _tokens_after_leading_program(
|
|
254
|
+
all_segment_tokens, leading_program
|
|
255
|
+
)
|
|
256
|
+
has_recurse_flag = any(
|
|
257
|
+
_token_is_recurse_flag(each_token, program_basename)
|
|
258
|
+
for each_token in all_argument_tokens
|
|
259
|
+
)
|
|
260
|
+
if not has_recurse_flag:
|
|
261
|
+
return False
|
|
262
|
+
all_path_tokens = _collect_get_child_item_path_tokens(all_argument_tokens)
|
|
263
|
+
if not all_path_tokens:
|
|
264
|
+
return False
|
|
265
|
+
return any(
|
|
266
|
+
is_unscoped_search_root(each_path) for each_path in all_path_tokens
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _string_exec_inner_command(all_segment_tokens: list[str]) -> str | None:
|
|
271
|
+
leading_program = effective_leading_program(all_segment_tokens)
|
|
272
|
+
if leading_program is None:
|
|
273
|
+
return None
|
|
274
|
+
if token_basename(leading_program) not in ALL_STRING_EXECUTING_SHELL_BASENAMES:
|
|
275
|
+
return None
|
|
276
|
+
all_argument_tokens = _tokens_after_leading_program(
|
|
277
|
+
all_segment_tokens, leading_program
|
|
278
|
+
)
|
|
279
|
+
token_index = 0
|
|
280
|
+
while token_index < len(all_argument_tokens):
|
|
281
|
+
each_token = all_argument_tokens[token_index]
|
|
282
|
+
if each_token.lower() in ALL_STRING_EXEC_COMMAND_FLAGS:
|
|
283
|
+
if token_index + 1 < len(all_argument_tokens):
|
|
284
|
+
return all_argument_tokens[token_index + 1]
|
|
285
|
+
return None
|
|
286
|
+
token_index += 1
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _all_command_tokenizations(command: str) -> list[list[str]]:
|
|
291
|
+
"""Return POSIX and non-POSIX tokenizations so Windows paths stay intact.
|
|
292
|
+
|
|
293
|
+
Git Bash commands need POSIX splitting (quote rules). PowerShell paths like
|
|
294
|
+
``C:\\`` need non-POSIX splitting so the backslash is not an escape. Both
|
|
295
|
+
are tried; an empty or failed split is dropped.
|
|
296
|
+
"""
|
|
297
|
+
all_tokenizations: list[list[str]] = []
|
|
298
|
+
for each_posix_mode in (True, False):
|
|
299
|
+
try:
|
|
300
|
+
all_tokens = shlex.split(command, posix=each_posix_mode)
|
|
301
|
+
except ValueError:
|
|
302
|
+
continue
|
|
303
|
+
if all_tokens:
|
|
304
|
+
all_tokenizations.append(all_tokens)
|
|
305
|
+
return all_tokenizations
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def find_unscoped_search_violation(command: str) -> str | None:
|
|
309
|
+
"""Return the deny message for an unscoped tree walk, or None to allow.
|
|
310
|
+
|
|
311
|
+
::
|
|
312
|
+
|
|
313
|
+
find / -iname x.py flag
|
|
314
|
+
find packages -iname x.py ok
|
|
315
|
+
Get-ChildItem -Path C:\\ -Recurse flag
|
|
316
|
+
Get-ChildItem -Path .\\src -Recurse ok
|
|
317
|
+
find //server/share -name x flag
|
|
318
|
+
find //server/share/project -name x ok
|
|
319
|
+
bash -c 'find / -name x' flag
|
|
320
|
+
ls -r / ok
|
|
321
|
+
ls -R / flag
|
|
322
|
+
ls -laR / flag
|
|
323
|
+
|
|
324
|
+
Tokenizes the command under POSIX and non-POSIX shlex modes, splits on shell
|
|
325
|
+
control operators, and evaluates each simple-command segment. A ``find``
|
|
326
|
+
segment with an unscoped starting point denies. A recursive listing segment
|
|
327
|
+
(PowerShell ``Get-ChildItem``/``gci``/``dir`` or Unix ``ls -R``, including a
|
|
328
|
+
clustered short-option token such as ``-laR``) with an unscoped path denies.
|
|
329
|
+
A ``bash -c`` / ``pwsh -Command`` wrapper re-runs the check on its string
|
|
330
|
+
argument. Returns None when neither tokenization yields tokens.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
command: The raw Bash or PowerShell command string from the tool input.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
The corrective deny message when a segment walks from an unscoped root,
|
|
337
|
+
else None.
|
|
338
|
+
"""
|
|
339
|
+
for each_tokenization in _all_command_tokenizations(command):
|
|
340
|
+
for each_segment in split_into_segments(each_tokenization):
|
|
341
|
+
if _segment_has_unscoped_find(each_segment):
|
|
342
|
+
return CORRECTIVE_MESSAGE
|
|
343
|
+
if _segment_has_unscoped_recursive_listing(each_segment):
|
|
344
|
+
return CORRECTIVE_MESSAGE
|
|
345
|
+
inner_command = _string_exec_inner_command(each_segment)
|
|
346
|
+
if inner_command is not None:
|
|
347
|
+
nested_denial = find_unscoped_search_violation(inner_command)
|
|
348
|
+
if nested_denial is not None:
|
|
349
|
+
return nested_denial
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def main() -> None:
|
|
354
|
+
try:
|
|
355
|
+
hook_input = json.load(sys.stdin)
|
|
356
|
+
except json.JSONDecodeError:
|
|
357
|
+
sys.exit(0)
|
|
358
|
+
|
|
359
|
+
tool_name = hook_input.get("tool_name", "")
|
|
360
|
+
if tool_name not in ALL_SUPPORTED_TOOL_NAMES:
|
|
361
|
+
sys.exit(0)
|
|
362
|
+
|
|
363
|
+
command = hook_input.get("tool_input", {}).get("command", "")
|
|
364
|
+
if not command:
|
|
365
|
+
sys.exit(0)
|
|
366
|
+
|
|
367
|
+
deny_reason = find_unscoped_search_violation(command)
|
|
368
|
+
if deny_reason is None:
|
|
369
|
+
sys.exit(0)
|
|
370
|
+
|
|
371
|
+
deny_payload = {
|
|
372
|
+
"hookSpecificOutput": {
|
|
373
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
374
|
+
"permissionDecision": DENY_DECISION,
|
|
375
|
+
"permissionDecisionReason": deny_reason,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
log_hook_block(
|
|
379
|
+
calling_hook_name=CALLING_HOOK_NAME,
|
|
380
|
+
hook_event=HOOK_EVENT_NAME,
|
|
381
|
+
block_reason=deny_reason,
|
|
382
|
+
tool_name=tool_name,
|
|
383
|
+
offending_input_preview=command,
|
|
384
|
+
)
|
|
385
|
+
print(json.dumps(deny_payload))
|
|
386
|
+
sys.stdout.flush()
|
|
387
|
+
sys.exit(0)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
main()
|