claude-dev-env 1.31.0 → 1.33.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/hooks/blocking/code_rules_enforcer.py +109 -0
- package/hooks/blocking/test_windows_rmtree_blocker.py +155 -0
- package/hooks/blocking/windows_rmtree_blocker.py +102 -0
- package/hooks/config/hook_log_extractor_constants.py +13 -0
- package/hooks/config/session_env_cleanup_constants.py +20 -0
- package/hooks/config/test_hook_log_extractor_constants.py +27 -0
- package/hooks/config/test_session_env_cleanup_constants.py +60 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +107 -19
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +258 -11
- package/hooks/hooks.json +15 -0
- package/hooks/session/session_env_cleanup.py +130 -0
- package/hooks/session/test_session_env_cleanup.py +280 -0
- package/package.json +1 -1
- package/rules/windows-filesystem-safe.md +91 -0
- package/skills/bugteam/PROMPTS.md +39 -0
- package/skills/bugteam/SKILL.md +49 -1
- package/skills/bugteam/SKILL_EVALS.md +1 -1
- package/skills/bugteam/reference/copilot-gap-analysis.md +496 -0
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/scripts/README.md +17 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +94 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +260 -0
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +17 -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 +164 -0
|
@@ -60,6 +60,13 @@ NOT_INSIDE_TYPE_CHECKING_BLOCK = -1
|
|
|
60
60
|
MAX_ISSUES_PER_CHECK = 3
|
|
61
61
|
FILE_GLOBAL_UPPER_SNAKE_PATTERN = re.compile(r"^_?[A-Z][A-Z0-9_]*$")
|
|
62
62
|
|
|
63
|
+
COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({
|
|
64
|
+
"list", "tuple", "set", "frozenset", "dict",
|
|
65
|
+
"Iterable", "Sequence", "Mapping", "MutableMapping", "FrozenSet",
|
|
66
|
+
})
|
|
67
|
+
COLLECTION_BY_NAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9]*_by_[a-z][a-z0-9_]*$")
|
|
68
|
+
CLI_FILE_PATH_MARKERS: tuple[str, ...] = ("/scripts/", "\\scripts\\", "_cli.py", "/cli.py", "\\cli.py")
|
|
69
|
+
|
|
63
70
|
|
|
64
71
|
def get_file_extension(file_path: str) -> str:
|
|
65
72
|
"""Extract lowercase file extension."""
|
|
@@ -1933,6 +1940,106 @@ def check_unused_optional_parameters(content: str, file_path: str) -> list[str]:
|
|
|
1933
1940
|
return issues
|
|
1934
1941
|
|
|
1935
1942
|
|
|
1943
|
+
def _annotation_names_collection(annotation_node: ast.expr | None) -> bool:
|
|
1944
|
+
if annotation_node is None:
|
|
1945
|
+
return False
|
|
1946
|
+
if isinstance(annotation_node, ast.Name):
|
|
1947
|
+
return annotation_node.id in COLLECTION_TYPE_NAMES
|
|
1948
|
+
if isinstance(annotation_node, ast.Subscript):
|
|
1949
|
+
return _annotation_names_collection(annotation_node.value)
|
|
1950
|
+
if isinstance(annotation_node, ast.Attribute):
|
|
1951
|
+
return annotation_node.attr in COLLECTION_TYPE_NAMES
|
|
1952
|
+
return False
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
def check_collection_prefix(content: str, file_path: str) -> list[str]:
|
|
1956
|
+
if is_test_file(file_path):
|
|
1957
|
+
return []
|
|
1958
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
1959
|
+
return []
|
|
1960
|
+
try:
|
|
1961
|
+
tree = ast.parse(content)
|
|
1962
|
+
except SyntaxError:
|
|
1963
|
+
return []
|
|
1964
|
+
issues: list[str] = []
|
|
1965
|
+
for node in tree.body:
|
|
1966
|
+
target_name: str | None = None
|
|
1967
|
+
target_line = 0
|
|
1968
|
+
is_collection_value = False
|
|
1969
|
+
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
1970
|
+
target_name = node.target.id
|
|
1971
|
+
target_line = node.lineno
|
|
1972
|
+
is_collection_value = _annotation_names_collection(node.annotation)
|
|
1973
|
+
elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
1974
|
+
target_name = node.targets[0].id
|
|
1975
|
+
target_line = node.lineno
|
|
1976
|
+
is_collection_value = isinstance(node.value, (ast.Tuple, ast.List, ast.Set, ast.Dict))
|
|
1977
|
+
if target_name is None or not is_collection_value:
|
|
1978
|
+
continue
|
|
1979
|
+
if not UPPER_SNAKE_CONSTANT_PATTERN.match(target_name):
|
|
1980
|
+
continue
|
|
1981
|
+
if target_name.startswith("ALL_") or COLLECTION_BY_NAME_PATTERN.match(target_name.lower()):
|
|
1982
|
+
continue
|
|
1983
|
+
issues.append(
|
|
1984
|
+
f"Line {target_line}: Collection constant {target_name} - prefix with ALL_ (CODE_RULES §5)"
|
|
1985
|
+
)
|
|
1986
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1987
|
+
break
|
|
1988
|
+
for node in ast.walk(tree):
|
|
1989
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1990
|
+
continue
|
|
1991
|
+
for each_arg in _collect_annotated_arguments(node):
|
|
1992
|
+
if not _annotation_names_collection(each_arg.annotation):
|
|
1993
|
+
continue
|
|
1994
|
+
if each_arg.arg in {"self", "cls"}:
|
|
1995
|
+
continue
|
|
1996
|
+
if each_arg.arg.startswith("all_") or COLLECTION_BY_NAME_PATTERN.match(each_arg.arg):
|
|
1997
|
+
continue
|
|
1998
|
+
issues.append(
|
|
1999
|
+
f"Line {each_arg.lineno}: Collection parameter {each_arg.arg} - prefix with all_ (CODE_RULES §5)"
|
|
2000
|
+
)
|
|
2001
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
2002
|
+
return issues
|
|
2003
|
+
return issues
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _is_cli_entry_point(file_path: str) -> bool:
|
|
2007
|
+
path_lower = file_path.lower().replace("\\", "/")
|
|
2008
|
+
return any(marker.replace("\\", "/") in path_lower for marker in CLI_FILE_PATH_MARKERS)
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
def check_library_print(content: str, file_path: str) -> list[str]:
|
|
2012
|
+
if is_test_file(file_path) or is_config_file(file_path) or is_hook_infrastructure(file_path):
|
|
2013
|
+
return []
|
|
2014
|
+
if _is_cli_entry_point(file_path):
|
|
2015
|
+
return []
|
|
2016
|
+
if get_file_extension(file_path) not in PYTHON_EXTENSIONS:
|
|
2017
|
+
return []
|
|
2018
|
+
try:
|
|
2019
|
+
tree = ast.parse(content)
|
|
2020
|
+
except SyntaxError:
|
|
2021
|
+
return []
|
|
2022
|
+
issues: list[str] = []
|
|
2023
|
+
for node in ast.walk(tree):
|
|
2024
|
+
if not isinstance(node, ast.Call):
|
|
2025
|
+
continue
|
|
2026
|
+
function_reference = node.func
|
|
2027
|
+
if isinstance(function_reference, ast.Name) and function_reference.id == "print":
|
|
2028
|
+
issues.append(
|
|
2029
|
+
f"Line {node.lineno}: Library print() - route through logger or accept an explicit stream parameter"
|
|
2030
|
+
)
|
|
2031
|
+
elif isinstance(function_reference, ast.Attribute) and function_reference.attr == "write":
|
|
2032
|
+
value_node = function_reference.value
|
|
2033
|
+
if isinstance(value_node, ast.Attribute) and isinstance(value_node.value, ast.Name):
|
|
2034
|
+
if value_node.value.id == "sys" and value_node.attr in {"stdout", "stderr"}:
|
|
2035
|
+
issues.append(
|
|
2036
|
+
f"Line {node.lineno}: sys.{value_node.attr}.write - route through logger"
|
|
2037
|
+
)
|
|
2038
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
2039
|
+
break
|
|
2040
|
+
return issues
|
|
2041
|
+
|
|
2042
|
+
|
|
1936
2043
|
def validate_content(content: str, file_path: str, old_content: str = "") -> list[str]:
|
|
1937
2044
|
"""Run all applicable validators on content.
|
|
1938
2045
|
|
|
@@ -1964,6 +2071,8 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
1964
2071
|
all_issues.extend(check_existence_check_tests(content, file_path))
|
|
1965
2072
|
all_issues.extend(check_constant_equality_tests(content, file_path))
|
|
1966
2073
|
all_issues.extend(check_unused_optional_parameters(content, file_path))
|
|
2074
|
+
all_issues.extend(check_collection_prefix(content, file_path))
|
|
2075
|
+
all_issues.extend(check_library_print(content, file_path))
|
|
1967
2076
|
check_incomplete_mocks(content, file_path)
|
|
1968
2077
|
check_duplicated_format_patterns(content, file_path)
|
|
1969
2078
|
|
|
@@ -0,0 +1,155 @@
|
|
|
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_blocks_rmtree_with_nested_parens_in_args() -> None:
|
|
80
|
+
assert payload_contains_unsafe_rmtree(
|
|
81
|
+
"shutil.rmtree(Path(target).resolve(), ignore_errors=True)"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_extract_payload_handles_write_content() -> None:
|
|
86
|
+
extracted = extract_payload_text("Write", {"content": "abc"})
|
|
87
|
+
assert extracted == "abc"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_extract_payload_handles_edit_new_string() -> None:
|
|
91
|
+
extracted = extract_payload_text("Edit", {"new_string": "abc"})
|
|
92
|
+
assert extracted == "abc"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_extract_payload_handles_bash_command() -> None:
|
|
96
|
+
extracted = extract_payload_text("Bash", {"command": "ls"})
|
|
97
|
+
assert extracted == "ls"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_extract_payload_returns_empty_for_unknown_tool() -> None:
|
|
101
|
+
extracted = extract_payload_text("OtherTool", {"content": "abc"})
|
|
102
|
+
assert extracted == ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _run_hook(hook_input: dict) -> tuple[str, int]:
|
|
106
|
+
captured = io.StringIO()
|
|
107
|
+
exit_code = 0
|
|
108
|
+
sys.stdin = io.StringIO(json.dumps(hook_input))
|
|
109
|
+
try:
|
|
110
|
+
with redirect_stdout(captured):
|
|
111
|
+
try:
|
|
112
|
+
hook_module.main()
|
|
113
|
+
except SystemExit as exit_signal:
|
|
114
|
+
exit_code = exit_signal.code or 0
|
|
115
|
+
finally:
|
|
116
|
+
sys.stdin = sys.__stdin__
|
|
117
|
+
return captured.getvalue(), exit_code
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_main_blocks_unsafe_bash_command() -> None:
|
|
121
|
+
stdout_text, exit_code = _run_hook(
|
|
122
|
+
{
|
|
123
|
+
"tool_name": "Bash",
|
|
124
|
+
"tool_input": {
|
|
125
|
+
"command": (
|
|
126
|
+
'python -c "import shutil; '
|
|
127
|
+
"shutil.rmtree(r'/tmp/x', ignore_errors=True)\""
|
|
128
|
+
)
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
assert exit_code == 0
|
|
133
|
+
response_payload = json.loads(stdout_text)
|
|
134
|
+
decision_block = response_payload["hookSpecificOutput"]
|
|
135
|
+
assert decision_block["permissionDecision"] == "deny"
|
|
136
|
+
assert "windows-rmtree" in decision_block["permissionDecisionReason"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_main_passes_through_safe_write() -> None:
|
|
140
|
+
stdout_text, exit_code = _run_hook(
|
|
141
|
+
{
|
|
142
|
+
"tool_name": "Write",
|
|
143
|
+
"tool_input": {"content": "shutil.rmtree(path, onexc=handler)"},
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
assert exit_code == 0
|
|
147
|
+
assert stdout_text == ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_main_passes_through_unrelated_tool() -> None:
|
|
151
|
+
stdout_text, exit_code = _run_hook(
|
|
152
|
+
{"tool_name": "Read", "tool_input": {"file_path": "/tmp/x"}}
|
|
153
|
+
)
|
|
154
|
+
assert exit_code == 0
|
|
155
|
+
assert stdout_text == ""
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
|
|
20
|
+
def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
|
|
21
|
+
if not payload_text:
|
|
22
|
+
return False
|
|
23
|
+
rmtree_ignore_errors_pattern = re.compile(
|
|
24
|
+
r"shutil\s*\.\s*rmtree\s*\(.*?\bignore_errors\s*=\s*True\b",
|
|
25
|
+
re.DOTALL,
|
|
26
|
+
)
|
|
27
|
+
return bool(rmtree_ignore_errors_pattern.search(payload_text))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_payload_text(tool_name: str, tool_input: dict) -> str:
|
|
31
|
+
if tool_name in {"Write", "Edit"}:
|
|
32
|
+
return tool_input.get("content", "") or tool_input.get("new_string", "") or ""
|
|
33
|
+
if tool_name == "Bash":
|
|
34
|
+
return tool_input.get("command", "") or ""
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main() -> None:
|
|
39
|
+
corrective_message = (
|
|
40
|
+
"BLOCKED [windows-rmtree]: shutil.rmtree(..., ignore_errors=True) silently "
|
|
41
|
+
"fails on Windows when a file carries the ReadOnly attribute "
|
|
42
|
+
"(FILE_ATTRIBUTE_READONLY). The PermissionError is swallowed and the tree "
|
|
43
|
+
"stays on disk -- cleanup looks successful but removes nothing. Linux is "
|
|
44
|
+
"unaffected because unlink only needs write on the parent directory.\n\n"
|
|
45
|
+
"Use a Windows-safe handler that strips the attribute and retries the "
|
|
46
|
+
"syscall:\n\n"
|
|
47
|
+
" import os\n"
|
|
48
|
+
" import shutil\n"
|
|
49
|
+
" import stat\n"
|
|
50
|
+
" import sys\n\n"
|
|
51
|
+
" def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n"
|
|
52
|
+
" try:\n"
|
|
53
|
+
" os.chmod(target_path, stat.S_IWRITE)\n"
|
|
54
|
+
" removal_function(target_path)\n"
|
|
55
|
+
" except OSError:\n"
|
|
56
|
+
" pass\n\n"
|
|
57
|
+
" def force_rmtree(target_path: str) -> None:\n"
|
|
58
|
+
" handler_kw = (\n"
|
|
59
|
+
' {"onexc": _strip_read_only_and_retry}\n'
|
|
60
|
+
" if sys.version_info >= (3, 12)\n"
|
|
61
|
+
' else {"onerror": _strip_read_only_and_retry}\n'
|
|
62
|
+
" )\n"
|
|
63
|
+
" try:\n"
|
|
64
|
+
" shutil.rmtree(target_path, **handler_kw)\n"
|
|
65
|
+
" except OSError:\n"
|
|
66
|
+
" pass\n\n"
|
|
67
|
+
"Two things to know about the handler:\n"
|
|
68
|
+
" - *_exc_info collapses the signature difference. onerror passes "
|
|
69
|
+
"(type, value, traceback); onexc (Python 3.12+) passes a single exception.\n"
|
|
70
|
+
" - removal_function is whichever syscall rmtree was attempting "
|
|
71
|
+
"(os.unlink for files, os.rmdir for dirs). Re-call it after chmod to finish "
|
|
72
|
+
"the work that originally failed.\n\n"
|
|
73
|
+
"See ~/.claude/rules/windows-filesystem-safe.md for full guidance."
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
hook_input = json.load(sys.stdin)
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
sys.stderr.write("windows_rmtree_blocker: malformed JSON on stdin\n")
|
|
79
|
+
sys.exit(0)
|
|
80
|
+
|
|
81
|
+
tool_name = hook_input.get("tool_name", "")
|
|
82
|
+
tool_input = hook_input.get("tool_input", {})
|
|
83
|
+
|
|
84
|
+
payload_text = extract_payload_text(tool_name, tool_input)
|
|
85
|
+
|
|
86
|
+
if not payload_contains_unsafe_rmtree(payload_text):
|
|
87
|
+
sys.exit(0)
|
|
88
|
+
|
|
89
|
+
deny_response = {
|
|
90
|
+
"hookSpecificOutput": {
|
|
91
|
+
"hookEventName": "PreToolUse",
|
|
92
|
+
"permissionDecision": "deny",
|
|
93
|
+
"permissionDecisionReason": corrective_message,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
print(json.dumps(deny_response))
|
|
97
|
+
sys.stdout.flush()
|
|
98
|
+
sys.exit(0)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|
|
@@ -216,6 +216,19 @@ BWS_RUN_SEPARATOR: str = "--"
|
|
|
216
216
|
BWS_RUN_SUBCOMMAND: str = "run"
|
|
217
217
|
STOP_WRAPPER_EXTRACTOR_SCRIPT_NAME: str = "hook_log_extractor.py"
|
|
218
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
|
+
|
|
219
232
|
LOCK_MAXIMUM_RETRY_COUNT: int = 30
|
|
220
233
|
LOCK_RETRY_SLEEP_SECONDS: float = 0.1
|
|
221
234
|
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS: tuple[int, int] = (3, 12)
|
|
15
|
+
|
|
16
|
+
SESSION_ID_PAYLOAD_KEY: str = "session_id"
|
|
17
|
+
|
|
18
|
+
SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
19
|
+
|
|
20
|
+
WINDOWS_PLATFORM_TAG = "win32"
|
|
@@ -21,6 +21,10 @@ from config.hook_log_extractor_constants import (
|
|
|
21
21
|
QUERY_NAME_PATTERN,
|
|
22
22
|
SENTINEL_INSERT_FAILURE_MESSAGE,
|
|
23
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,
|
|
24
28
|
)
|
|
25
29
|
|
|
26
30
|
|
|
@@ -94,3 +98,26 @@ def test_resolver_falls_back_to_home_when_claude_home_is_whitespace(
|
|
|
94
98
|
def test_lock_retry_constants_are_positive_and_bounded() -> None:
|
|
95
99
|
assert LOCK_MAXIMUM_RETRY_COUNT > 0
|
|
96
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
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tests for session_env_cleanup_constants — behavioral checks on SESSION_ID_PATTERN."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_CONFIG_DIRECTORY = Path(__file__).resolve().parent
|
|
9
|
+
_HOOKS_ROOT = _CONFIG_DIRECTORY.parent
|
|
10
|
+
for each_sys_path_entry in (str(_CONFIG_DIRECTORY), str(_HOOKS_ROOT)):
|
|
11
|
+
if each_sys_path_entry not in sys.path:
|
|
12
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
13
|
+
|
|
14
|
+
from config.session_env_cleanup_constants import SESSION_ID_PATTERN, SESSION_ID_PAYLOAD_KEY
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestSessionIdPatternAccepts:
|
|
18
|
+
def test_accepts_uuid_with_hyphens(self) -> None:
|
|
19
|
+
valid_uuid_input = "5fcc01b3-138b-49e1-9976-ff1035013a4f"
|
|
20
|
+
matched = SESSION_ID_PATTERN.fullmatch(valid_uuid_input)
|
|
21
|
+
assert matched.group(0) == valid_uuid_input
|
|
22
|
+
|
|
23
|
+
def test_accepts_alphanumeric_only(self) -> None:
|
|
24
|
+
alphanumeric_input = "abc123XYZ"
|
|
25
|
+
matched = SESSION_ID_PATTERN.fullmatch(alphanumeric_input)
|
|
26
|
+
assert matched.group(0) == alphanumeric_input
|
|
27
|
+
|
|
28
|
+
def test_accepts_underscore_separated(self) -> None:
|
|
29
|
+
underscore_input = "session_42_alpha"
|
|
30
|
+
matched = SESSION_ID_PATTERN.fullmatch(underscore_input)
|
|
31
|
+
assert matched.group(0) == underscore_input
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestSessionIdPayloadKey:
|
|
35
|
+
def test_session_id_payload_key_matches_hook_protocol_field(self) -> None:
|
|
36
|
+
assert SESSION_ID_PAYLOAD_KEY == "session_id"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestSessionIdPatternRejects:
|
|
40
|
+
def test_rejects_forward_slash(self) -> None:
|
|
41
|
+
assert SESSION_ID_PATTERN.match("etc/passwd") is None
|
|
42
|
+
|
|
43
|
+
def test_rejects_back_slash(self) -> None:
|
|
44
|
+
assert SESSION_ID_PATTERN.match("Users\\jon") is None
|
|
45
|
+
|
|
46
|
+
def test_rejects_parent_traversal(self) -> None:
|
|
47
|
+
assert SESSION_ID_PATTERN.match("..") is None
|
|
48
|
+
|
|
49
|
+
def test_rejects_absolute_windows_path(self) -> None:
|
|
50
|
+
assert SESSION_ID_PATTERN.match("C:\\Windows\\Temp") is None
|
|
51
|
+
|
|
52
|
+
def test_rejects_empty_string(self) -> None:
|
|
53
|
+
assert SESSION_ID_PATTERN.match("") is None
|
|
54
|
+
|
|
55
|
+
def test_rejects_overlong_input(self) -> None:
|
|
56
|
+
overlong_input = "a" * 65
|
|
57
|
+
assert SESSION_ID_PATTERN.match(overlong_input) is None
|
|
58
|
+
|
|
59
|
+
def test_rejects_dot_in_middle(self) -> None:
|
|
60
|
+
assert SESSION_ID_PATTERN.match("session.uuid") is None
|