claude-dev-env 1.17.1 → 1.17.5
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/bin/install.mjs +145 -62
- package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +8 -6
- package/hooks/blocking/content-search-to-zoekt-redirector.py +55 -0
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +25 -0
- package/hooks/blocking/content_search_zoekt_block_payload.py +17 -0
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +24 -0
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +131 -0
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
- package/hooks/blocking/destructive-command-blocker.py +53 -4
- package/hooks/blocking/prompt_workflow_validate.py +218 -0
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +54 -0
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +51 -0
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +102 -0
- package/hooks/blocking/test_destructive_command_blocker.py +108 -0
- package/hooks/blocking/test_prompt_workflow_validate.py +339 -0
- package/hooks/hooks.json +0 -5
- package/package.json +4 -1
- package/skills/prompt-generator/ARCHITECTURE.md +2 -1
- package/skills/prompt-generator/REFERENCE.md +9 -11
- package/skills/prompt-generator/SKILL.md +41 -48
- package/skills/prompt-generator/TARGET_OUTPUT.md +25 -18
- package/skills/rule-audit/SKILL.md +2 -2
- package/hooks/blocking/prompt-workflow-stop-guard.py +0 -217
- package/hooks/blocking/test_prompt_workflow_stop_guard.py +0 -261
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
import datetime
|
|
2
3
|
import json
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
5
6
|
import sys
|
|
7
|
+
from pathlib import Path
|
|
6
8
|
|
|
7
9
|
CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
|
|
8
10
|
|
|
@@ -25,10 +27,6 @@ DESTRUCTIVE_BASH_PATTERNS = [
|
|
|
25
27
|
(re.compile(r'\bDROP\s+TABLE\b', re.IGNORECASE), "DROP TABLE (destroys database table)"),
|
|
26
28
|
(re.compile(r'\bDROP\s+DATABASE\b', re.IGNORECASE), "DROP DATABASE (destroys entire database)"),
|
|
27
29
|
(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
30
|
]
|
|
33
31
|
|
|
34
32
|
def find_destructive_pattern(command: str) -> str | None:
|
|
@@ -38,6 +36,51 @@ def find_destructive_pattern(command: str) -> str | None:
|
|
|
38
36
|
return None
|
|
39
37
|
|
|
40
38
|
|
|
39
|
+
def find_redirected_gh_pattern(command: str) -> str | None:
|
|
40
|
+
redirected_gh_bash_patterns = [
|
|
41
|
+
(re.compile(r'\bgh\s+api\b.*/(comments|reviews)\b.*-X\s+POST', re.IGNORECASE), "gh api comment/review POST"),
|
|
42
|
+
(re.compile(r'\bgh\s+pr\s+comment\b', re.IGNORECASE), "gh pr comment"),
|
|
43
|
+
(re.compile(r'\bgh\s+pr\s+review\b', re.IGNORECASE), "gh pr review"),
|
|
44
|
+
(re.compile(r'\bgh\s+issue\s+comment\b', re.IGNORECASE), "gh issue comment"),
|
|
45
|
+
]
|
|
46
|
+
for pattern_regex, pattern_description in redirected_gh_bash_patterns:
|
|
47
|
+
if pattern_regex.search(command):
|
|
48
|
+
return pattern_description
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _append_destructive_gate_log_entry(brief_label: str, full_reason: str) -> None:
|
|
53
|
+
destructive_gate_log_path = Path.home() / ".claude" / "logs" / "destructive-gate.log"
|
|
54
|
+
try:
|
|
55
|
+
destructive_gate_log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
timestamp_iso = datetime.datetime.now().isoformat()
|
|
57
|
+
log_entry = f"{timestamp_iso}\t{brief_label}\t{full_reason}\n"
|
|
58
|
+
with destructive_gate_log_path.open("a", encoding="utf-8") as log_handle:
|
|
59
|
+
log_handle.write(log_entry)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_silent_gh_deny_response(matched_description: str) -> dict:
|
|
65
|
+
gh_gate_user_facing_prefix = "[gh-gate]"
|
|
66
|
+
brief_label = f"blocked redirected {matched_description}"
|
|
67
|
+
full_reason = (
|
|
68
|
+
f"GH-REDIRECT GATE: {matched_description} already executed by "
|
|
69
|
+
"gh-wsl-to-windows-redirect.py via PowerShell. Denying the original "
|
|
70
|
+
"Bash call prevents duplicate execution."
|
|
71
|
+
)
|
|
72
|
+
_append_destructive_gate_log_entry(brief_label, full_reason)
|
|
73
|
+
return {
|
|
74
|
+
"hookSpecificOutput": {
|
|
75
|
+
"hookEventName": "PreToolUse",
|
|
76
|
+
"permissionDecision": "deny",
|
|
77
|
+
"permissionDecisionReason": full_reason,
|
|
78
|
+
},
|
|
79
|
+
"suppressOutput": True,
|
|
80
|
+
"systemMessage": f"{gh_gate_user_facing_prefix} {brief_label}",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
41
84
|
def targets_only_claude_directory(command: str) -> bool:
|
|
42
85
|
"""Check if rm command targets only paths under ~/.claude/."""
|
|
43
86
|
all_rm_target_paths = re.findall(
|
|
@@ -72,6 +115,12 @@ def main() -> None:
|
|
|
72
115
|
sys.exit(0)
|
|
73
116
|
|
|
74
117
|
command = tool_input.get("command", "")
|
|
118
|
+
|
|
119
|
+
redirected_gh_description = find_redirected_gh_pattern(command)
|
|
120
|
+
if redirected_gh_description is not None:
|
|
121
|
+
print(json.dumps(_build_silent_gh_deny_response(redirected_gh_description)))
|
|
122
|
+
sys.exit(0)
|
|
123
|
+
|
|
75
124
|
matched_description = find_destructive_pattern(command)
|
|
76
125
|
|
|
77
126
|
if matched_description is not None and targets_only_claude_directory(command):
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared prompt-workflow validator callable from tests, CLI, and the Stop hook.
|
|
3
|
+
|
|
4
|
+
Public API
|
|
5
|
+
----------
|
|
6
|
+
validate_prompt_workflow(assistant_message, user_context="")
|
|
7
|
+
Returns a ``ValidationResult`` with allowed/blocked status and reasons.
|
|
8
|
+
|
|
9
|
+
CLI
|
|
10
|
+
---
|
|
11
|
+
python prompt_workflow_validate.py path/to/draft.md
|
|
12
|
+
cat draft.md | python prompt_workflow_validate.py
|
|
13
|
+
|
|
14
|
+
Exit codes match hook conventions: 0 allowed, 2 blocked.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from prompt_workflow_gate_core import (
|
|
24
|
+
find_ambiguous_scope_terms,
|
|
25
|
+
find_negative_keywords_in_fenced_xml,
|
|
26
|
+
has_checklist_container,
|
|
27
|
+
has_debug_intent,
|
|
28
|
+
has_internal_object_leak,
|
|
29
|
+
is_prompt_workflow_response,
|
|
30
|
+
missing_checklist_rows,
|
|
31
|
+
missing_context_control_signals,
|
|
32
|
+
missing_required_xml_sections,
|
|
33
|
+
missing_scope_anchors,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ValidationReason:
|
|
38
|
+
code: str
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ValidationResult:
|
|
44
|
+
allowed: bool
|
|
45
|
+
reasons: tuple[ValidationReason, ...] = field(default_factory=tuple)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def reason_messages(self) -> list[str]:
|
|
49
|
+
return [each_reason.message for each_reason in self.reasons]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def reason_codes(self) -> list[str]:
|
|
53
|
+
return [each_reason.code for each_reason in self.reasons]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _blocked(code: str, message: str) -> ValidationResult:
|
|
57
|
+
return ValidationResult(
|
|
58
|
+
allowed=False,
|
|
59
|
+
reasons=(ValidationReason(code=code, message=message),),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_internal_leak(
|
|
64
|
+
assistant_message: str,
|
|
65
|
+
debug_requested: bool,
|
|
66
|
+
) -> ValidationResult | None:
|
|
67
|
+
if not has_internal_object_leak(assistant_message) or debug_requested:
|
|
68
|
+
return None
|
|
69
|
+
return _blocked(
|
|
70
|
+
code="internal_object_leak",
|
|
71
|
+
message=(
|
|
72
|
+
"Raw internal refinement object leakage detected. "
|
|
73
|
+
"Return sanitized user-facing output unless explicit debug intent is present."
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _check_required_sections(assistant_message: str) -> ValidationResult | None:
|
|
79
|
+
missing_sections = missing_required_xml_sections(assistant_message)
|
|
80
|
+
if not missing_sections:
|
|
81
|
+
return None
|
|
82
|
+
return _blocked(
|
|
83
|
+
code="missing_xml_sections",
|
|
84
|
+
message=(
|
|
85
|
+
"Fenced XML artifact missing required sections: "
|
|
86
|
+
+ ", ".join(missing_sections)
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_checklist_rows(assistant_message: str) -> ValidationResult | None:
|
|
92
|
+
if not has_checklist_container(assistant_message):
|
|
93
|
+
return None
|
|
94
|
+
missing_rows = missing_checklist_rows(assistant_message)
|
|
95
|
+
if not missing_rows:
|
|
96
|
+
return None
|
|
97
|
+
return _blocked(
|
|
98
|
+
code="missing_checklist_rows",
|
|
99
|
+
message=("Deterministic checklist rows missing: " + ", ".join(missing_rows)),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_scope_anchors(assistant_message: str) -> ValidationResult | None:
|
|
104
|
+
missing_anchors = missing_scope_anchors(assistant_message)
|
|
105
|
+
if not missing_anchors:
|
|
106
|
+
return None
|
|
107
|
+
return _blocked(
|
|
108
|
+
code="missing_scope_anchors",
|
|
109
|
+
message=("Required scope anchors missing: " + ", ".join(missing_anchors)),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_context_signals(assistant_message: str) -> ValidationResult | None:
|
|
114
|
+
missing_signals = missing_context_control_signals(assistant_message)
|
|
115
|
+
if not missing_signals:
|
|
116
|
+
return None
|
|
117
|
+
return _blocked(
|
|
118
|
+
code="missing_context_signals",
|
|
119
|
+
message=(
|
|
120
|
+
"Runtime context-control preamble missing. "
|
|
121
|
+
"Include the two required lines from prompt-workflow-context-controls "
|
|
122
|
+
"(minimal instruction layer and on-demand skill loading)."
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_ambiguous_scope(assistant_message: str) -> ValidationResult | None:
|
|
128
|
+
ambiguous_terms = find_ambiguous_scope_terms(assistant_message)
|
|
129
|
+
if not ambiguous_terms:
|
|
130
|
+
return None
|
|
131
|
+
return _blocked(
|
|
132
|
+
code="ambiguous_scope",
|
|
133
|
+
message=("Ambiguous scope phrasing detected: " + ", ".join(ambiguous_terms)),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _check_negative_keywords(assistant_message: str) -> ValidationResult | None:
|
|
138
|
+
violations = find_negative_keywords_in_fenced_xml(assistant_message)
|
|
139
|
+
if not violations:
|
|
140
|
+
return None
|
|
141
|
+
violation_descriptions = [
|
|
142
|
+
f" line {each_violation['line_number']}: "
|
|
143
|
+
f'"{each_violation["keyword"]}" in: {each_violation["line_text"]}'
|
|
144
|
+
for each_violation in violations
|
|
145
|
+
]
|
|
146
|
+
return _blocked(
|
|
147
|
+
code="negative_keywords_in_artifact",
|
|
148
|
+
message=(
|
|
149
|
+
"Banned negative keywords found inside fenced XML artifact. "
|
|
150
|
+
"Rephrase as positive directives (what TO do, not what to avoid):\n"
|
|
151
|
+
+ "\n".join(violation_descriptions)
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def validate_prompt_workflow(
|
|
157
|
+
assistant_message: str,
|
|
158
|
+
user_context: str = "",
|
|
159
|
+
) -> ValidationResult:
|
|
160
|
+
"""Run all prompt-workflow gates on *assistant_message*.
|
|
161
|
+
|
|
162
|
+
Returns ``ValidationResult.allowed == True`` when every gate passes.
|
|
163
|
+
The first failing gate short-circuits and its reason is returned.
|
|
164
|
+
"""
|
|
165
|
+
allowed_result = ValidationResult(allowed=True)
|
|
166
|
+
|
|
167
|
+
if not assistant_message.strip():
|
|
168
|
+
return allowed_result
|
|
169
|
+
|
|
170
|
+
debug_requested = has_debug_intent(user_context)
|
|
171
|
+
|
|
172
|
+
leak_result = _check_internal_leak(assistant_message, debug_requested)
|
|
173
|
+
if leak_result is not None:
|
|
174
|
+
return leak_result
|
|
175
|
+
|
|
176
|
+
if not is_prompt_workflow_response(assistant_message):
|
|
177
|
+
return allowed_result
|
|
178
|
+
|
|
179
|
+
workflow_checks = (
|
|
180
|
+
_check_required_sections,
|
|
181
|
+
_check_checklist_rows,
|
|
182
|
+
_check_scope_anchors,
|
|
183
|
+
_check_context_signals,
|
|
184
|
+
_check_ambiguous_scope,
|
|
185
|
+
_check_negative_keywords,
|
|
186
|
+
)
|
|
187
|
+
for each_check in workflow_checks:
|
|
188
|
+
gate_result = each_check(assistant_message)
|
|
189
|
+
if gate_result is not None:
|
|
190
|
+
return gate_result
|
|
191
|
+
|
|
192
|
+
return allowed_result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main() -> None:
|
|
196
|
+
blocked_exit_code: int = 2
|
|
197
|
+
allowed_exit_code: int = 0
|
|
198
|
+
|
|
199
|
+
if len(sys.argv) > 1:
|
|
200
|
+
file_path = Path(sys.argv[1])
|
|
201
|
+
assistant_text = file_path.read_text(encoding="utf-8")
|
|
202
|
+
elif not sys.stdin.isatty():
|
|
203
|
+
assistant_text = sys.stdin.read()
|
|
204
|
+
else:
|
|
205
|
+
sys.stderr.write("Usage: prompt_workflow_validate.py [path/to/draft.md]\n")
|
|
206
|
+
sys.stderr.write(" cat draft.md | prompt_workflow_validate.py\n")
|
|
207
|
+
sys.exit(blocked_exit_code)
|
|
208
|
+
|
|
209
|
+
validation_result = validate_prompt_workflow(assistant_text)
|
|
210
|
+
if validation_result.allowed:
|
|
211
|
+
sys.exit(allowed_exit_code)
|
|
212
|
+
for each_reason in validation_result.reasons:
|
|
213
|
+
sys.stderr.write(f"[{each_reason.code}] {each_reason.message}\n")
|
|
214
|
+
sys.exit(blocked_exit_code)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Subprocess integration tests for content-search-to-zoekt-redirector PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ContentSearchHookIntegrationTests(unittest.TestCase):
|
|
12
|
+
def test_bash_grep_command_emits_stdout_json_deny(self) -> None:
|
|
13
|
+
hook_directory = pathlib.Path(__file__).resolve().parent
|
|
14
|
+
if str(hook_directory) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(hook_directory))
|
|
16
|
+
from content_search_zoekt_redirect_guidance import get_zoekt_redirect_guidance
|
|
17
|
+
|
|
18
|
+
hook_path = hook_directory / "content-search-to-zoekt-redirector.py"
|
|
19
|
+
destructive_gate_label_prefix = "[destructive-gate]"
|
|
20
|
+
destructive_gate_label_prefix_value = f"{destructive_gate_label_prefix} "
|
|
21
|
+
expected_decision = "deny"
|
|
22
|
+
hook_stdin_payload = json.dumps(
|
|
23
|
+
{"tool_name": "Bash", "tool_input": {"command": "grep foo bar"}},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
completed = subprocess.run(
|
|
27
|
+
[sys.executable, str(hook_path)],
|
|
28
|
+
input=hook_stdin_payload,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
cwd=str(hook_directory),
|
|
32
|
+
)
|
|
33
|
+
self.assertEqual(completed.returncode, 0)
|
|
34
|
+
self.assertEqual(completed.stderr, "")
|
|
35
|
+
payload: dict[str, Any] = json.loads(completed.stdout)
|
|
36
|
+
self.assertTrue(
|
|
37
|
+
payload["systemMessage"].startswith(destructive_gate_label_prefix_value),
|
|
38
|
+
)
|
|
39
|
+
self.assertEqual(
|
|
40
|
+
payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
41
|
+
get_zoekt_redirect_guidance(),
|
|
42
|
+
)
|
|
43
|
+
self.assertEqual(
|
|
44
|
+
payload["hookSpecificOutput"]["permissionDecision"],
|
|
45
|
+
expected_decision,
|
|
46
|
+
)
|
|
47
|
+
self.assertEqual(
|
|
48
|
+
payload["hookSpecificOutput"]["hookEventName"],
|
|
49
|
+
"PreToolUse",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
unittest.main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Unit tests for Zoekt redirector PreToolUse deny payload (build_block_payload)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
HOOK_DIRECTORY = pathlib.Path(__file__).resolve().parent
|
|
10
|
+
if str(HOOK_DIRECTORY) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(HOOK_DIRECTORY))
|
|
12
|
+
|
|
13
|
+
from content_search_zoekt_block_payload import build_block_payload
|
|
14
|
+
from content_search_zoekt_redirect_guidance import get_zoekt_redirect_guidance
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BuildBlockPayloadTests(unittest.TestCase):
|
|
18
|
+
def test_payload_matches_pretooluse_contract(self) -> None:
|
|
19
|
+
destructive_gate_label_prefix = "[destructive-gate]"
|
|
20
|
+
payload: dict[str, Any] = build_block_payload("demo", "body")
|
|
21
|
+
prefix_with_space = f"{destructive_gate_label_prefix} "
|
|
22
|
+
self.assertTrue(payload["systemMessage"].startswith(prefix_with_space))
|
|
23
|
+
self.assertEqual(
|
|
24
|
+
payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
25
|
+
"body",
|
|
26
|
+
)
|
|
27
|
+
self.assertEqual(payload["hookSpecificOutput"]["permissionDecision"], "deny")
|
|
28
|
+
self.assertEqual(
|
|
29
|
+
payload["hookSpecificOutput"]["hookEventName"],
|
|
30
|
+
"PreToolUse",
|
|
31
|
+
)
|
|
32
|
+
self.assertEqual(payload["suppressOutput"], True)
|
|
33
|
+
self.assertNotIn("decision", payload)
|
|
34
|
+
self.assertNotIn("reason", payload)
|
|
35
|
+
|
|
36
|
+
def test_serialized_payload_under_documented_context_cap(self) -> None:
|
|
37
|
+
cap_characters = 10_000
|
|
38
|
+
payload = build_block_payload(
|
|
39
|
+
brief_label="blocked Bash(grep); use Zoekt MCP",
|
|
40
|
+
permission_decision_reason=get_zoekt_redirect_guidance(),
|
|
41
|
+
)
|
|
42
|
+
serialized = json.dumps(payload)
|
|
43
|
+
self.assertLessEqual(
|
|
44
|
+
len(serialized),
|
|
45
|
+
cap_characters,
|
|
46
|
+
msg="Hooks doc caps additionalContext/systemMessage/plain stdout injection at 10,000 characters",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tests for Zoekt indexed root resolution (env, file, empty built-in fallback, WSL variants)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
HOOK_DIRECTORY = pathlib.Path(__file__).resolve().parent
|
|
12
|
+
if str(HOOK_DIRECTORY) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(HOOK_DIRECTORY))
|
|
14
|
+
|
|
15
|
+
from content_search_zoekt_indexed_paths import is_in_indexed_repo
|
|
16
|
+
from content_search_zoekt_indexed_roots_config import (
|
|
17
|
+
clear_indexed_root_prefixes_cache,
|
|
18
|
+
indexed_root_prefixes,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IndexedRootsConfigTests(unittest.TestCase):
|
|
23
|
+
def tearDown(self) -> None:
|
|
24
|
+
clear_indexed_root_prefixes_cache()
|
|
25
|
+
|
|
26
|
+
def test_environment_json_array_defines_prefixes(self) -> None:
|
|
27
|
+
roots_json = json.dumps(["Y:/OnlyOne/Indexed/"])
|
|
28
|
+
with patch.dict(os.environ, {"ZOEKT_REDIRECT_INDEXED_ROOTS": roots_json}, clear=False):
|
|
29
|
+
clear_indexed_root_prefixes_cache()
|
|
30
|
+
prefixes = indexed_root_prefixes()
|
|
31
|
+
self.assertIn("y:/onlyone/indexed/", prefixes)
|
|
32
|
+
self.assertIn("/mnt/y/onlyone/indexed/", prefixes)
|
|
33
|
+
|
|
34
|
+
def test_empty_environment_array_yields_no_prefixes(self) -> None:
|
|
35
|
+
with patch.dict(os.environ, {"ZOEKT_REDIRECT_INDEXED_ROOTS": "[]"}, clear=False):
|
|
36
|
+
clear_indexed_root_prefixes_cache()
|
|
37
|
+
prefixes = indexed_root_prefixes()
|
|
38
|
+
self.assertEqual(prefixes, ())
|
|
39
|
+
|
|
40
|
+
def test_json_file_used_when_env_missing(self) -> None:
|
|
41
|
+
with tempfile.TemporaryDirectory() as tmp_str:
|
|
42
|
+
home = pathlib.Path(tmp_str)
|
|
43
|
+
config_dir = home / ".claude"
|
|
44
|
+
config_dir.mkdir(parents=True)
|
|
45
|
+
roots_payload = {"roots": ["Y:/FromFile/Project/"]}
|
|
46
|
+
(config_dir / "zoekt-indexed-roots.json").write_text(
|
|
47
|
+
json.dumps(roots_payload),
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
)
|
|
50
|
+
with patch("pathlib.Path.home", return_value=home):
|
|
51
|
+
saved = os.environ.pop("ZOEKT_REDIRECT_INDEXED_ROOTS", None)
|
|
52
|
+
try:
|
|
53
|
+
clear_indexed_root_prefixes_cache()
|
|
54
|
+
prefixes = indexed_root_prefixes()
|
|
55
|
+
finally:
|
|
56
|
+
if saved is not None:
|
|
57
|
+
os.environ["ZOEKT_REDIRECT_INDEXED_ROOTS"] = saved
|
|
58
|
+
self.assertIn("y:/fromfile/project/", prefixes)
|
|
59
|
+
|
|
60
|
+
def test_environment_overrides_file(self) -> None:
|
|
61
|
+
with tempfile.TemporaryDirectory() as tmp_str:
|
|
62
|
+
home = pathlib.Path(tmp_str)
|
|
63
|
+
config_dir = home / ".claude"
|
|
64
|
+
config_dir.mkdir(parents=True)
|
|
65
|
+
(config_dir / "zoekt-indexed-roots.json").write_text(
|
|
66
|
+
json.dumps({"roots": ["Y:/FromFile/"]}),
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
)
|
|
69
|
+
with patch("pathlib.Path.home", return_value=home):
|
|
70
|
+
with patch.dict(
|
|
71
|
+
os.environ,
|
|
72
|
+
{"ZOEKT_REDIRECT_INDEXED_ROOTS": json.dumps(["Y:/FromEnv/"])},
|
|
73
|
+
clear=False,
|
|
74
|
+
):
|
|
75
|
+
clear_indexed_root_prefixes_cache()
|
|
76
|
+
prefixes = indexed_root_prefixes()
|
|
77
|
+
self.assertIn("y:/fromenv/", prefixes)
|
|
78
|
+
self.assertNotIn("y:/fromfile/", prefixes)
|
|
79
|
+
|
|
80
|
+
def test_longer_prefix_matches_before_shorter_parent(self) -> None:
|
|
81
|
+
roots_json = json.dumps(["Y:/parent/", "Y:/parent/child/"])
|
|
82
|
+
with patch.dict(os.environ, {"ZOEKT_REDIRECT_INDEXED_ROOTS": roots_json}, clear=False):
|
|
83
|
+
clear_indexed_root_prefixes_cache()
|
|
84
|
+
self.assertTrue(is_in_indexed_repo("Y:/parent/child/file.py"))
|
|
85
|
+
|
|
86
|
+
def test_invalid_environment_json_falls_through_to_empty_builtin(self) -> None:
|
|
87
|
+
with patch(
|
|
88
|
+
"content_search_zoekt_indexed_roots_config._roots_from_json_file",
|
|
89
|
+
return_value=None,
|
|
90
|
+
):
|
|
91
|
+
with patch.dict(
|
|
92
|
+
os.environ,
|
|
93
|
+
{"ZOEKT_REDIRECT_INDEXED_ROOTS": "not-json"},
|
|
94
|
+
clear=False,
|
|
95
|
+
):
|
|
96
|
+
clear_indexed_root_prefixes_cache()
|
|
97
|
+
prefixes = indexed_root_prefixes()
|
|
98
|
+
self.assertEqual(prefixes, ())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
unittest.main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Tests for destructive-command-blocker hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SCRIPT_PATH = Path(__file__).parent / "destructive-command-blocker.py"
|
|
10
|
+
GH_GATE_USER_FACING_PREFIX = "[gh-gate]"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
|
|
14
|
+
return subprocess.run(
|
|
15
|
+
[sys.executable, str(SCRIPT_PATH)],
|
|
16
|
+
input=json.dumps(payload),
|
|
17
|
+
text=True,
|
|
18
|
+
capture_output=True,
|
|
19
|
+
check=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _make_bash_payload(command: str) -> dict:
|
|
24
|
+
return {
|
|
25
|
+
"tool_name": "Bash",
|
|
26
|
+
"tool_input": {"command": command},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_denies_gh_issue_comment_as_redirect_duplicate_guard() -> None:
|
|
31
|
+
payload = _make_bash_payload(
|
|
32
|
+
'gh issue comment 83 --repo jl-cmd/claude-code-config --body "hello"'
|
|
33
|
+
)
|
|
34
|
+
result = _run_hook(payload)
|
|
35
|
+
response = json.loads(result.stdout)
|
|
36
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
37
|
+
assert (
|
|
38
|
+
"gh issue comment" in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
39
|
+
)
|
|
40
|
+
assert (
|
|
41
|
+
"duplicate execution"
|
|
42
|
+
in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_denies_gh_pr_comment_as_redirect_duplicate_guard() -> None:
|
|
47
|
+
payload = _make_bash_payload('gh pr comment 42 --body "ok"')
|
|
48
|
+
result = _run_hook(payload)
|
|
49
|
+
response = json.loads(result.stdout)
|
|
50
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
51
|
+
assert "gh pr comment" in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_denies_gh_pr_review_as_redirect_duplicate_guard() -> None:
|
|
55
|
+
payload = _make_bash_payload("gh pr review 42 --approve")
|
|
56
|
+
result = _run_hook(payload)
|
|
57
|
+
response = json.loads(result.stdout)
|
|
58
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
59
|
+
assert "gh pr review" in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_denies_gh_api_post_comment_as_redirect_duplicate_guard() -> None:
|
|
63
|
+
payload = _make_bash_payload(
|
|
64
|
+
"gh api /repos/owner/name/issues/1/comments -X POST -f body=hello"
|
|
65
|
+
)
|
|
66
|
+
result = _run_hook(payload)
|
|
67
|
+
response = json.loads(result.stdout)
|
|
68
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_suppresses_output_on_gh_redirect_deny() -> None:
|
|
72
|
+
payload = _make_bash_payload('gh issue comment 1 --body "x"')
|
|
73
|
+
result = _run_hook(payload)
|
|
74
|
+
response = json.loads(result.stdout)
|
|
75
|
+
assert response["suppressOutput"] is True
|
|
76
|
+
assert response["systemMessage"].startswith(GH_GATE_USER_FACING_PREFIX)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_asks_on_rm_rf_still_works() -> None:
|
|
80
|
+
payload = _make_bash_payload("rm -rf /tmp/somewhere")
|
|
81
|
+
result = _run_hook(payload)
|
|
82
|
+
response = json.loads(result.stdout)
|
|
83
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
84
|
+
assert "rm -rf" in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_asks_on_git_push_force_still_works() -> None:
|
|
88
|
+
payload = _make_bash_payload("git push --force origin main")
|
|
89
|
+
result = _run_hook(payload)
|
|
90
|
+
response = json.loads(result.stdout)
|
|
91
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
92
|
+
assert (
|
|
93
|
+
"git push --force" in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_allows_plain_command_without_destructive_pattern() -> None:
|
|
98
|
+
payload = _make_bash_payload("ls -la")
|
|
99
|
+
result = _run_hook(payload)
|
|
100
|
+
assert result.stdout.strip() == ""
|
|
101
|
+
assert result.returncode == 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_ignores_non_bash_tool() -> None:
|
|
105
|
+
payload = {"tool_name": "Read", "tool_input": {"file_path": "/tmp/x"}}
|
|
106
|
+
result = _run_hook(payload)
|
|
107
|
+
assert result.stdout.strip() == ""
|
|
108
|
+
assert result.returncode == 0
|