claude-dev-env 1.0.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/LICENSE +21 -0
- package/README.md +219 -0
- package/agents/agent-writer.md +157 -0
- package/agents/clasp-deployment-orchestrator.md +609 -0
- package/agents/clean-coder.md +295 -0
- package/agents/code-quality-agent.md +40 -0
- package/agents/code-standards-agent.md +93 -0
- package/agents/config-centralizer.md +686 -0
- package/agents/config-extraction-agent.md +225 -0
- package/agents/doc-orchestrator.md +47 -0
- package/agents/docs-agent.md +112 -0
- package/agents/docx-agent.md +211 -0
- package/agents/git-commit-crafter.md +100 -0
- package/agents/magic-value-eliminator-agent.md +72 -0
- package/agents/mandatory-agent-workflow-agent.md +88 -0
- package/agents/parallel-workflow-coordinator.md +779 -0
- package/agents/pdf-agent.md +302 -0
- package/agents/plan-executor.md +226 -0
- package/agents/pr-description-writer.md +87 -0
- package/agents/project-context-loader.md +238 -0
- package/agents/project-docs-analyzer.md +54 -0
- package/agents/project-structure-organizer-agent.md +72 -0
- package/agents/readability-review-agent.md +76 -0
- package/agents/refactoring-specialist.md +69 -0
- package/agents/right-sized-engineer.md +129 -0
- package/agents/session-continuity-manager.md +53 -0
- package/agents/skill-to-agent-converter.md +371 -0
- package/agents/skill-writer-agent.md +470 -0
- package/agents/stub-detector-agent.md +140 -0
- package/agents/tdd-test-writer.md +62 -0
- package/agents/test-data-builder.md +68 -0
- package/agents/tooling-builder.md +78 -0
- package/agents/user-docs-writer.md +67 -0
- package/agents/validation-expert.md +71 -0
- package/agents/workflow-visual-documenter.md +82 -0
- package/agents/xlsx-agent.md +169 -0
- package/bin/install.mjs +256 -0
- package/commands/commit.md +28 -0
- package/commands/docupdate.md +322 -0
- package/commands/implement.md +102 -0
- package/commands/initialize.md +91 -0
- package/commands/plan.md +63 -0
- package/commands/pr-comments.md +47 -0
- package/commands/readability-review.md +20 -0
- package/commands/review-plan.md +7 -0
- package/commands/right-size.md +15 -0
- package/commands/stubcheck.md +89 -0
- package/commands/sum.md +30 -0
- package/docs/CODE_RULES.md +186 -0
- package/docs/DJANGO_PATTERNS.md +80 -0
- package/docs/REACT_PATTERNS.md +185 -0
- package/docs/TEST_QUALITY.md +104 -0
- package/hooks/advisory/migration-safety-advisor.py +49 -0
- package/hooks/advisory/refactor-guard.py +205 -0
- package/hooks/blocking/block-main-commit.py +168 -0
- package/hooks/blocking/code-rules-enforcer.py +549 -0
- package/hooks/blocking/destructive-command-blocker.py +107 -0
- package/hooks/blocking/docker-settings-guard.py +44 -0
- package/hooks/blocking/hedging-language-blocker.py +130 -0
- package/hooks/blocking/parallel-task-blocker.py +69 -0
- package/hooks/blocking/pr-description-enforcer.py +87 -0
- package/hooks/blocking/pyautogui-scroll-blocker.py +74 -0
- package/hooks/blocking/sensitive-file-protector.py +70 -0
- package/hooks/blocking/tdd-enforcer.py +62 -0
- package/hooks/blocking/test-preflight-check.py +343 -0
- package/hooks/blocking/write-existing-file-blocker.py +63 -0
- package/hooks/git-hooks/post-commit.py +103 -0
- package/hooks/github-action/test_workflow.py +33 -0
- package/hooks/hooks.json +246 -0
- package/hooks/lifecycle/config-change-guard.py +84 -0
- package/hooks/lifecycle/session-end-cleanup.py +59 -0
- package/hooks/notification/attention-needed-notify.py +63 -0
- package/hooks/notification/claude-notification-handler.py +59 -0
- package/hooks/notification/notification_utils.py +206 -0
- package/hooks/rewrite-plugin-paths.py +116 -0
- package/hooks/session/bulk-edit-reminder.py +30 -0
- package/hooks/session/code-rules-reminder.py +97 -0
- package/hooks/session/compact-context-reinject.py +39 -0
- package/hooks/session/hook-structure-context.py +140 -0
- package/hooks/session/plugin-data-dir-cleanup.py +39 -0
- package/hooks/validation/code-style-validator.py +145 -0
- package/hooks/validation/e2e-test-validator.py +142 -0
- package/hooks/validation/hook-format-validator.py +66 -0
- package/hooks/validation/mypy_validator.py +180 -0
- package/hooks/validators/README.md +125 -0
- package/hooks/validators/VALIDATION_REPORT.md +287 -0
- package/hooks/validators/__init__.py +19 -0
- package/hooks/validators/abbreviation_checks.py +82 -0
- package/hooks/validators/code_quality_checks.py +133 -0
- package/hooks/validators/comment_checks.py +188 -0
- package/hooks/validators/file_structure_checks.py +182 -0
- package/hooks/validators/git_checks.py +107 -0
- package/hooks/validators/health_check.py +214 -0
- package/hooks/validators/magic_value_checks.py +81 -0
- package/hooks/validators/mypy_integration.py +52 -0
- package/hooks/validators/output_formatter.py +266 -0
- package/hooks/validators/pr_reference_checks.py +72 -0
- package/hooks/validators/python_antipattern_checks.py +110 -0
- package/hooks/validators/python_style_checks.py +364 -0
- package/hooks/validators/react_checks.py +90 -0
- package/hooks/validators/ruff_integration.py +80 -0
- package/hooks/validators/run_all_validators.py +772 -0
- package/hooks/validators/security_checks.py +135 -0
- package/hooks/validators/test_abbreviation_checks.py +76 -0
- package/hooks/validators/test_bad.tsx +7 -0
- package/hooks/validators/test_code_quality_checks.py +129 -0
- package/hooks/validators/test_file_structure_checks.py +307 -0
- package/hooks/validators/test_files/01_basic_component.tsx +10 -0
- package/hooks/validators/test_files/02_component_without_react.tsx +10 -0
- package/hooks/validators/test_files/03_pure_component.tsx +10 -0
- package/hooks/validators/test_files/04_pure_component_import.tsx +10 -0
- package/hooks/validators/test_files/05_typescript_generics.tsx +14 -0
- package/hooks/validators/test_files/06_typescript_two_generics.tsx +18 -0
- package/hooks/validators/test_files/07_multiline_declaration.tsx +11 -0
- package/hooks/validators/test_files/08_error_boundary_valid.tsx +14 -0
- package/hooks/validators/test_files/09_error_boundary_with_other_class.tsx +20 -0
- package/hooks/validators/test_files/10_inheritance_chain.tsx +16 -0
- package/hooks/validators/test_files/11_ts_file.ts +10 -0
- package/hooks/validators/test_files/12_non_react_class.tsx +14 -0
- package/hooks/validators/test_files/13_functional_component.tsx +8 -0
- package/hooks/validators/test_files/14_indented_class.tsx +13 -0
- package/hooks/validators/test_files/15_getDerivedStateFromError.tsx +14 -0
- package/hooks/validators/test_files/16_mixed_components.tsx +20 -0
- package/hooks/validators/test_files/EXECUTIVE_SUMMARY.md +175 -0
- package/hooks/validators/test_files/TEST_RESULTS_TABLE.txt +60 -0
- package/hooks/validators/test_files/VALIDATION_REPORT.md +201 -0
- package/hooks/validators/test_files/async_views.py +23 -0
- package/hooks/validators/test_files/async_with_imports.py +14 -0
- package/hooks/validators/test_files/bad_inline_imports.py +37 -0
- package/hooks/validators/test_files/management/commands/cmd_01_no_debug_check.py +10 -0
- package/hooks/validators/test_files/management/commands/cmd_02_proper_debug_check.py +14 -0
- package/hooks/validators/test_files/management/commands/cmd_03_debug_check_with_return.py +14 -0
- package/hooks/validators/test_files/management/commands/cmd_04_imported_DEBUG.py +14 -0
- package/hooks/validators/test_files/management/commands/cmd_05_debug_check_in_helper.py +16 -0
- package/hooks/validators/test_files/management/commands/cmd_06_debug_check_late.py +22 -0
- package/hooks/validators/test_files/management/commands/cmd_07_positive_debug_check.py +15 -0
- package/hooks/validators/test_files/management/commands/cmd_08_debug_with_and.py +14 -0
- package/hooks/validators/test_files/not_management_command.py +10 -0
- package/hooks/validators/test_files/skip_decorators/test_01_simple_skip.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_02_pytest_skipif.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_03_unittest_skipIf.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_04_skip_with_parens.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_05_xfail.py +7 -0
- package/hooks/validators/test_files/skip_decorators/test_06_custom_skip.py +11 -0
- package/hooks/validators/test_files/skip_decorators/test_07_capital_Skip.py +8 -0
- package/hooks/validators/test_files/skip_decorators/test_08_skipUnless.py +7 -0
- package/hooks/validators/test_files/skip_decorators/test_09_pytest_mark_skip_simple.py +7 -0
- package/hooks/validators/test_files/test_async_functions.py +45 -0
- package/hooks/validators/test_files/test_purecomponent/PureComponentExample.tsx +7 -0
- package/hooks/validators/test_files/test_purecomponent/ReactPureComponentExample.tsx +7 -0
- package/hooks/validators/test_git_checks.py +295 -0
- package/hooks/validators/test_good.tsx +5 -0
- package/hooks/validators/test_health_check.py +57 -0
- package/hooks/validators/test_magic_value_checks.py +63 -0
- package/hooks/validators/test_mypy_integration.py +27 -0
- package/hooks/validators/test_output_formatter.py +150 -0
- package/hooks/validators/test_pr_reference_checks.py +41 -0
- package/hooks/validators/test_python_antipattern_checks.py +113 -0
- package/hooks/validators/test_python_style_checks.py +439 -0
- package/hooks/validators/test_react_checks.py +213 -0
- package/hooks/validators/test_results.txt +25 -0
- package/hooks/validators/test_ruff_integration.py +27 -0
- package/hooks/validators/test_run_all_validators.py +228 -0
- package/hooks/validators/test_run_all_validators_integration.py +48 -0
- package/hooks/validators/test_safety_checks.py +243 -0
- package/hooks/validators/test_security_checks.py +105 -0
- package/hooks/validators/test_test_safety_checks.py +321 -0
- package/hooks/validators/test_todo_checks.py +39 -0
- package/hooks/validators/test_type_safety_checks.py +85 -0
- package/hooks/validators/test_useless_test_checks.py +55 -0
- package/hooks/validators/test_validator_base.py +26 -0
- package/hooks/validators/test_verify_paths.py +34 -0
- package/hooks/validators/todo_checks.py +59 -0
- package/hooks/validators/type_safety_checks.py +101 -0
- package/hooks/validators/useless_test_checks.py +92 -0
- package/hooks/validators/validator_base.py +19 -0
- package/hooks/validators/verify_paths.py +57 -0
- package/hooks/workflow/auto-formatter.py +114 -0
- package/hooks/workflow/investigation-tracker-reset.py +46 -0
- package/package.json +30 -0
- package/rules/agent-spawn-protocol.md +47 -0
- package/rules/cleanup-temp-files.md +27 -0
- package/rules/code-reviews.md +11 -0
- package/rules/code-standards.md +43 -0
- package/rules/conservative-action.md +20 -0
- package/rules/context7.md +12 -0
- package/rules/explore-thoroughly.md +27 -0
- package/rules/git-workflow.md +42 -0
- package/rules/parallel-tools.md +23 -0
- package/rules/research-mode.md +23 -0
- package/rules/right-sized-engineering.md +28 -0
- package/rules/tdd.md +7 -0
- package/rules/testing.md +12 -0
- package/skills/agent-prompt/SKILL.md +102 -0
- package/skills/anthropic-plan/SKILL.md +107 -0
- package/skills/everything-search/SKILL.md +144 -0
- package/skills/ingest/SKILL.md +40 -0
- package/skills/npm-creator/SKILL.md +183 -0
- package/skills/pr-review-responder/EXAMPLES.md +590 -0
- package/skills/pr-review-responder/PRINCIPLES.md +539 -0
- package/skills/pr-review-responder/README.md +209 -0
- package/skills/pr-review-responder/SKILL.md +202 -0
- package/skills/pr-review-responder/TESTING.md +407 -0
- package/skills/pr-review-responder/scripts/respond_to_reviews.py +376 -0
- package/skills/pr-review-responder/update_skill.py +297 -0
- package/skills/prompt-generator/REFERENCE.md +150 -0
- package/skills/prompt-generator/SKILL.md +154 -0
- package/skills/readability-review/SKILL.md +127 -0
- package/skills/recall/SKILL.md +27 -0
- package/skills/remember/SKILL.md +63 -0
- package/skills/rule-audit/SKILL.md +307 -0
- package/skills/rule-creator/SKILL.md +150 -0
- package/skills/skill-writer/REFERENCE.md +246 -0
- package/skills/skill-writer/SKILL.md +270 -0
- package/skills/tdd-team/SKILL.md +128 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CODE_RULES.md enforcer - blocks code that violates mandatory rules.
|
|
4
|
+
|
|
5
|
+
Checks (all blocking):
|
|
6
|
+
1. No comments (# or // in code, excluding shebangs/type: ignore)
|
|
7
|
+
2. Imports at top (no imports inside functions)
|
|
8
|
+
3. Logging f-strings (log_* calls must use format args)
|
|
9
|
+
4. File line count (>400 blocks)
|
|
10
|
+
5. Windows API None (win32gui calls with None parameter)
|
|
11
|
+
6. Magic values (literals in function bodies)
|
|
12
|
+
7. E2E test naming (no online/offline in test names)
|
|
13
|
+
8. Constants outside config (UPPER_SNAKE = in non-config files)
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
PYTHON_EXTENSIONS = {".py"}
|
|
21
|
+
JAVASCRIPT_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx"}
|
|
22
|
+
ALL_CODE_EXTENSIONS = PYTHON_EXTENSIONS | JAVASCRIPT_EXTENSIONS
|
|
23
|
+
|
|
24
|
+
CONFIG_PATH_PATTERNS = {"config/", "config\\", "/config.", "\\config.", "settings.py"}
|
|
25
|
+
TEST_PATH_PATTERNS = {"test_", "_test.", ".spec.", "conftest", "/tests/", "\\tests\\", "/tests.py", "\\tests.py"}
|
|
26
|
+
HOOK_INFRASTRUCTURE_PATTERNS = {"/.claude/hooks/", "\\.claude\\hooks\\", "\\.claude/hooks/"}
|
|
27
|
+
WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "\\workflow\\", "_tab.py", "/states.py", "\\states.py", "/modules.py", "\\modules.py"}
|
|
28
|
+
MIGRATION_PATH_PATTERNS = {"/migrations/", "\\migrations\\"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_file_extension(file_path: str) -> str:
|
|
32
|
+
"""Extract lowercase file extension."""
|
|
33
|
+
dot_index = file_path.rfind(".")
|
|
34
|
+
if dot_index == -1:
|
|
35
|
+
return ""
|
|
36
|
+
return file_path[dot_index:].lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_hook_infrastructure(file_path: str) -> bool:
|
|
40
|
+
"""Check if file is a Claude Code hook (standalone infrastructure, not project code)."""
|
|
41
|
+
path_lower = file_path.lower().replace("\\", "/")
|
|
42
|
+
return any(pattern.replace("\\", "/") in path_lower for pattern in HOOK_INFRASTRUCTURE_PATTERNS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_config_file(file_path: str) -> bool:
|
|
46
|
+
"""Check if file is in a config directory or is a config file."""
|
|
47
|
+
path_lower = file_path.lower()
|
|
48
|
+
return any(pattern in path_lower for pattern in CONFIG_PATH_PATTERNS)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_test_file(file_path: str) -> bool:
|
|
52
|
+
"""Check if file is a test file."""
|
|
53
|
+
path_lower = file_path.lower()
|
|
54
|
+
return any(pattern in path_lower for pattern in TEST_PATH_PATTERNS)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_workflow_registry_file(file_path: str) -> bool:
|
|
58
|
+
"""Check if file is a workflow state/module registry file.
|
|
59
|
+
|
|
60
|
+
Workflow tab files and state/module registry files use UPPER_SNAKE naming
|
|
61
|
+
for StateDefinition and WorkflowModule instances by architectural convention.
|
|
62
|
+
These are module-level singletons, not misplaced literal constants.
|
|
63
|
+
"""
|
|
64
|
+
path_lower = file_path.lower().replace("\\", "/")
|
|
65
|
+
return any(pattern.replace("\\", "/") in path_lower for pattern in WORKFLOW_REGISTRY_PATTERNS)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_spec_file(file_path: str) -> bool:
|
|
69
|
+
"""Check if file is an E2E spec file."""
|
|
70
|
+
return ".spec." in file_path.lower()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_comments_python(content: str) -> list[str]:
|
|
74
|
+
"""Check for comments in Python code."""
|
|
75
|
+
issues = []
|
|
76
|
+
lines = content.split("\n")
|
|
77
|
+
|
|
78
|
+
for line_number, line in enumerate(lines, 1):
|
|
79
|
+
stripped = line.strip()
|
|
80
|
+
|
|
81
|
+
if not stripped:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if stripped.startswith("#!"):
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if stripped.startswith("# type:"):
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if stripped.startswith("# noqa"):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if stripped.startswith("# pylint:"):
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
if stripped.startswith("# pragma:"):
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
comment_index = line.find("#")
|
|
100
|
+
if comment_index != -1:
|
|
101
|
+
before_comment = line[:comment_index]
|
|
102
|
+
if not before_comment.strip().startswith(("'", '"')):
|
|
103
|
+
in_string = False
|
|
104
|
+
quote_char = None
|
|
105
|
+
for i, char in enumerate(before_comment):
|
|
106
|
+
if char in ("'", '"') and (i == 0 or before_comment[i - 1] != "\\"):
|
|
107
|
+
if not in_string:
|
|
108
|
+
in_string = True
|
|
109
|
+
quote_char = char
|
|
110
|
+
elif char == quote_char:
|
|
111
|
+
in_string = False
|
|
112
|
+
|
|
113
|
+
if not in_string:
|
|
114
|
+
comment_text = line[comment_index + 1 :].strip()
|
|
115
|
+
if comment_text and not comment_text.startswith(("type:", "noqa", "pylint:", "pragma:")):
|
|
116
|
+
issues.append(f"Line {line_number}: Comment found - refactor to self-documenting code")
|
|
117
|
+
|
|
118
|
+
if len(issues) >= 3:
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
return issues
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_comments_javascript(content: str) -> list[str]:
|
|
125
|
+
"""Check for comments in JavaScript/TypeScript code."""
|
|
126
|
+
issues = []
|
|
127
|
+
lines = content.split("\n")
|
|
128
|
+
in_multiline_comment = False
|
|
129
|
+
|
|
130
|
+
for line_number, line in enumerate(lines, 1):
|
|
131
|
+
stripped = line.strip()
|
|
132
|
+
|
|
133
|
+
if not stripped:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if in_multiline_comment:
|
|
137
|
+
if "*/" in stripped:
|
|
138
|
+
in_multiline_comment = False
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if stripped.startswith("/*"):
|
|
142
|
+
in_multiline_comment = "*/" not in stripped
|
|
143
|
+
if not stripped.startswith("/**"):
|
|
144
|
+
issues.append(f"Line {line_number}: Block comment found - refactor to self-documenting code")
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if stripped.startswith("//"):
|
|
148
|
+
if not stripped.startswith(("// @ts-", "// eslint-", "// prettier-", "/// ")):
|
|
149
|
+
issues.append(f"Line {line_number}: Comment found - refactor to self-documenting code")
|
|
150
|
+
|
|
151
|
+
if len(issues) >= 3:
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
return issues
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def extract_comment_texts(content: str, file_path: str) -> tuple[set[str], set[str]]:
|
|
158
|
+
"""Extract normalized comment text strings from content for comparison.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Tuple of (inline_comments, standalone_comments).
|
|
162
|
+
Inline comments appear after code on the same line.
|
|
163
|
+
Standalone comments are lines where the entire line is a comment.
|
|
164
|
+
"""
|
|
165
|
+
extension = get_file_extension(file_path)
|
|
166
|
+
inline_comments: set[str] = set()
|
|
167
|
+
standalone_comments: set[str] = set()
|
|
168
|
+
if not content:
|
|
169
|
+
return inline_comments, standalone_comments
|
|
170
|
+
|
|
171
|
+
lines = content.split("\n")
|
|
172
|
+
|
|
173
|
+
if extension in PYTHON_EXTENSIONS:
|
|
174
|
+
for line in lines:
|
|
175
|
+
stripped = line.strip()
|
|
176
|
+
if not stripped:
|
|
177
|
+
continue
|
|
178
|
+
if stripped.startswith("#"):
|
|
179
|
+
if stripped.startswith(("#!", "# type:", "# noqa", "# pylint:", "# pragma:")):
|
|
180
|
+
continue
|
|
181
|
+
standalone_comments.add(stripped)
|
|
182
|
+
elif "#" in line:
|
|
183
|
+
comment_index = line.find("#")
|
|
184
|
+
before_comment = line[:comment_index]
|
|
185
|
+
if not before_comment.strip().startswith(("'", '"')):
|
|
186
|
+
in_string = False
|
|
187
|
+
quote_char = None
|
|
188
|
+
for i, char in enumerate(before_comment):
|
|
189
|
+
if char in ("'", '"') and (i == 0 or before_comment[i - 1] != "\\"):
|
|
190
|
+
if not in_string:
|
|
191
|
+
in_string = True
|
|
192
|
+
quote_char = char
|
|
193
|
+
elif char == quote_char:
|
|
194
|
+
in_string = False
|
|
195
|
+
if not in_string:
|
|
196
|
+
comment_text = line[comment_index + 1 :].strip()
|
|
197
|
+
if comment_text and not comment_text.startswith(("type:", "noqa", "pylint:", "pragma:")):
|
|
198
|
+
inline_comments.add(line[comment_index:].strip())
|
|
199
|
+
|
|
200
|
+
elif extension in JAVASCRIPT_EXTENSIONS:
|
|
201
|
+
in_multiline = False
|
|
202
|
+
for line in lines:
|
|
203
|
+
stripped = line.strip()
|
|
204
|
+
if not stripped:
|
|
205
|
+
continue
|
|
206
|
+
if in_multiline:
|
|
207
|
+
if "*/" in stripped:
|
|
208
|
+
in_multiline = False
|
|
209
|
+
continue
|
|
210
|
+
if stripped.startswith("/*"):
|
|
211
|
+
in_multiline = "*/" not in stripped
|
|
212
|
+
if not stripped.startswith("/**"):
|
|
213
|
+
standalone_comments.add(stripped)
|
|
214
|
+
continue
|
|
215
|
+
if stripped.startswith("//"):
|
|
216
|
+
if not stripped.startswith(("// @ts-", "// eslint-", "// prettier-", "/// ")):
|
|
217
|
+
standalone_comments.add(stripped)
|
|
218
|
+
elif "//" in line:
|
|
219
|
+
before_slash = line[:line.index("//")]
|
|
220
|
+
if before_slash.strip():
|
|
221
|
+
inline_comments.add(stripped[stripped.index("//"):])
|
|
222
|
+
|
|
223
|
+
return inline_comments, standalone_comments
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def check_comment_changes(old_content: str, new_content: str, file_path: str) -> list[str]:
|
|
227
|
+
"""Check for comment additions or removals between old and new content.
|
|
228
|
+
|
|
229
|
+
Inline comments (after code on same line): BLOCK when added.
|
|
230
|
+
Standalone comment lines: NUDGE (print advisory) when added.
|
|
231
|
+
Existing comments being removed: BLOCK (comment preservation principle).
|
|
232
|
+
"""
|
|
233
|
+
issues: list[str] = []
|
|
234
|
+
|
|
235
|
+
old_inline, old_standalone = extract_comment_texts(old_content, file_path)
|
|
236
|
+
new_inline, new_standalone = extract_comment_texts(new_content, file_path)
|
|
237
|
+
|
|
238
|
+
added_inline = new_inline - old_inline
|
|
239
|
+
if added_inline:
|
|
240
|
+
sample = next(iter(added_inline))
|
|
241
|
+
issues.append(f"Inline comment added: {sample[:60]} - refactor to self-documenting code")
|
|
242
|
+
|
|
243
|
+
added_standalone = new_standalone - old_standalone
|
|
244
|
+
if added_standalone:
|
|
245
|
+
sample = next(iter(added_standalone))
|
|
246
|
+
print(f"[CODE_RULES advisory] Standalone comment added: {sample[:60]} - prefer self-documenting code", file=sys.stderr)
|
|
247
|
+
|
|
248
|
+
all_old = old_inline | old_standalone
|
|
249
|
+
all_new = new_inline | new_standalone
|
|
250
|
+
removed_comments = all_old - all_new
|
|
251
|
+
if removed_comments:
|
|
252
|
+
old_line_count = len([line for line in old_content.split("\n") if line.strip()])
|
|
253
|
+
new_line_count = len([line for line in new_content.split("\n") if line.strip()])
|
|
254
|
+
code_was_removed = new_line_count < old_line_count - len(removed_comments)
|
|
255
|
+
if not code_was_removed:
|
|
256
|
+
sample = next(iter(removed_comments))
|
|
257
|
+
issues.append(f"Existing comment removed: {sample[:60]} - NEVER delete existing comments")
|
|
258
|
+
|
|
259
|
+
return issues
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def check_imports_at_top(content: str) -> list[str]:
|
|
263
|
+
"""Check for imports inside functions (Python only)."""
|
|
264
|
+
issues = []
|
|
265
|
+
lines = content.split("\n")
|
|
266
|
+
inside_function = False
|
|
267
|
+
function_indent = 0
|
|
268
|
+
|
|
269
|
+
for line_number, line in enumerate(lines, 1):
|
|
270
|
+
stripped = line.strip()
|
|
271
|
+
|
|
272
|
+
if not stripped:
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
func_match = re.match(r"^(\s*)(async\s+)?def\s+\w+", line)
|
|
276
|
+
if func_match:
|
|
277
|
+
inside_function = True
|
|
278
|
+
function_indent = len(func_match.group(1)) if func_match.group(1) else 0
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
if inside_function:
|
|
282
|
+
current_indent = len(line) - len(line.lstrip())
|
|
283
|
+
if current_indent <= function_indent and stripped and not stripped.startswith(("#", "@", ")")):
|
|
284
|
+
inside_function = False
|
|
285
|
+
|
|
286
|
+
if inside_function:
|
|
287
|
+
if stripped.startswith(("import ", "from ")) and "TYPE_CHECKING" not in content[:500]:
|
|
288
|
+
issues.append(f"Line {line_number}: Import inside function - move to top of file")
|
|
289
|
+
|
|
290
|
+
if len(issues) >= 3:
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
return issues
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def check_logging_fstrings(content: str) -> list[str]:
|
|
297
|
+
"""Check for f-strings in logging calls."""
|
|
298
|
+
issues = []
|
|
299
|
+
pattern = re.compile(r'\blog_(debug|info|warning|error|critical)\s*\(\s*f["\']')
|
|
300
|
+
|
|
301
|
+
for line_number, line in enumerate(content.split("\n"), 1):
|
|
302
|
+
if pattern.search(line):
|
|
303
|
+
issues.append(f"Line {line_number}: f-string in log call - use format args instead")
|
|
304
|
+
|
|
305
|
+
if len(issues) >= 3:
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
return issues
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def check_file_line_count(content: str) -> list[str]:
|
|
312
|
+
"""Check file line count."""
|
|
313
|
+
line_count = content.count("\n") + 1
|
|
314
|
+
if line_count > 400:
|
|
315
|
+
return [f"File has {line_count} lines (max 400) - split into smaller modules"]
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def check_windows_api_none(content: str) -> list[str]:
|
|
320
|
+
"""Check for win32gui calls with None parameter."""
|
|
321
|
+
issues = []
|
|
322
|
+
pattern = re.compile(r"win32gui\.\w+\s*\([^)]*,\s*None\s*\)")
|
|
323
|
+
|
|
324
|
+
for line_number, line in enumerate(content.split("\n"), 1):
|
|
325
|
+
if pattern.search(line):
|
|
326
|
+
issues.append(f"Line {line_number}: win32gui call with None - use 0 for unused int params")
|
|
327
|
+
|
|
328
|
+
if len(issues) >= 3:
|
|
329
|
+
break
|
|
330
|
+
|
|
331
|
+
return issues
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
335
|
+
"""Check for magic values in function bodies."""
|
|
336
|
+
if is_config_file(file_path) or is_test_file(file_path):
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
issues = []
|
|
340
|
+
lines = content.split("\n")
|
|
341
|
+
inside_function = False
|
|
342
|
+
|
|
343
|
+
number_pattern = re.compile(r"(?<![.\w])(\d+\.?\d*)(?![.\w])")
|
|
344
|
+
allowed_numbers = {"0", "1", "-1", "0.0", "1.0", "2", "100"}
|
|
345
|
+
|
|
346
|
+
for line_number, line in enumerate(lines, 1):
|
|
347
|
+
stripped = line.strip()
|
|
348
|
+
|
|
349
|
+
if not stripped:
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
if re.match(r"^(async\s+)?def\s+\w+", stripped):
|
|
353
|
+
inside_function = True
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
if re.match(r"^class\s+\w+", stripped):
|
|
357
|
+
inside_function = False
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
if inside_function:
|
|
361
|
+
if "=" in stripped and stripped.split("=")[0].strip().isupper():
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
if stripped.startswith(("return", "yield", "raise")):
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
numbers_found = number_pattern.findall(stripped)
|
|
368
|
+
for number in numbers_found:
|
|
369
|
+
if number not in allowed_numbers:
|
|
370
|
+
if "range(" in stripped or "enumerate(" in stripped:
|
|
371
|
+
continue
|
|
372
|
+
if "[" in stripped and "]" in stripped:
|
|
373
|
+
continue
|
|
374
|
+
issues.append(f"Line {line_number}: Magic value {number} - extract to named constant")
|
|
375
|
+
break
|
|
376
|
+
|
|
377
|
+
if len(issues) >= 3:
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
return issues
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def check_e2e_test_naming(content: str, file_path: str) -> list[str]:
|
|
384
|
+
"""Check for online/offline in test names (spec files only)."""
|
|
385
|
+
if not is_spec_file(file_path):
|
|
386
|
+
return []
|
|
387
|
+
|
|
388
|
+
issues = []
|
|
389
|
+
pattern = re.compile(r'(test|it|describe)\s*\(\s*["\'][^"\']*\b(online|offline)\b[^"\']*["\']', re.IGNORECASE)
|
|
390
|
+
|
|
391
|
+
for line_number, line in enumerate(content.split("\n"), 1):
|
|
392
|
+
if pattern.search(line):
|
|
393
|
+
issues.append(f"Line {line_number}: Test name contains online/offline - file scope defines this")
|
|
394
|
+
|
|
395
|
+
if len(issues) >= 3:
|
|
396
|
+
break
|
|
397
|
+
|
|
398
|
+
return issues
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def is_migration_file(file_path: str) -> bool:
|
|
402
|
+
"""Check if file is a Django migration (must be self-contained)."""
|
|
403
|
+
path_lower = file_path.lower().replace("\\", "/")
|
|
404
|
+
return any(pattern.replace("\\", "/") in path_lower for pattern in MIGRATION_PATH_PATTERNS)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def check_constants_outside_config(content: str, file_path: str) -> list[str]:
|
|
408
|
+
"""Check for UPPER_SNAKE constants defined outside config files."""
|
|
409
|
+
if is_config_file(file_path):
|
|
410
|
+
return []
|
|
411
|
+
|
|
412
|
+
if is_test_file(file_path):
|
|
413
|
+
return []
|
|
414
|
+
|
|
415
|
+
if is_workflow_registry_file(file_path):
|
|
416
|
+
return []
|
|
417
|
+
|
|
418
|
+
if is_migration_file(file_path):
|
|
419
|
+
return []
|
|
420
|
+
|
|
421
|
+
issues = []
|
|
422
|
+
lines = content.split("\n")
|
|
423
|
+
inside_function = False
|
|
424
|
+
inside_class = False
|
|
425
|
+
|
|
426
|
+
constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})\s*=\s*[^=]")
|
|
427
|
+
|
|
428
|
+
for line_number, line in enumerate(lines, 1):
|
|
429
|
+
stripped = line.strip()
|
|
430
|
+
|
|
431
|
+
if not stripped:
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
if re.match(r"^(async\s+)?def\s+\w+", stripped):
|
|
435
|
+
inside_function = True
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
if re.match(r"^class\s+\w+", stripped):
|
|
439
|
+
inside_class = True
|
|
440
|
+
inside_function = False
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
indent = len(line) - len(line.lstrip())
|
|
444
|
+
if indent == 0 and stripped and not stripped.startswith(("#", "@", ")")):
|
|
445
|
+
inside_function = False
|
|
446
|
+
inside_class = False
|
|
447
|
+
|
|
448
|
+
if not inside_function and not inside_class:
|
|
449
|
+
match = constant_pattern.match(stripped)
|
|
450
|
+
if match:
|
|
451
|
+
constant_name = match.group(1)
|
|
452
|
+
if constant_name not in ("__all__",):
|
|
453
|
+
issues.append(f"Line {line_number}: Constant {constant_name} - move to config/")
|
|
454
|
+
|
|
455
|
+
if len(issues) >= 3:
|
|
456
|
+
break
|
|
457
|
+
|
|
458
|
+
return issues
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def validate_content(content: str, file_path: str, old_content: str = "") -> list[str]:
|
|
462
|
+
"""Run all applicable validators on content.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
content: The new content being written.
|
|
466
|
+
file_path: Path to the file.
|
|
467
|
+
old_content: Previous content (old_string for Edit, existing file for Write).
|
|
468
|
+
Used to detect comment additions/removals instead of flagging all comments.
|
|
469
|
+
"""
|
|
470
|
+
extension = get_file_extension(file_path)
|
|
471
|
+
all_issues = []
|
|
472
|
+
|
|
473
|
+
if extension in PYTHON_EXTENSIONS:
|
|
474
|
+
if not is_test_file(file_path):
|
|
475
|
+
all_issues.extend(check_comment_changes(old_content, content, file_path))
|
|
476
|
+
all_issues.extend(check_imports_at_top(content))
|
|
477
|
+
all_issues.extend(check_logging_fstrings(content))
|
|
478
|
+
all_issues.extend(check_windows_api_none(content))
|
|
479
|
+
all_issues.extend(check_magic_values(content, file_path))
|
|
480
|
+
all_issues.extend(check_constants_outside_config(content, file_path))
|
|
481
|
+
|
|
482
|
+
elif extension in JAVASCRIPT_EXTENSIONS:
|
|
483
|
+
if not is_test_file(file_path):
|
|
484
|
+
all_issues.extend(check_comment_changes(old_content, content, file_path))
|
|
485
|
+
all_issues.extend(check_e2e_test_naming(content, file_path))
|
|
486
|
+
|
|
487
|
+
if extension in ALL_CODE_EXTENSIONS:
|
|
488
|
+
all_issues.extend(check_file_line_count(content))
|
|
489
|
+
|
|
490
|
+
return all_issues
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def main() -> None:
|
|
494
|
+
try:
|
|
495
|
+
input_data = json.load(sys.stdin)
|
|
496
|
+
except json.JSONDecodeError:
|
|
497
|
+
sys.exit(0)
|
|
498
|
+
|
|
499
|
+
tool_name = input_data.get("tool_name", "")
|
|
500
|
+
tool_input = input_data.get("tool_input", {})
|
|
501
|
+
file_path = tool_input.get("file_path", "")
|
|
502
|
+
|
|
503
|
+
if not file_path:
|
|
504
|
+
sys.exit(0)
|
|
505
|
+
|
|
506
|
+
if is_hook_infrastructure(file_path):
|
|
507
|
+
sys.exit(0)
|
|
508
|
+
|
|
509
|
+
extension = get_file_extension(file_path)
|
|
510
|
+
if extension not in ALL_CODE_EXTENSIONS:
|
|
511
|
+
sys.exit(0)
|
|
512
|
+
|
|
513
|
+
old_content = ""
|
|
514
|
+
if tool_name == "Edit":
|
|
515
|
+
content = tool_input.get("new_string", "")
|
|
516
|
+
old_content = tool_input.get("old_string", "")
|
|
517
|
+
else:
|
|
518
|
+
content = tool_input.get("content", "") or tool_input.get("new_string", "")
|
|
519
|
+
try:
|
|
520
|
+
with open(file_path, "r", encoding="utf-8") as existing_file:
|
|
521
|
+
old_content = existing_file.read()
|
|
522
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
|
523
|
+
old_content = ""
|
|
524
|
+
|
|
525
|
+
if old_content:
|
|
526
|
+
sys.exit(0)
|
|
527
|
+
|
|
528
|
+
if not content:
|
|
529
|
+
sys.exit(0)
|
|
530
|
+
|
|
531
|
+
issues = validate_content(content, file_path, old_content)
|
|
532
|
+
|
|
533
|
+
if issues:
|
|
534
|
+
issue_list = "; ".join(issues[:10])
|
|
535
|
+
result = {
|
|
536
|
+
"hookSpecificOutput": {
|
|
537
|
+
"hookEventName": "PreToolUse",
|
|
538
|
+
"permissionDecision": "deny",
|
|
539
|
+
"permissionDecisionReason": f"BLOCKED: [CODE_RULES] {len(issues)} violation(s): {issue_list}",
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
print(json.dumps(result))
|
|
543
|
+
sys.stdout.flush()
|
|
544
|
+
|
|
545
|
+
sys.exit(0)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
if __name__ == "__main__":
|
|
549
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
|
|
8
|
+
|
|
9
|
+
# Projects where git reset --hard is explicitly allowed by the user.
|
|
10
|
+
# Add your own project paths here, e.g.:
|
|
11
|
+
# os.path.normpath("C:/Users/you/your-project"),
|
|
12
|
+
ALLOW_GIT_RESET_HARD_PROJECTS: list[str] = []
|
|
13
|
+
|
|
14
|
+
DESTRUCTIVE_BASH_PATTERNS = [
|
|
15
|
+
(re.compile(r'\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r', re.IGNORECASE), "rm -rf (destructive recursive forced delete)"),
|
|
16
|
+
(re.compile(r'\brm\s+--recursive\b.*--force\b|\brm\s+--force\b.*--recursive\b', re.IGNORECASE), "rm --recursive --force (destructive recursive forced delete)"),
|
|
17
|
+
(re.compile(r'\brm\s+-r\s+([/~]|\.(?:\s|$)|\$HOME)', re.IGNORECASE), "rm -r on broad path (/, ~, $HOME, .)"),
|
|
18
|
+
(re.compile(r'\bmkfs\b', re.IGNORECASE), "mkfs (format filesystem)"),
|
|
19
|
+
(re.compile(r'\bdd\s+.*\bif=.*\bof=/dev/', re.IGNORECASE), "dd raw disk write"),
|
|
20
|
+
(re.compile(r'\bgit\s+reset\s+--hard\b', re.IGNORECASE), "git reset --hard (discards uncommitted work)"),
|
|
21
|
+
(re.compile(r'\bgit\s+push\s+--force(?!-with-lease)\b', re.IGNORECASE), "git push --force (rewrites remote history)"),
|
|
22
|
+
(re.compile(r'\bgit\s+push\s+-f\b', re.IGNORECASE), "git push -f (rewrites remote history)"),
|
|
23
|
+
(re.compile(r'\bgit\s+clean\s+(-fd|-df)\b', re.IGNORECASE), "git clean -fd (deletes untracked files and dirs)"),
|
|
24
|
+
(re.compile(r'\bgit\s+clean\s+-f\b', re.IGNORECASE), "git clean -f (deletes untracked files)"),
|
|
25
|
+
(re.compile(r'\bDROP\s+TABLE\b', re.IGNORECASE), "DROP TABLE (destroys database table)"),
|
|
26
|
+
(re.compile(r'\bDROP\s+DATABASE\b', re.IGNORECASE), "DROP DATABASE (destroys entire database)"),
|
|
27
|
+
(re.compile(r'\bTRUNCATE\s+TABLE\b', re.IGNORECASE), "TRUNCATE TABLE (removes all table rows)"),
|
|
28
|
+
(re.compile(r'\bgh\s+api\b.*/(comments|reviews)\b.*-X\s+POST', re.IGNORECASE), "gh api comment/review POST (visible to others)"),
|
|
29
|
+
(re.compile(r'\bgh\s+pr\s+comment\b', re.IGNORECASE), "gh pr comment (visible to others)"),
|
|
30
|
+
(re.compile(r'\bgh\s+pr\s+review\b', re.IGNORECASE), "gh pr review (visible to others)"),
|
|
31
|
+
(re.compile(r'\bgh\s+issue\s+comment\b', re.IGNORECASE), "gh issue comment (visible to others)"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def find_destructive_pattern(command: str) -> str | None:
|
|
35
|
+
for pattern_regex, pattern_description in DESTRUCTIVE_BASH_PATTERNS:
|
|
36
|
+
if pattern_regex.search(command):
|
|
37
|
+
return pattern_description
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def targets_only_claude_directory(command: str) -> bool:
|
|
42
|
+
"""Check if rm command targets only paths under ~/.claude/."""
|
|
43
|
+
all_rm_target_paths = re.findall(
|
|
44
|
+
r'(?:rm\s+(?:-\w+\s+)*)("[^"]+"|\'[^\']+\'|\S+)',
|
|
45
|
+
command,
|
|
46
|
+
)
|
|
47
|
+
if not all_rm_target_paths:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
for each_raw_path in all_rm_target_paths:
|
|
51
|
+
each_stripped_path = each_raw_path.strip("\"'")
|
|
52
|
+
each_cleaned_path = re.split(r'[;&|]', each_stripped_path)[0]
|
|
53
|
+
if each_cleaned_path != each_stripped_path:
|
|
54
|
+
return False
|
|
55
|
+
each_resolved_path = os.path.normpath(os.path.expanduser(each_cleaned_path))
|
|
56
|
+
if not each_resolved_path.startswith(CLAUDE_DIRECTORY_PATH):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
try:
|
|
64
|
+
hook_input = json.load(sys.stdin)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
sys.exit(0)
|
|
67
|
+
|
|
68
|
+
tool_name = hook_input.get("tool_name", "")
|
|
69
|
+
tool_input = hook_input.get("tool_input", {})
|
|
70
|
+
|
|
71
|
+
if tool_name != "Bash":
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
command = tool_input.get("command", "")
|
|
75
|
+
matched_description = find_destructive_pattern(command)
|
|
76
|
+
|
|
77
|
+
if matched_description is not None and targets_only_claude_directory(command):
|
|
78
|
+
sys.exit(0)
|
|
79
|
+
|
|
80
|
+
# Allow git reset --hard in explicitly approved projects (case-insensitive for Windows drive letters)
|
|
81
|
+
if matched_description is not None and "git reset --hard" in matched_description:
|
|
82
|
+
cwd = os.path.normpath(os.getcwd()).lower()
|
|
83
|
+
command_lower = command.lower()
|
|
84
|
+
for allowed_project in ALLOW_GIT_RESET_HARD_PROJECTS:
|
|
85
|
+
allowed_lower = allowed_project.lower()
|
|
86
|
+
if cwd.startswith(allowed_lower):
|
|
87
|
+
sys.exit(0)
|
|
88
|
+
# Also check the cd target in the command itself
|
|
89
|
+
for path_match in re.findall(r'cd\s+"([^"]+)"', command):
|
|
90
|
+
if os.path.normpath(path_match).lower().startswith(allowed_lower):
|
|
91
|
+
sys.exit(0)
|
|
92
|
+
|
|
93
|
+
if matched_description is not None:
|
|
94
|
+
ask_response = {
|
|
95
|
+
"hookSpecificOutput": {
|
|
96
|
+
"hookEventName": "PreToolUse",
|
|
97
|
+
"permissionDecision": "ask",
|
|
98
|
+
"permissionDecisionReason": f"DESTRUCTIVE: {matched_description}. Requires explicit user approval."
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
print(json.dumps(ask_response))
|
|
102
|
+
|
|
103
|
+
sys.exit(0)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PreToolUse hook: blocks direct edits to Docker settings files.
|
|
4
|
+
Hooks must be added to the Windows settings.json instead.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
BLOCKED_PATHS = [
|
|
11
|
+
"settings-docker.json",
|
|
12
|
+
"settings-docker",
|
|
13
|
+
"docker/settings-docker.json",
|
|
14
|
+
".claude/docker/settings-docker.json",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
try:
|
|
20
|
+
stdin_data = sys.stdin.read()
|
|
21
|
+
hook_input = json.loads(stdin_data)
|
|
22
|
+
tool_input = hook_input.get("tool_input", {})
|
|
23
|
+
file_path = tool_input.get("file_path", "")
|
|
24
|
+
|
|
25
|
+
for blocked in BLOCKED_PATHS:
|
|
26
|
+
if file_path.endswith(blocked):
|
|
27
|
+
message = "BLOCKED: Docker settings edit denied. Edit your user settings.json instead."
|
|
28
|
+
result = {
|
|
29
|
+
"hookSpecificOutput": {
|
|
30
|
+
"hookEventName": "PreToolUse",
|
|
31
|
+
"permissionDecision": "deny",
|
|
32
|
+
"permissionDecisionReason": message
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
print(json.dumps(result))
|
|
36
|
+
sys.exit(0)
|
|
37
|
+
except SystemExit:
|
|
38
|
+
raise
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|