claude-dev-env 1.30.1 → 1.32.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/agents/clean-coder.md +275 -111
- package/agents/code-quality-agent.md +196 -209
- package/bin/install.mjs +81 -0
- package/bin/install.test.mjs +158 -0
- package/bin/install_mypy_ini.mjs +51 -0
- package/bin/install_mypy_ini.test.mjs +121 -0
- package/commands/hook-log-extract.md +70 -0
- package/commands/hook-log-init.md +76 -0
- package/hooks/blocking/code_rules_enforcer.py +5 -3
- package/hooks/blocking/destructive_command_blocker.py +187 -0
- package/hooks/blocking/question_to_user_enforcer.py +140 -0
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +39 -0
- package/hooks/blocking/test_destructive_command_blocker.py +397 -0
- package/hooks/blocking/test_question_to_user_enforcer.py +163 -0
- package/hooks/blocking/test_windows_rmtree_blocker.py +148 -0
- package/hooks/blocking/windows_rmtree_blocker.py +106 -0
- package/hooks/config/hook_log_extractor_constants.py +234 -0
- package/hooks/config/messages.py +3 -0
- package/hooks/config/session_env_cleanup_constants.py +18 -0
- package/hooks/config/test_hook_log_extractor_constants.py +123 -0
- package/hooks/config/test_messages.py +5 -0
- package/hooks/config/test_session_env_cleanup_constants.py +55 -0
- package/hooks/diagnostic/hook_log_extractor.py +907 -0
- package/hooks/diagnostic/hook_log_init.py +202 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +172 -0
- package/hooks/diagnostic/migrations/2026-04-25-drop-themes-hook-events.sql +3 -0
- package/hooks/diagnostic/migrations/README.md +77 -0
- package/hooks/diagnostic/queries/block_details_for_hook.sql +26 -0
- package/hooks/diagnostic/queries/blocks_by_category.sql +10 -0
- package/hooks/diagnostic/queries/blocks_by_tool.sql +9 -0
- package/hooks/diagnostic/queries/blocks_last_7_days.sql +11 -0
- package/hooks/diagnostic/queries/top_blockers_last_24_hours.sql +12 -0
- package/hooks/diagnostic/queries/top_blockers_overall.sql +12 -0
- package/hooks/diagnostic/requirements-hook-logs-dev.txt +2 -0
- package/hooks/diagnostic/requirements-hook-logs.txt +1 -0
- package/hooks/diagnostic/schema.sql +51 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +1531 -0
- package/hooks/diagnostic/test_hook_log_init.py +227 -0
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +345 -0
- package/hooks/hooks.json +25 -0
- package/hooks/session/session_env_cleanup.py +129 -0
- package/hooks/session/test_session_env_cleanup.py +278 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +44 -0
- package/rules/windows-filesystem-safe.md +93 -0
- package/scripts/config/test_spec_implementer_prompt.py +0 -4
- package/scripts/test_groq_bugteam_spec.py +0 -8
- package/skills/bugteam/SKILL.md +15 -1
- package/skills/bugteam/SKILL_EVALS.md +1 -1
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/scripts/README.md +17 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +238 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +267 -0
- package/skills/logifix/SKILL.md +69 -0
- package/skills/logifix/scripts/logifix.ps1 +205 -0
- package/skills/rebase/SKILL.md +157 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Unit tests for windows_rmtree_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import io
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
from contextlib import redirect_stdout
|
|
9
|
+
|
|
10
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
11
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
13
|
+
|
|
14
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
15
|
+
"windows_rmtree_blocker",
|
|
16
|
+
_HOOK_DIR / "windows_rmtree_blocker.py",
|
|
17
|
+
)
|
|
18
|
+
assert hook_spec is not None
|
|
19
|
+
assert hook_spec.loader is not None
|
|
20
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
21
|
+
hook_spec.loader.exec_module(hook_module)
|
|
22
|
+
|
|
23
|
+
payload_contains_unsafe_rmtree = hook_module.payload_contains_unsafe_rmtree
|
|
24
|
+
extract_payload_text = hook_module.extract_payload_text
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_detects_basic_ignore_errors_call() -> None:
|
|
28
|
+
assert payload_contains_unsafe_rmtree(
|
|
29
|
+
"shutil.rmtree(target_path, ignore_errors=True)"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_detects_call_with_path_first_then_ignore_errors() -> None:
|
|
34
|
+
assert payload_contains_unsafe_rmtree(
|
|
35
|
+
'shutil.rmtree(r"C:\\temp\\foo", ignore_errors=True)'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_detects_oneliner_python_dash_c_form() -> None:
|
|
40
|
+
bash_command = (
|
|
41
|
+
'python -c "import shutil; '
|
|
42
|
+
"shutil.rmtree(r'<team_temp_dir>', ignore_errors=True)\""
|
|
43
|
+
)
|
|
44
|
+
assert payload_contains_unsafe_rmtree(bash_command)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_detects_call_with_extra_whitespace() -> None:
|
|
48
|
+
assert payload_contains_unsafe_rmtree(
|
|
49
|
+
"shutil .rmtree (path, ignore_errors = True)"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_detects_call_split_across_lines() -> None:
|
|
54
|
+
multiline_code = "shutil.rmtree(\n target_path,\n ignore_errors=True,\n)"
|
|
55
|
+
assert payload_contains_unsafe_rmtree(multiline_code)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_allows_rmtree_with_onexc_handler() -> None:
|
|
59
|
+
safe_code = "shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)"
|
|
60
|
+
assert not payload_contains_unsafe_rmtree(safe_code)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_allows_rmtree_with_onerror_handler() -> None:
|
|
64
|
+
safe_code = "shutil.rmtree(target_path, onerror=_strip_read_only_and_retry)"
|
|
65
|
+
assert not payload_contains_unsafe_rmtree(safe_code)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_allows_bare_rmtree_call() -> None:
|
|
69
|
+
bare_call = "shutil.rmtree(target_path)"
|
|
70
|
+
assert not payload_contains_unsafe_rmtree(bare_call)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_allows_ignore_errors_false() -> None:
|
|
74
|
+
assert not payload_contains_unsafe_rmtree(
|
|
75
|
+
"shutil.rmtree(target_path, ignore_errors=False)"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_extract_payload_handles_write_content() -> None:
|
|
80
|
+
extracted = extract_payload_text("Write", {"content": "abc"})
|
|
81
|
+
assert extracted == "abc"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_extract_payload_handles_edit_new_string() -> None:
|
|
85
|
+
extracted = extract_payload_text("Edit", {"new_string": "abc"})
|
|
86
|
+
assert extracted == "abc"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_extract_payload_handles_bash_command() -> None:
|
|
90
|
+
extracted = extract_payload_text("Bash", {"command": "ls"})
|
|
91
|
+
assert extracted == "ls"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_extract_payload_returns_empty_for_unknown_tool() -> None:
|
|
95
|
+
extracted = extract_payload_text("OtherTool", {"content": "abc"})
|
|
96
|
+
assert extracted == ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _run_hook(hook_input: dict) -> tuple[str, int]:
|
|
100
|
+
captured = io.StringIO()
|
|
101
|
+
sys.stdin = io.StringIO(json.dumps(hook_input))
|
|
102
|
+
try:
|
|
103
|
+
with redirect_stdout(captured):
|
|
104
|
+
try:
|
|
105
|
+
hook_module.main()
|
|
106
|
+
except SystemExit as exit_signal:
|
|
107
|
+
exit_code = exit_signal.code or 0
|
|
108
|
+
finally:
|
|
109
|
+
sys.stdin = sys.__stdin__
|
|
110
|
+
return captured.getvalue(), exit_code
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_main_blocks_unsafe_bash_command() -> None:
|
|
114
|
+
stdout_text, exit_code = _run_hook(
|
|
115
|
+
{
|
|
116
|
+
"tool_name": "Bash",
|
|
117
|
+
"tool_input": {
|
|
118
|
+
"command": (
|
|
119
|
+
'python -c "import shutil; '
|
|
120
|
+
"shutil.rmtree(r'/tmp/x', ignore_errors=True)\""
|
|
121
|
+
)
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
assert exit_code == 0
|
|
126
|
+
response_payload = json.loads(stdout_text)
|
|
127
|
+
decision_block = response_payload["hookSpecificOutput"]
|
|
128
|
+
assert decision_block["permissionDecision"] == "deny"
|
|
129
|
+
assert "windows-rmtree" in decision_block["permissionDecisionReason"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_main_passes_through_safe_write() -> None:
|
|
133
|
+
stdout_text, exit_code = _run_hook(
|
|
134
|
+
{
|
|
135
|
+
"tool_name": "Write",
|
|
136
|
+
"tool_input": {"content": "shutil.rmtree(path, onexc=handler)"},
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
assert exit_code == 0
|
|
140
|
+
assert stdout_text == ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_main_passes_through_unrelated_tool() -> None:
|
|
144
|
+
stdout_text, exit_code = _run_hook(
|
|
145
|
+
{"tool_name": "Read", "tool_input": {"file_path": "/tmp/x"}}
|
|
146
|
+
)
|
|
147
|
+
assert exit_code == 0
|
|
148
|
+
assert stdout_text == ""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block shutil.rmtree(..., ignore_errors=True).
|
|
3
|
+
|
|
4
|
+
shutil.rmtree on Windows raises PermissionError when it encounters a file carrying
|
|
5
|
+
the ReadOnly attribute (FILE_ATTRIBUTE_READONLY). With ignore_errors=True the failure
|
|
6
|
+
is silently swallowed and the tree stays on disk — cleanup looks successful but
|
|
7
|
+
pruned nothing. Linux never hits this because unlink on Linux only needs write on
|
|
8
|
+
the parent directory, not on the file itself. Tests run inside pytest's tmp_path
|
|
9
|
+
do not exercise the regression path because tmp dirs do not carry the attribute.
|
|
10
|
+
|
|
11
|
+
This hook scans Write/Edit content and Bash commands for the dangerous pattern and
|
|
12
|
+
blocks it with a corrective message pointing to the force_rmtree replacement.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
_WRITE_EDIT_TOOL_NAMES = {"Write", "Edit"}
|
|
20
|
+
_BASH_TOOL_NAME = "Bash"
|
|
21
|
+
|
|
22
|
+
_RMTREE_IGNORE_ERRORS_PATTERN = re.compile(
|
|
23
|
+
r"shutil\s*\.\s*rmtree\s*\([^)]*\bignore_errors\s*=\s*True\b",
|
|
24
|
+
re.DOTALL,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_CORRECTIVE_MESSAGE = (
|
|
28
|
+
"BLOCKED [windows-rmtree]: shutil.rmtree(..., ignore_errors=True) silently "
|
|
29
|
+
"fails on Windows when a file carries the ReadOnly attribute "
|
|
30
|
+
"(FILE_ATTRIBUTE_READONLY). The PermissionError is swallowed and the tree "
|
|
31
|
+
"stays on disk -- cleanup looks successful but removes nothing. Linux is "
|
|
32
|
+
"unaffected because unlink only needs write on the parent directory.\n\n"
|
|
33
|
+
"Use a Windows-safe handler that strips the attribute and retries the "
|
|
34
|
+
"syscall:\n\n"
|
|
35
|
+
" import os\n"
|
|
36
|
+
" import shutil\n"
|
|
37
|
+
" import stat\n"
|
|
38
|
+
" import sys\n\n"
|
|
39
|
+
" def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n"
|
|
40
|
+
" try:\n"
|
|
41
|
+
" os.chmod(target_path, stat.S_IWRITE)\n"
|
|
42
|
+
" removal_function(target_path)\n"
|
|
43
|
+
" except OSError:\n"
|
|
44
|
+
" pass\n\n"
|
|
45
|
+
" def force_rmtree(target_path: str) -> None:\n"
|
|
46
|
+
" handler_kw = (\n"
|
|
47
|
+
' {"onexc": _strip_read_only_and_retry}\n'
|
|
48
|
+
" if sys.version_info >= (3, 12)\n"
|
|
49
|
+
' else {"onerror": _strip_read_only_and_retry}\n'
|
|
50
|
+
" )\n"
|
|
51
|
+
" try:\n"
|
|
52
|
+
" shutil.rmtree(target_path, **handler_kw)\n"
|
|
53
|
+
" except OSError:\n"
|
|
54
|
+
" pass\n\n"
|
|
55
|
+
"Two things to know about the handler:\n"
|
|
56
|
+
" - *_exc_info collapses the signature difference. onerror passes "
|
|
57
|
+
"(type, value, traceback); onexc (Python 3.12+) passes a single exception.\n"
|
|
58
|
+
" - removal_function is whichever syscall rmtree was attempting "
|
|
59
|
+
"(os.unlink for files, os.rmdir for dirs). Re-call it after chmod to finish "
|
|
60
|
+
"the work that originally failed.\n\n"
|
|
61
|
+
"See ~/.claude/rules/windows-filesystem-safe.md for full guidance."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
|
|
66
|
+
if not payload_text:
|
|
67
|
+
return False
|
|
68
|
+
return bool(_RMTREE_IGNORE_ERRORS_PATTERN.search(payload_text))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def extract_payload_text(tool_name: str, tool_input: dict) -> str:
|
|
72
|
+
if tool_name in _WRITE_EDIT_TOOL_NAMES:
|
|
73
|
+
return tool_input.get("content", "") or tool_input.get("new_string", "") or ""
|
|
74
|
+
if tool_name == _BASH_TOOL_NAME:
|
|
75
|
+
return tool_input.get("command", "") or ""
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main() -> None:
|
|
80
|
+
try:
|
|
81
|
+
hook_input = json.load(sys.stdin)
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
sys.exit(0)
|
|
84
|
+
|
|
85
|
+
tool_name = hook_input.get("tool_name", "")
|
|
86
|
+
tool_input = hook_input.get("tool_input", {})
|
|
87
|
+
|
|
88
|
+
payload_text = extract_payload_text(tool_name, tool_input)
|
|
89
|
+
|
|
90
|
+
if not payload_contains_unsafe_rmtree(payload_text):
|
|
91
|
+
sys.exit(0)
|
|
92
|
+
|
|
93
|
+
deny_response = {
|
|
94
|
+
"hookSpecificOutput": {
|
|
95
|
+
"hookEventName": "PreToolUse",
|
|
96
|
+
"permissionDecision": "deny",
|
|
97
|
+
"permissionDecisionReason": _CORRECTIVE_MESSAGE,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
print(json.dumps(deny_response))
|
|
101
|
+
sys.stdout.flush()
|
|
102
|
+
sys.exit(0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Constants for the hook-log extractor and init scripts.
|
|
2
|
+
|
|
3
|
+
Centralizes all named values used by ``hook_log_extractor.py`` and
|
|
4
|
+
``hook_log_init.py`` so that production modules carry zero magic values.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
COMMAND_EXCERPT_MAX_CHARACTERS: int = 300
|
|
13
|
+
STDOUT_EXCERPT_MAX_CHARACTERS: int = 500
|
|
14
|
+
STDERR_EXCERPT_MAX_CHARACTERS: int = 500
|
|
15
|
+
|
|
16
|
+
INSERT_BATCH_SIZE: int = 500
|
|
17
|
+
CONNECT_TIMEOUT_SECONDS: int = 5
|
|
18
|
+
|
|
19
|
+
def _resolve_claude_home_directory() -> Path:
|
|
20
|
+
"""Return the root of the local ``~/.claude`` tree, honoring ``CLAUDE_HOME``.
|
|
21
|
+
|
|
22
|
+
An unset, empty, or whitespace-only ``CLAUDE_HOME`` falls back to
|
|
23
|
+
``~/.claude`` so state and transcript paths do not silently resolve
|
|
24
|
+
to the process working directory.
|
|
25
|
+
"""
|
|
26
|
+
claude_home_override = os.environ.get("CLAUDE_HOME", "").strip()
|
|
27
|
+
if claude_home_override:
|
|
28
|
+
return Path(claude_home_override).expanduser()
|
|
29
|
+
return Path.home() / ".claude"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
OFFSET_STATE_FILE: str = str(
|
|
33
|
+
_resolve_claude_home_directory()
|
|
34
|
+
/ "logs"
|
|
35
|
+
/ "hooks"
|
|
36
|
+
/ ".state"
|
|
37
|
+
/ "offsets.json"
|
|
38
|
+
)
|
|
39
|
+
OFFLINE_WARNING_LOG: str = str(
|
|
40
|
+
_resolve_claude_home_directory() / "logs" / "hook-extractor.log"
|
|
41
|
+
)
|
|
42
|
+
PROJECTS_TRANSCRIPT_ROOT: str = str(_resolve_claude_home_directory() / "projects")
|
|
43
|
+
|
|
44
|
+
NEON_DATABASE_URL_ENVIRONMENT_VARIABLE: str = "NEON_HOOK_LOGS_DATABASE_URL"
|
|
45
|
+
|
|
46
|
+
ATTACHMENT_TYPE_PREFIX: str = "hook_"
|
|
47
|
+
TOP_LEVEL_ATTACHMENT_TYPE: str = "attachment"
|
|
48
|
+
|
|
49
|
+
ATTACHMENT_TYPE_HOOK_SUCCESS: str = "hook_success"
|
|
50
|
+
ATTACHMENT_TYPE_HOOK_BLOCKING_ERROR: str = "hook_blocking_error"
|
|
51
|
+
ATTACHMENT_TYPE_HOOK_SYSTEM_MESSAGE: str = "hook_system_message"
|
|
52
|
+
ATTACHMENT_TYPE_HOOK_ADDITIONAL_CONTEXT: str = "hook_additional_context"
|
|
53
|
+
|
|
54
|
+
OUTCOME_SUCCESS: str = "success"
|
|
55
|
+
OUTCOME_BLOCKED: str = "blocked"
|
|
56
|
+
OUTCOME_NON_BLOCKING_ERROR: str = "non_blocking_error"
|
|
57
|
+
OUTCOME_SYSTEM_MESSAGE: str = "system_message"
|
|
58
|
+
OUTCOME_ADDED_CONTEXT: str = "added_context"
|
|
59
|
+
OUTCOME_INIT_PROBE: str = "init_probe"
|
|
60
|
+
|
|
61
|
+
OUTCOME_BY_ATTACHMENT_TYPE: dict[str, str] = {
|
|
62
|
+
ATTACHMENT_TYPE_HOOK_SUCCESS: OUTCOME_SUCCESS,
|
|
63
|
+
ATTACHMENT_TYPE_HOOK_BLOCKING_ERROR: OUTCOME_BLOCKED,
|
|
64
|
+
"hook_non_blocking_error": OUTCOME_NON_BLOCKING_ERROR,
|
|
65
|
+
ATTACHMENT_TYPE_HOOK_SYSTEM_MESSAGE: OUTCOME_SYSTEM_MESSAGE,
|
|
66
|
+
ATTACHMENT_TYPE_HOOK_ADDITIONAL_CONTEXT: OUTCOME_ADDED_CONTEXT,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
HOOK_CATEGORY_UNCATEGORIZED: str = "uncategorized"
|
|
70
|
+
|
|
71
|
+
KNOWN_HOOK_CATEGORIES: frozenset[str] = frozenset(
|
|
72
|
+
{
|
|
73
|
+
"advisory",
|
|
74
|
+
"blocking",
|
|
75
|
+
"config",
|
|
76
|
+
"context",
|
|
77
|
+
"diagnostic",
|
|
78
|
+
"git-hooks",
|
|
79
|
+
"github-action",
|
|
80
|
+
"lifecycle",
|
|
81
|
+
"notification",
|
|
82
|
+
"session",
|
|
83
|
+
"system",
|
|
84
|
+
"validation",
|
|
85
|
+
"validators",
|
|
86
|
+
"workflow",
|
|
87
|
+
"worktree",
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
HOOK_NAME_TOOL_SEPARATOR: str = ":"
|
|
92
|
+
|
|
93
|
+
SCHEMA_RELATIVE_PATH: str = "schema.sql"
|
|
94
|
+
QUERIES_DIRECTORY_NAME: str = "queries"
|
|
95
|
+
SQL_FILE_EXTENSION: str = ".sql"
|
|
96
|
+
|
|
97
|
+
DEFAULT_QUERY_FOR_SUMMARY: str = "top_blockers_last_24_hours"
|
|
98
|
+
|
|
99
|
+
JSONL_FILE_GLOB: str = "*.jsonl"
|
|
100
|
+
|
|
101
|
+
FLAG_INCREMENTAL: str = "--incremental"
|
|
102
|
+
FLAG_FULL_REBUILD: str = "--full-rebuild"
|
|
103
|
+
FLAG_SUMMARY: str = "--summary"
|
|
104
|
+
FLAG_QUERY: str = "--query"
|
|
105
|
+
|
|
106
|
+
EXIT_CODE_SUCCESS: int = 0
|
|
107
|
+
EXIT_CODE_ENVIRONMENT_MISSING: int = 1
|
|
108
|
+
EXIT_CODE_EXTRACTOR_ENVIRONMENT_MISSING: int = 0
|
|
109
|
+
EXIT_CODE_UNKNOWN_QUERY: int = 2
|
|
110
|
+
|
|
111
|
+
QUERY_NAME_PATTERN: str = r"[a-z0-9_]+"
|
|
112
|
+
|
|
113
|
+
SENTINEL_SESSION_ID: str = "__init_probe_session__"
|
|
114
|
+
SENTINEL_HOOK_EVENT: str = "InitProbe"
|
|
115
|
+
SENTINEL_HOOK_NAME: str = "init_probe"
|
|
116
|
+
SENTINEL_SOURCE_PATH: str = "__init_probe__"
|
|
117
|
+
SENTINEL_SOURCE_LINE_NUMBER: int = 0
|
|
118
|
+
|
|
119
|
+
SUMMARY_COLUMN_HEADINGS: tuple[str, str, str, str] = (
|
|
120
|
+
"hook_name",
|
|
121
|
+
"hook_category",
|
|
122
|
+
"block_count_last_24_hours",
|
|
123
|
+
"top_blocked_command_preview",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
SUMMARY_NO_NEW_BLOCKS_MESSAGE: str = "No new blocks since last run."
|
|
127
|
+
|
|
128
|
+
QUERY_NO_ROWS_RETURNED_MESSAGE: str = "No rows returned."
|
|
129
|
+
|
|
130
|
+
TOP_BLOCKED_COMMAND_PREVIEW_MAX_CHARACTERS: int = 80
|
|
131
|
+
|
|
132
|
+
HOOK_EVENTS_TABLE_NAME: str = "hook_events"
|
|
133
|
+
|
|
134
|
+
HOOK_EVENTS_INSERT_SQL: str = (
|
|
135
|
+
"INSERT INTO hook_events ("
|
|
136
|
+
"event_timestamp, session_id, cwd, git_branch, hook_event, hook_name, "
|
|
137
|
+
"hook_category, script_path, tool_name, tool_use_id, outcome, exit_code, "
|
|
138
|
+
"duration_ms, command_excerpt, stdout_excerpt, stderr_excerpt, "
|
|
139
|
+
"source_jsonl_path, source_line_number"
|
|
140
|
+
") VALUES ("
|
|
141
|
+
"%(event_timestamp)s, %(session_id)s, %(cwd)s, %(git_branch)s, "
|
|
142
|
+
"%(hook_event)s, %(hook_name)s, %(hook_category)s, %(script_path)s, "
|
|
143
|
+
"%(tool_name)s, %(tool_use_id)s, %(outcome)s, %(exit_code)s, "
|
|
144
|
+
"%(duration_ms)s, %(command_excerpt)s, %(stdout_excerpt)s, "
|
|
145
|
+
"%(stderr_excerpt)s, %(source_jsonl_path)s, %(source_line_number)s"
|
|
146
|
+
") ON CONFLICT (source_jsonl_path, source_line_number) DO NOTHING"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
HOOK_EVENTS_TRUNCATE_SQL: str = "TRUNCATE TABLE hook_events RESTART IDENTITY"
|
|
150
|
+
|
|
151
|
+
HOOK_EVENTS_ROW_COUNT_SQL: str = "SELECT COUNT(*) FROM hook_events"
|
|
152
|
+
|
|
153
|
+
SENTINEL_INSERT_SQL: str = (
|
|
154
|
+
"INSERT INTO hook_events ("
|
|
155
|
+
"event_timestamp, session_id, hook_event, hook_name, hook_category, "
|
|
156
|
+
"outcome, source_jsonl_path, source_line_number"
|
|
157
|
+
") VALUES (NOW(), %s, %s, %s, %s, %s, %s, %s) RETURNING id"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
SENTINEL_SELECT_SQL: str = "SELECT id FROM hook_events WHERE id = %s"
|
|
161
|
+
|
|
162
|
+
SENTINEL_DELETE_SQL: str = "DELETE FROM hook_events WHERE id = %s"
|
|
163
|
+
|
|
164
|
+
TOP_BLOCKERS_LAST_24_HOURS_SQL: str = (
|
|
165
|
+
"SELECT hook_name, hook_category, COUNT(*) AS block_count, "
|
|
166
|
+
"MIN(COALESCE(command_excerpt, stdout_excerpt, stderr_excerpt, '')) "
|
|
167
|
+
"AS top_blocked_command_preview "
|
|
168
|
+
"FROM hook_events WHERE outcome = 'blocked' "
|
|
169
|
+
"AND event_timestamp >= (NOW() - INTERVAL '1 day') "
|
|
170
|
+
"GROUP BY hook_name, hook_category "
|
|
171
|
+
"ORDER BY block_count DESC LIMIT 10"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
EMPTY_STRING: str = ""
|
|
175
|
+
NEWLINE_JOINER: str = "\n"
|
|
176
|
+
SEMICOLON_SPLIT_TOKEN: str = ";"
|
|
177
|
+
|
|
178
|
+
HOOKS_DIRECTORY_TOKEN: str = "/hooks/"
|
|
179
|
+
|
|
180
|
+
SCRIPT_PATH_PYTHON_PREFIXES: tuple[str, ...] = ("python3 ", "python ")
|
|
181
|
+
|
|
182
|
+
SUMMARY_TABLE_COLUMN_GAP: str = " "
|
|
183
|
+
|
|
184
|
+
CATEGORY_PATH_MINIMUM_PARTS: int = 2
|
|
185
|
+
OFFSETS_JSON_INDENT: int = 2
|
|
186
|
+
|
|
187
|
+
MISSING_ENVIRONMENT_VARIABLE_PREFIX: str = "Missing required environment variable: "
|
|
188
|
+
SUCCESS_REPORT_HEADER: str = "Hook-log init succeeded."
|
|
189
|
+
NEON_HOST_REPORT_LABEL: str = "Neon host:"
|
|
190
|
+
TABLE_REPORT_LABEL: str = "Table:"
|
|
191
|
+
ROW_COUNT_REPORT_LABEL: str = "Row count:"
|
|
192
|
+
UNKNOWN_HOST_PLACEHOLDER: str = "unknown"
|
|
193
|
+
SENTINEL_HOOK_CATEGORY: str = "diagnostic"
|
|
194
|
+
|
|
195
|
+
MISSING_PSYCOPG_WARNING_LABEL: str = "missing_psycopg"
|
|
196
|
+
MISSING_NEON_DATABASE_URL_WARNING_LABEL: str = "missing_neon_database_url"
|
|
197
|
+
LEGACY_OFFSETS_FORMAT_WARNING_LABEL: str = "legacy_offsets_format"
|
|
198
|
+
|
|
199
|
+
SENTINEL_SELECT_FAILURE_MESSAGE: str = (
|
|
200
|
+
"Sentinel SELECT did not return the inserted id; round-trip failed."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
SENTINEL_INSERT_FAILURE_MESSAGE: str = (
|
|
204
|
+
"Sentinel INSERT did not return a row; round-trip failed."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
BYTE_OFFSET_KEY: str = "byte_offset"
|
|
208
|
+
LINE_NUMBER_KEY: str = "line_number"
|
|
209
|
+
|
|
210
|
+
UNKNOWN_QUERY_MESSAGE_PREFIX: str = "Unknown query: "
|
|
211
|
+
INVALID_QUERY_NAME_MESSAGE_PREFIX: str = "Invalid query name: "
|
|
212
|
+
|
|
213
|
+
BWS_EXECUTABLE_NAME: str = "bws"
|
|
214
|
+
BWS_ACCESS_TOKEN_ENV_VAR: str = "BWS_ACCESS_TOKEN"
|
|
215
|
+
BWS_RUN_SEPARATOR: str = "--"
|
|
216
|
+
BWS_RUN_SUBCOMMAND: str = "run"
|
|
217
|
+
STOP_WRAPPER_EXTRACTOR_SCRIPT_NAME: str = "hook_log_extractor.py"
|
|
218
|
+
|
|
219
|
+
STOP_WRAPPER_DEBOUNCE_SECONDS: int = 60
|
|
220
|
+
STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE: str = str(
|
|
221
|
+
_resolve_claude_home_directory()
|
|
222
|
+
/ "logs"
|
|
223
|
+
/ "hooks"
|
|
224
|
+
/ ".state"
|
|
225
|
+
/ "stop_wrapper_last_run.txt"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
WINDOWS_OS_NAME: str = "nt"
|
|
229
|
+
WINDOWS_DETACHED_PROCESS_FLAG: int = 0x00000008
|
|
230
|
+
WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG: int = 0x00000200
|
|
231
|
+
|
|
232
|
+
LOCK_MAXIMUM_RETRY_COUNT: int = 30
|
|
233
|
+
LOCK_RETRY_SLEEP_SECONDS: float = 0.1
|
|
234
|
+
|
package/hooks/config/messages.py
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Configuration constants for the session_env_cleanup SessionStart hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
SESSION_ENV_DIRECTORY = os.path.join(os.path.expanduser("~"), ".claude", "session-env")
|
|
9
|
+
|
|
10
|
+
SECONDS_PER_DAY = 24 * 60 * 60
|
|
11
|
+
STALE_AGE_DAYS = 7
|
|
12
|
+
STALE_AGE_SECONDS = STALE_AGE_DAYS * SECONDS_PER_DAY
|
|
13
|
+
|
|
14
|
+
RMTREE_ONEXC_PYTHON_VERSION = (3, 12)
|
|
15
|
+
|
|
16
|
+
SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
17
|
+
|
|
18
|
+
WINDOWS_PLATFORM_TAG = "win32"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Behavior tests for query-name pattern and exit-code routing contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
10
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
12
|
+
|
|
13
|
+
from config import hook_log_extractor_constants
|
|
14
|
+
from config.hook_log_extractor_constants import (
|
|
15
|
+
EXIT_CODE_ENVIRONMENT_MISSING,
|
|
16
|
+
EXIT_CODE_EXTRACTOR_ENVIRONMENT_MISSING,
|
|
17
|
+
EXIT_CODE_SUCCESS,
|
|
18
|
+
EXIT_CODE_UNKNOWN_QUERY,
|
|
19
|
+
LOCK_MAXIMUM_RETRY_COUNT,
|
|
20
|
+
LOCK_RETRY_SLEEP_SECONDS,
|
|
21
|
+
QUERY_NAME_PATTERN,
|
|
22
|
+
SENTINEL_INSERT_FAILURE_MESSAGE,
|
|
23
|
+
SENTINEL_SELECT_FAILURE_MESSAGE,
|
|
24
|
+
STOP_WRAPPER_DEBOUNCE_SECONDS,
|
|
25
|
+
STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE,
|
|
26
|
+
WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG,
|
|
27
|
+
WINDOWS_DETACHED_PROCESS_FLAG,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _matches_query_pattern(candidate_name: str) -> bool:
|
|
32
|
+
return re.fullmatch(QUERY_NAME_PATTERN, candidate_name) is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_query_name_pattern_allows_canonical_pre_baked_query_name() -> None:
|
|
36
|
+
assert _matches_query_pattern("top_blockers_last_24_hours")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_query_name_pattern_rejects_path_traversal() -> None:
|
|
40
|
+
assert not _matches_query_pattern("../etc/passwd")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_query_name_pattern_rejects_uppercase() -> None:
|
|
44
|
+
assert not _matches_query_pattern("TopBlockers")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_query_name_pattern_rejects_hyphens() -> None:
|
|
48
|
+
assert not _matches_query_pattern("top-blockers")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_query_name_pattern_rejects_empty_string() -> None:
|
|
52
|
+
assert not _matches_query_pattern("")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_unknown_query_exit_code_distinguishes_from_success() -> None:
|
|
56
|
+
assert EXIT_CODE_UNKNOWN_QUERY != EXIT_CODE_SUCCESS
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_unknown_query_exit_code_distinguishes_from_extractor_offline_fallback() -> None:
|
|
60
|
+
assert EXIT_CODE_UNKNOWN_QUERY != EXIT_CODE_EXTRACTOR_ENVIRONMENT_MISSING
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_unknown_query_exit_code_distinguishes_from_init_environment_missing() -> None:
|
|
64
|
+
assert EXIT_CODE_UNKNOWN_QUERY != EXIT_CODE_ENVIRONMENT_MISSING
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_extractor_offline_fallback_matches_success_so_stop_hook_does_not_surface_failure() -> None:
|
|
68
|
+
assert EXIT_CODE_EXTRACTOR_ENVIRONMENT_MISSING == EXIT_CODE_SUCCESS
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_sentinel_insert_failure_message_is_distinct_from_select_failure() -> None:
|
|
72
|
+
assert SENTINEL_INSERT_FAILURE_MESSAGE != SENTINEL_SELECT_FAILURE_MESSAGE
|
|
73
|
+
assert SENTINEL_INSERT_FAILURE_MESSAGE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_resolver_falls_back_to_home_when_claude_home_is_empty(
|
|
77
|
+
monkeypatch,
|
|
78
|
+
) -> None:
|
|
79
|
+
monkeypatch.setenv("CLAUDE_HOME", "")
|
|
80
|
+
|
|
81
|
+
assert (
|
|
82
|
+
hook_log_extractor_constants._resolve_claude_home_directory()
|
|
83
|
+
== Path.home() / ".claude"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_resolver_falls_back_to_home_when_claude_home_is_whitespace(
|
|
88
|
+
monkeypatch,
|
|
89
|
+
) -> None:
|
|
90
|
+
monkeypatch.setenv("CLAUDE_HOME", " ")
|
|
91
|
+
|
|
92
|
+
assert (
|
|
93
|
+
hook_log_extractor_constants._resolve_claude_home_directory()
|
|
94
|
+
== Path.home() / ".claude"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_lock_retry_constants_are_positive_and_bounded() -> None:
|
|
99
|
+
assert LOCK_MAXIMUM_RETRY_COUNT > 0
|
|
100
|
+
assert LOCK_RETRY_SLEEP_SECONDS > 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_stop_wrapper_debounce_seconds_is_positive() -> None:
|
|
104
|
+
assert STOP_WRAPPER_DEBOUNCE_SECONDS > 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_stop_wrapper_last_run_timestamp_file_is_under_claude_home() -> None:
|
|
108
|
+
expected_path = (
|
|
109
|
+
hook_log_extractor_constants._resolve_claude_home_directory()
|
|
110
|
+
/ "logs"
|
|
111
|
+
/ "hooks"
|
|
112
|
+
/ ".state"
|
|
113
|
+
/ "stop_wrapper_last_run.txt"
|
|
114
|
+
)
|
|
115
|
+
assert Path(STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE) == expected_path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_windows_creation_flags_are_distinct_nonzero_bits() -> None:
|
|
119
|
+
assert WINDOWS_DETACHED_PROCESS_FLAG > 0
|
|
120
|
+
assert WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG > 0
|
|
121
|
+
assert (
|
|
122
|
+
WINDOWS_DETACHED_PROCESS_FLAG & WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG
|
|
123
|
+
) == 0
|
|
@@ -15,3 +15,8 @@ def test_user_facing_notice_is_nonempty_string() -> None:
|
|
|
15
15
|
def test_user_facing_tdd_notice_is_nonempty_string() -> None:
|
|
16
16
|
assert isinstance(messages.USER_FACING_TDD_NOTICE, str)
|
|
17
17
|
assert messages.USER_FACING_TDD_NOTICE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_user_facing_askuserquestion_notice_is_nonempty_string() -> None:
|
|
21
|
+
assert isinstance(messages.USER_FACING_ASKUSERQUESTION_NOTICE, str)
|
|
22
|
+
assert messages.USER_FACING_ASKUSERQUESTION_NOTICE
|