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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse gate: deny orchestrator-refresh re-arm when the run is not active.
|
|
3
|
+
|
|
4
|
+
Fires on ``ScheduleWakeup`` and ``CronCreate`` when the prompt targets
|
|
5
|
+
``/orchestrator-refresh``. Uses the same status-file gate as
|
|
6
|
+
``skills/orchestrator/scripts/status_gate.py`` so idle loops
|
|
7
|
+
cannot be re-armed on the Claude host without an active status file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from types import ModuleType
|
|
19
|
+
from typing import Mapping
|
|
20
|
+
|
|
21
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
22
|
+
if _hooks_dir not in sys.path:
|
|
23
|
+
sys.path.insert(0, _hooks_dir)
|
|
24
|
+
|
|
25
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
26
|
+
from hooks_constants.orchestrator_refresh_reschedule_gate_constants import ( # noqa: E402
|
|
27
|
+
ALL_HOME_SKILLS_RELATIVE_PARTS,
|
|
28
|
+
ALL_SCHEDULE_TOOL_NAMES,
|
|
29
|
+
ALL_SKILL_SCRIPTS_RELATIVE_PARTS,
|
|
30
|
+
CALLING_HOOK_NAME,
|
|
31
|
+
CRON_CREATE_TOOL_NAME,
|
|
32
|
+
CWD_FIELD_NAME,
|
|
33
|
+
DENY_REASON_TEMPLATE,
|
|
34
|
+
HOOK_EVENT_NAME,
|
|
35
|
+
ORCHESTRATOR_REFRESH_PROMPT_TOKEN,
|
|
36
|
+
PLUGIN_ROOT_ENV_VAR,
|
|
37
|
+
PROMPT_FIELD_NAME,
|
|
38
|
+
REASON_CRON_CREATE_FORBIDDEN,
|
|
39
|
+
RUN_SLUG_PROMPT_FLAG,
|
|
40
|
+
RUN_SLUG_PROMPT_VALUE_PATTERN,
|
|
41
|
+
TOOL_INPUT_FIELD_NAME,
|
|
42
|
+
TOOL_NAME_FIELD_NAME,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _candidate_skill_script_directories() -> list[Path]:
|
|
47
|
+
"""Return ordered paths that may contain status_gate.py.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Absolute candidate directories in preference order.
|
|
51
|
+
"""
|
|
52
|
+
all_candidates: list[Path] = []
|
|
53
|
+
plugin_root = os.environ.get(PLUGIN_ROOT_ENV_VAR)
|
|
54
|
+
if plugin_root:
|
|
55
|
+
all_candidates.append(
|
|
56
|
+
Path(plugin_root).joinpath(*ALL_SKILL_SCRIPTS_RELATIVE_PARTS)
|
|
57
|
+
)
|
|
58
|
+
package_hooks_parent = Path(__file__).resolve().parents[2]
|
|
59
|
+
all_candidates.append(
|
|
60
|
+
package_hooks_parent.joinpath(*ALL_SKILL_SCRIPTS_RELATIVE_PARTS)
|
|
61
|
+
)
|
|
62
|
+
all_candidates.append(Path.home().joinpath(*ALL_HOME_SKILLS_RELATIVE_PARTS))
|
|
63
|
+
return all_candidates
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_status_gate_module() -> ModuleType | None:
|
|
67
|
+
"""Import status_gate from the skill scripts package.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The loaded module, or None when no candidate path is usable.
|
|
71
|
+
"""
|
|
72
|
+
for each_directory in _candidate_skill_script_directories():
|
|
73
|
+
module_path = each_directory / "status_gate.py"
|
|
74
|
+
if not module_path.is_file():
|
|
75
|
+
continue
|
|
76
|
+
scripts_directory = str(each_directory)
|
|
77
|
+
if scripts_directory not in sys.path:
|
|
78
|
+
sys.path.insert(0, scripts_directory)
|
|
79
|
+
spec = importlib.util.spec_from_file_location(
|
|
80
|
+
"orchestrator_status_gate_for_reschedule_hook",
|
|
81
|
+
module_path,
|
|
82
|
+
)
|
|
83
|
+
if spec is None or spec.loader is None:
|
|
84
|
+
continue
|
|
85
|
+
status_gate_module = importlib.util.module_from_spec(spec)
|
|
86
|
+
sys.modules[spec.name] = status_gate_module
|
|
87
|
+
spec.loader.exec_module(status_gate_module)
|
|
88
|
+
return status_gate_module
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _prompt_targets_orchestrator_refresh(
|
|
93
|
+
all_tool_input: Mapping[str, object],
|
|
94
|
+
) -> bool:
|
|
95
|
+
"""Return True when the schedule prompt re-enters orchestrator-refresh.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
all_tool_input: PreToolUse tool_input mapping.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
True when the prompt field contains the refresh skill token.
|
|
102
|
+
"""
|
|
103
|
+
prompt_value = all_tool_input.get(PROMPT_FIELD_NAME, "")
|
|
104
|
+
if not isinstance(prompt_value, str):
|
|
105
|
+
return False
|
|
106
|
+
stripped_prompt = prompt_value.lstrip()
|
|
107
|
+
if not stripped_prompt.startswith(ORCHESTRATOR_REFRESH_PROMPT_TOKEN):
|
|
108
|
+
return False
|
|
109
|
+
if len(stripped_prompt) == len(ORCHESTRATOR_REFRESH_PROMPT_TOKEN):
|
|
110
|
+
return True
|
|
111
|
+
return stripped_prompt[len(ORCHESTRATOR_REFRESH_PROMPT_TOKEN)] in (" ", "\n", "\t")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _should_evaluate(all_payload_by_field: Mapping[str, object]) -> bool:
|
|
115
|
+
"""Return True for schedule tools re-arming orchestrator-refresh.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
all_payload_by_field: Full PreToolUse payload.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True when this gate must run.
|
|
122
|
+
"""
|
|
123
|
+
tool_name = all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")
|
|
124
|
+
if tool_name not in ALL_SCHEDULE_TOOL_NAMES:
|
|
125
|
+
return False
|
|
126
|
+
tool_input = all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
|
|
127
|
+
if not isinstance(tool_input, dict):
|
|
128
|
+
return False
|
|
129
|
+
return _prompt_targets_orchestrator_refresh(tool_input)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _run_slug_from_prompt(prompt_value: str) -> str:
|
|
133
|
+
"""Extract ``--run-slug`` from a ScheduleWakeup prompt when present.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
prompt_value: The schedule tool prompt text.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The slug string, or empty when the flag is absent.
|
|
140
|
+
"""
|
|
141
|
+
run_slug_from_prompt_pattern = re.compile(
|
|
142
|
+
re.escape(RUN_SLUG_PROMPT_FLAG) + RUN_SLUG_PROMPT_VALUE_PATTERN
|
|
143
|
+
)
|
|
144
|
+
matched = run_slug_from_prompt_pattern.search(prompt_value)
|
|
145
|
+
if matched is None:
|
|
146
|
+
return ""
|
|
147
|
+
return matched.group(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _resolve_status_file_path(
|
|
151
|
+
status_gate_module: ModuleType,
|
|
152
|
+
all_payload_by_field: Mapping[str, object],
|
|
153
|
+
) -> Path:
|
|
154
|
+
"""Resolve the run status file for this schedule attempt without chdir.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
status_gate_module: Loaded status_gate module.
|
|
158
|
+
all_payload_by_field: PreToolUse payload (may carry cwd and prompt).
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Absolute status file path.
|
|
162
|
+
"""
|
|
163
|
+
base_directory: Path | None = None
|
|
164
|
+
working_directory_value = all_payload_by_field.get(CWD_FIELD_NAME)
|
|
165
|
+
if isinstance(working_directory_value, str) and working_directory_value:
|
|
166
|
+
base_directory = Path(working_directory_value)
|
|
167
|
+
run_slug = ""
|
|
168
|
+
tool_input = all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
|
|
169
|
+
if isinstance(tool_input, dict):
|
|
170
|
+
prompt_value = tool_input.get(PROMPT_FIELD_NAME, "")
|
|
171
|
+
if isinstance(prompt_value, str):
|
|
172
|
+
run_slug = _run_slug_from_prompt(prompt_value)
|
|
173
|
+
return status_gate_module.resolve_status_file_path(
|
|
174
|
+
None, base_directory, run_slug
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def build_denial_response(reason_code: str) -> dict[str, dict[str, str]]:
|
|
179
|
+
"""Build the PreToolUse deny payload.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
reason_code: Machine reason from decide_should_reschedule.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Hook-specific deny JSON structure.
|
|
186
|
+
"""
|
|
187
|
+
denial_reason = DENY_REASON_TEMPLATE.format(reason=reason_code)
|
|
188
|
+
return {
|
|
189
|
+
"hookSpecificOutput": {
|
|
190
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
191
|
+
"permissionDecision": "deny",
|
|
192
|
+
"permissionDecisionReason": denial_reason,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _emit_denial(
|
|
198
|
+
reason_code: str,
|
|
199
|
+
all_payload_by_field: Mapping[str, object],
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Log and print a deny decision for the current schedule attempt.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
reason_code: Machine reason string.
|
|
205
|
+
all_payload_by_field: PreToolUse payload for logging context.
|
|
206
|
+
"""
|
|
207
|
+
denial = build_denial_response(reason_code)
|
|
208
|
+
hook_specific_output = denial["hookSpecificOutput"]
|
|
209
|
+
block_reason = hook_specific_output["permissionDecisionReason"]
|
|
210
|
+
log_hook_block(
|
|
211
|
+
calling_hook_name=CALLING_HOOK_NAME,
|
|
212
|
+
hook_event=HOOK_EVENT_NAME,
|
|
213
|
+
block_reason=block_reason,
|
|
214
|
+
tool_name=str(all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")),
|
|
215
|
+
offending_input_preview=str(
|
|
216
|
+
all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
print(json.dumps(denial))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main() -> None:
|
|
223
|
+
"""Read PreToolUse stdin and deny idle orchestrator-refresh re-arms."""
|
|
224
|
+
try:
|
|
225
|
+
payload_by_field = json.load(sys.stdin)
|
|
226
|
+
except json.JSONDecodeError:
|
|
227
|
+
sys.exit(0)
|
|
228
|
+
|
|
229
|
+
if not isinstance(payload_by_field, dict):
|
|
230
|
+
sys.exit(0)
|
|
231
|
+
if not _should_evaluate(payload_by_field):
|
|
232
|
+
sys.exit(0)
|
|
233
|
+
|
|
234
|
+
tool_name = payload_by_field.get(TOOL_NAME_FIELD_NAME, "")
|
|
235
|
+
if tool_name == CRON_CREATE_TOOL_NAME:
|
|
236
|
+
_emit_denial(REASON_CRON_CREATE_FORBIDDEN, payload_by_field)
|
|
237
|
+
sys.exit(0)
|
|
238
|
+
|
|
239
|
+
status_gate_module = _load_status_gate_module()
|
|
240
|
+
if status_gate_module is None:
|
|
241
|
+
_emit_denial("status_gate_unavailable", payload_by_field)
|
|
242
|
+
sys.exit(0)
|
|
243
|
+
|
|
244
|
+
status_file_path = _resolve_status_file_path(status_gate_module, payload_by_field)
|
|
245
|
+
is_reschedule_allowed, reason_code = status_gate_module.decide_should_reschedule(
|
|
246
|
+
status_file_path
|
|
247
|
+
)
|
|
248
|
+
if is_reschedule_allowed:
|
|
249
|
+
sys.exit(0)
|
|
250
|
+
|
|
251
|
+
_emit_denial(reason_code, payload_by_field)
|
|
252
|
+
sys.exit(0)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
main()
|
|
@@ -7,10 +7,10 @@ declared in the constants module, aggregates the results, and emits one deny
|
|
|
7
7
|
decision when any hook denied (carrying every denying reason) or exits zero to
|
|
8
8
|
allow.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
Each tool runs the ``ALL_HOSTED_HOOK_ENTRIES`` whose ``applicable_tool_names``
|
|
11
|
+
list that tool; that roster is the live source of the per-tool coverage, and the
|
|
12
|
+
dispatcher roster tests in ``test_pre_tool_use_dispatcher.py`` assert the counts,
|
|
13
|
+
rather than a hardcoded matrix here that would drift as hooks are added.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
@@ -24,20 +24,18 @@ from collections.abc import Callable
|
|
|
24
24
|
from dataclasses import dataclass, field
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
if _hooks_directory not in sys.path:
|
|
29
|
-
sys.path.insert(0, _hooks_directory)
|
|
27
|
+
import _path_setup # noqa: F401
|
|
30
28
|
|
|
31
|
-
from plain_language_blocker import (
|
|
29
|
+
from plain_language_blocker import (
|
|
32
30
|
build_deny_payload as build_plain_language_deny_payload,
|
|
33
31
|
)
|
|
34
|
-
from plain_language_blocker import evaluate as evaluate_plain_language
|
|
35
|
-
from state_description_blocker import (
|
|
32
|
+
from plain_language_blocker import evaluate as evaluate_plain_language
|
|
33
|
+
from state_description_blocker import (
|
|
36
34
|
build_deny_payload as build_state_description_deny_payload,
|
|
37
35
|
)
|
|
38
|
-
from state_description_blocker import evaluate as evaluate_state_description
|
|
36
|
+
from state_description_blocker import evaluate as evaluate_state_description
|
|
39
37
|
|
|
40
|
-
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
38
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
41
39
|
ALL_HOSTED_HOOK_ENTRIES,
|
|
42
40
|
ALLOW_DECISION,
|
|
43
41
|
BLOCKING_CRASH_DENY_REASON,
|
|
@@ -49,7 +47,7 @@ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
|
|
|
49
47
|
STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
|
|
50
48
|
HostedHookEntry,
|
|
51
49
|
)
|
|
52
|
-
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
50
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
53
51
|
|
|
54
52
|
NativeEvaluator = Callable[[dict[str, object]], str | None]
|
|
55
53
|
DenyPayloadBuilder = Callable[[str], dict[str, object]]
|
|
@@ -70,6 +68,18 @@ class NativeHook:
|
|
|
70
68
|
build_deny_payload: DenyPayloadBuilder
|
|
71
69
|
|
|
72
70
|
|
|
71
|
+
_native_hook_by_module_name: dict[str, NativeHook] = {
|
|
72
|
+
STATE_DESCRIPTION_BLOCKER_MODULE_NAME: NativeHook(
|
|
73
|
+
evaluate=evaluate_state_description,
|
|
74
|
+
build_deny_payload=build_state_description_deny_payload,
|
|
75
|
+
),
|
|
76
|
+
PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
|
|
77
|
+
evaluate=evaluate_plain_language,
|
|
78
|
+
build_deny_payload=build_plain_language_deny_payload,
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
73
83
|
@dataclass
|
|
74
84
|
class HostedHookResult:
|
|
75
85
|
"""Outcome of running one hosted hook inside the dispatcher process.
|
|
@@ -477,17 +487,7 @@ def _run_one_hosted_hook(
|
|
|
477
487
|
The HostedHookResult for this hook's run.
|
|
478
488
|
"""
|
|
479
489
|
if each_entry.native_module_name is not None:
|
|
480
|
-
|
|
481
|
-
STATE_DESCRIPTION_BLOCKER_MODULE_NAME: NativeHook(
|
|
482
|
-
evaluate=evaluate_state_description,
|
|
483
|
-
build_deny_payload=build_state_description_deny_payload,
|
|
484
|
-
),
|
|
485
|
-
PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
|
|
486
|
-
evaluate=evaluate_plain_language,
|
|
487
|
-
build_deny_payload=build_plain_language_deny_payload,
|
|
488
|
-
),
|
|
489
|
-
}
|
|
490
|
-
native_hook = native_hook_by_module_name[each_entry.native_module_name]
|
|
490
|
+
native_hook = _native_hook_by_module_name[each_entry.native_module_name]
|
|
491
491
|
return run_native_hook(native_hook, payload_by_key, each_entry.is_blocking)
|
|
492
492
|
script_path = _resolve_hook_script_path(each_entry.script_relative_path)
|
|
493
493
|
return run_hosted_hook(script_path, payload_text, each_entry.is_blocking)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block Bash commands containing shell command substitution.
|
|
3
|
+
|
|
4
|
+
Root cause: Claude Code's auto-allowlist matcher operates on the raw command
|
|
5
|
+
string, not on parsed syntax, so it does not descend into command substitutions
|
|
6
|
+
such as $(...), unescaped backticks, or bash process substitutions <(...) and
|
|
7
|
+
>(...). A compound of the form
|
|
8
|
+
|
|
9
|
+
cd X && echo "$(git rev-parse HEAD)"
|
|
10
|
+
|
|
11
|
+
therefore falls outside the auto-allowed set for `cd` + `echo` + `git rev-parse`
|
|
12
|
+
and forces a permission prompt, even though every segment would be auto-allowed
|
|
13
|
+
individually. The same mechanics apply to `diff <(cat a) <(cat b)` and
|
|
14
|
+
`tee >(gzip > out.gz)`: each inner command would be auto-allowed on its own,
|
|
15
|
+
but the outer wrapper defeats the matcher. Splitting into two separate Bash
|
|
16
|
+
tool calls, or collapsing to a substitution-free form like
|
|
17
|
+
`git -C X rev-parse HEAD`, avoids the prompt.
|
|
18
|
+
|
|
19
|
+
Detection: a regex match for `$(`, an unescaped backtick, or `<(` / `>(`, after
|
|
20
|
+
stripping single-quoted literal runs. Single-quoted regions are intentionally
|
|
21
|
+
ignored because bash performs no substitution inside them (for example
|
|
22
|
+
`echo '$(not-executed)'`). Backslash-escaped backticks outside single quotes
|
|
23
|
+
are treated as literal only when the count of preceding backslashes is odd;
|
|
24
|
+
an even count (including zero) means the backtick is a live substitution.
|
|
25
|
+
|
|
26
|
+
Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
|
|
27
|
+
spawn a subshell and does not defeat the allowlist matcher, so the
|
|
28
|
+
``$(`` pattern uses a negative lookahead to skip the ``$((`` case.
|
|
29
|
+
|
|
30
|
+
False positives are still possible when the user is intentionally writing
|
|
31
|
+
shell-script content (for example, authoring a .sh file via heredoc) and the
|
|
32
|
+
substitution is literal payload rather than something to execute. In that
|
|
33
|
+
case, write via the Write tool instead of a Bash heredoc.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import sys
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
43
|
+
if _hooks_dir not in sys.path:
|
|
44
|
+
sys.path.insert(0, _hooks_dir)
|
|
45
|
+
|
|
46
|
+
from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
|
|
47
|
+
BASH_TOOL_NAME,
|
|
48
|
+
COMMAND_KEY,
|
|
49
|
+
CORRECTIVE_MESSAGE,
|
|
50
|
+
DENY_DECISION,
|
|
51
|
+
DOLLAR_PAREN_PATTERN,
|
|
52
|
+
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
53
|
+
HOOK_EVENT_NAME,
|
|
54
|
+
HOOK_EVENT_NAME_KEY,
|
|
55
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
56
|
+
PERMISSION_DECISION_KEY,
|
|
57
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
58
|
+
PROCESS_SUBSTITUTION_PATTERN,
|
|
59
|
+
SINGLE_QUOTED_RUN_PATTERN,
|
|
60
|
+
STRIPPED_RUN_REPLACEMENT,
|
|
61
|
+
TOOL_INPUT_KEY,
|
|
62
|
+
TOOL_NAME_KEY,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _strip_single_quoted_runs(command: str) -> str:
|
|
67
|
+
return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def has_shell_substitution(command: str) -> bool:
|
|
71
|
+
"""Return True when a Bash command carries a live shell substitution.
|
|
72
|
+
|
|
73
|
+
::
|
|
74
|
+
|
|
75
|
+
echo "$(git rev-parse HEAD)" flag: command substitution
|
|
76
|
+
echo `date` flag: live backtick
|
|
77
|
+
diff <(cat a) <(cat b) flag: process substitution
|
|
78
|
+
echo '$(not-executed)' ok: single-quoted, inert
|
|
79
|
+
echo $((2 + 2)) ok: arithmetic expansion
|
|
80
|
+
|
|
81
|
+
Single-quoted runs are stripped first because bash substitutes nothing
|
|
82
|
+
inside them. A backtick preceded by an odd number of backslashes is escaped
|
|
83
|
+
and stays allowed.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
command: The raw Bash command string from the tool input.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True when a substitution the allowlist matcher cannot descend into is
|
|
90
|
+
present outside single quotes.
|
|
91
|
+
"""
|
|
92
|
+
scannable_command = _strip_single_quoted_runs(command)
|
|
93
|
+
if DOLLAR_PAREN_PATTERN.search(scannable_command):
|
|
94
|
+
return True
|
|
95
|
+
if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
|
|
96
|
+
return True
|
|
97
|
+
if PROCESS_SUBSTITUTION_PATTERN.search(scannable_command):
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> None:
|
|
103
|
+
try:
|
|
104
|
+
hook_input = json.load(sys.stdin)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
sys.exit(0)
|
|
107
|
+
|
|
108
|
+
tool_name = hook_input.get(TOOL_NAME_KEY, "")
|
|
109
|
+
if tool_name != BASH_TOOL_NAME:
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
command = hook_input.get(TOOL_INPUT_KEY, {}).get(COMMAND_KEY, "")
|
|
113
|
+
if not command or not has_shell_substitution(command):
|
|
114
|
+
sys.exit(0)
|
|
115
|
+
|
|
116
|
+
deny_payload = {
|
|
117
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
118
|
+
HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
|
|
119
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
120
|
+
PERMISSION_DECISION_REASON_KEY: CORRECTIVE_MESSAGE,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
print(json.dumps(deny_payload))
|
|
124
|
+
sys.stdout.flush()
|
|
125
|
+
sys.exit(0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -345,7 +345,7 @@ def build_deny_payload(deny_reason: str) -> dict[str, object]:
|
|
|
345
345
|
"only the current state. For example:\n"
|
|
346
346
|
' BAD: "Uses X instead of Y" → GOOD: "Uses X"\n'
|
|
347
347
|
' BAD: "Previously configured via Z" → GOOD: "Configured via Z"\n'
|
|
348
|
-
"See ~/.claude/rules/
|
|
348
|
+
"See ~/.claude/rules/doc-prose-cuts.md for full rules."
|
|
349
349
|
),
|
|
350
350
|
},
|
|
351
351
|
"systemMessage": "Agent wrote comparative/historical language - describe current state only",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Stop-hook dispatcher that hosts the
|
|
2
|
+
"""Stop-hook dispatcher that hosts the six Stop-chain hooks in one process.
|
|
3
3
|
|
|
4
4
|
Reads the Stop payload from stdin once, runs each hosted hook in registration
|
|
5
5
|
order via the shared hosted-hook runner, and emits the first block decision the
|
|
@@ -212,16 +212,15 @@ def test_additional_context_is_collected_from_deciding_hooks() -> None:
|
|
|
212
212
|
assert aggregated.all_additional_context == ["see docs/runbook.md"]
|
|
213
213
|
|
|
214
214
|
|
|
215
|
-
def
|
|
215
|
+
def test_powershell_selects_the_shared_hooks_in_registration_order() -> None:
|
|
216
216
|
"""Selecting for PowerShell yields the shared hooks in registration order."""
|
|
217
217
|
powershell_paths = [
|
|
218
218
|
each_entry.script_relative_path
|
|
219
219
|
for each_entry in select_applicable_entries(POWERSHELL_TOOL_NAME)
|
|
220
220
|
]
|
|
221
221
|
assert powershell_paths == [
|
|
222
|
+
"blocking/unscoped_search_blocker.py",
|
|
222
223
|
"blocking/pii_prevention_blocker.py",
|
|
223
|
-
"blocking/verified_commit_gate.py",
|
|
224
|
-
"blocking/verdict_directory_write_blocker.py",
|
|
225
224
|
"blocking/code_review_push_gate.py",
|
|
226
225
|
"blocking/code_review_pr_create_gate.py",
|
|
227
226
|
"blocking/code_review_stamp_directory_write_blocker.py",
|
|
@@ -43,6 +43,12 @@ store_module = _load_module("code_review_stamp_store")
|
|
|
43
43
|
deny_module = _load_module("code_review_gate_deny")
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
@pytest.fixture(autouse=True)
|
|
47
|
+
def enable_code_review_enforcement(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
48
|
+
"""Behavior tests exercise the gates with enforcement on."""
|
|
49
|
+
monkeypatch.setattr(gate_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", True)
|
|
50
|
+
|
|
51
|
+
|
|
46
52
|
def _run_git(repository_directory: Path, *git_arguments: str) -> None:
|
|
47
53
|
subprocess.run(
|
|
48
54
|
["git", "-C", str(repository_directory), *git_arguments],
|
|
@@ -183,3 +189,11 @@ def test_build_deny_payload_shape() -> None:
|
|
|
183
189
|
deny_payload = deny_module.build_code_review_deny_payload("blocked reason")
|
|
184
190
|
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
185
191
|
assert deny_payload["hookSpecificOutput"]["permissionDecisionReason"] == "blocked reason"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_enforcement_off_allows_pr_create_without_stamp(
|
|
195
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
196
|
+
) -> None:
|
|
197
|
+
monkeypatch.setattr(gate_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", False)
|
|
198
|
+
work_directory = _prepared_repo(monkeypatch, tmp_path)
|
|
199
|
+
assert gate_module.deny_reason_for_directory(str(work_directory)) is None
|
|
@@ -46,6 +46,12 @@ gate_module = _load_module("code_review_push_gate")
|
|
|
46
46
|
store_module = _load_module("code_review_stamp_store")
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
@pytest.fixture(autouse=True)
|
|
50
|
+
def enable_code_review_enforcement(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
51
|
+
"""Behavior tests exercise the gates with enforcement on."""
|
|
52
|
+
monkeypatch.setattr(gate_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", True)
|
|
53
|
+
|
|
54
|
+
|
|
49
55
|
def _run_git(repository_directory: Path, *git_arguments: str) -> None:
|
|
50
56
|
subprocess.run(
|
|
51
57
|
["git", "-C", str(repository_directory), *git_arguments],
|
|
@@ -187,3 +193,13 @@ def test_code_review_skip_marker_allows_push(
|
|
|
187
193
|
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
188
194
|
emitted = _run_main(monkeypatch, f"git push origin main {BYPASS_MARKER}", work_directory)
|
|
189
195
|
assert emitted == ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_enforcement_off_allows_push_without_stamp(
|
|
199
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
200
|
+
) -> None:
|
|
201
|
+
monkeypatch.setattr(gate_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", False)
|
|
202
|
+
work_directory = _prepared_code_repo(monkeypatch, tmp_path)
|
|
203
|
+
assert gate_module.deny_reason_for_directory(str(work_directory)) is None
|
|
204
|
+
emitted = _run_main(monkeypatch, "git push origin main", work_directory)
|
|
205
|
+
assert emitted == ""
|
|
@@ -53,6 +53,12 @@ def _load_module(module_name: str) -> ModuleType:
|
|
|
53
53
|
blocker_module = _load_module("code_review_stamp_directory_write_blocker")
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
@pytest.fixture(autouse=True)
|
|
57
|
+
def enable_code_review_enforcement(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
58
|
+
"""Behavior tests exercise the write-blocker with enforcement on."""
|
|
59
|
+
monkeypatch.setattr(blocker_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", True)
|
|
60
|
+
|
|
61
|
+
|
|
56
62
|
def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None:
|
|
57
63
|
home_text = str(fake_home)
|
|
58
64
|
monkeypatch.setenv("HOME", home_text)
|
|
@@ -178,3 +184,16 @@ def test_chr_chain_obfuscated_stamp_path_is_blocked() -> None:
|
|
|
178
184
|
|
|
179
185
|
def test_benign_decode_to_other_path_is_not_blocked() -> None:
|
|
180
186
|
assert not blocker_module.references_stamp_directory(_hex_forge(BENIGN_OBFUSCATION_PATH))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_enforcement_off_allows_stamp_directory_access(
|
|
190
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
191
|
+
) -> None:
|
|
192
|
+
monkeypatch.setattr(blocker_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", False)
|
|
193
|
+
decision = blocker_module.decision_for_payload(
|
|
194
|
+
{
|
|
195
|
+
"tool_name": "Bash",
|
|
196
|
+
"tool_input": {"command": "rm -rf ~/.claude/code-review-stamps"},
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
assert decision is None
|