claude-dev-env 1.32.0 → 1.34.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 +7 -0
- package/hooks/blocking/windows_rmtree_blocker.py +45 -49
- package/hooks/config/session_env_cleanup_constants.py +3 -1
- package/hooks/config/test_session_env_cleanup_constants.py +6 -1
- package/hooks/hooks.json +12 -0
- package/hooks/observability/instructions_loaded_logger.py +38 -0
- package/hooks/observability/test_instructions_loaded_logger.py +85 -0
- package/hooks/session/session_env_cleanup.py +5 -4
- package/hooks/session/test_session_env_cleanup.py +2 -0
- package/package.json +1 -1
- package/rules/code-standards.md +0 -2
- package/rules/file-global-constants.md +4 -0
- package/rules/shell-invocation-policy.md +144 -0
- package/rules/windows-filesystem-safe.md +1 -3
- package/scripts/Audit-ShellPolicy.ps1 +142 -0
- package/scripts/Migrate-ShellPolicy.ps1 +192 -0
- package/skills/auditing-claude-config/SKILL.md +171 -0
- package/skills/bugteam/PROMPTS.md +39 -0
- package/skills/bugteam/SKILL.md +34 -0
- package/skills/bugteam/reference/copilot-gap-analysis.md +496 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +94 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +34 -12
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +17 -0
- package/skills/caveman/SKILL.md +38 -0
- package/skills/pr-converge/SKILL.md +218 -0
- package/skills/rebase/SKILL.md +15 -8
|
@@ -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
|
|
|
@@ -76,6 +76,12 @@ def test_allows_ignore_errors_false() -> None:
|
|
|
76
76
|
)
|
|
77
77
|
|
|
78
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
|
+
|
|
79
85
|
def test_extract_payload_handles_write_content() -> None:
|
|
80
86
|
extracted = extract_payload_text("Write", {"content": "abc"})
|
|
81
87
|
assert extracted == "abc"
|
|
@@ -98,6 +104,7 @@ def test_extract_payload_returns_empty_for_unknown_tool() -> None:
|
|
|
98
104
|
|
|
99
105
|
def _run_hook(hook_input: dict) -> tuple[str, int]:
|
|
100
106
|
captured = io.StringIO()
|
|
107
|
+
exit_code = 0
|
|
101
108
|
sys.stdin = io.StringIO(json.dumps(hook_input))
|
|
102
109
|
try:
|
|
103
110
|
with redirect_stdout(captured):
|
|
@@ -16,70 +16,66 @@ import json
|
|
|
16
16
|
import re
|
|
17
17
|
import sys
|
|
18
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
19
|
|
|
65
20
|
def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
|
|
66
21
|
if not payload_text:
|
|
67
22
|
return False
|
|
68
|
-
|
|
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))
|
|
69
28
|
|
|
70
29
|
|
|
71
30
|
def extract_payload_text(tool_name: str, tool_input: dict) -> str:
|
|
72
|
-
if tool_name in
|
|
31
|
+
if tool_name in {"Write", "Edit"}:
|
|
73
32
|
return tool_input.get("content", "") or tool_input.get("new_string", "") or ""
|
|
74
|
-
if tool_name ==
|
|
33
|
+
if tool_name == "Bash":
|
|
75
34
|
return tool_input.get("command", "") or ""
|
|
76
35
|
return ""
|
|
77
36
|
|
|
78
37
|
|
|
79
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
|
+
)
|
|
80
75
|
try:
|
|
81
76
|
hook_input = json.load(sys.stdin)
|
|
82
77
|
except json.JSONDecodeError:
|
|
78
|
+
sys.stderr.write("windows_rmtree_blocker: malformed JSON on stdin\n")
|
|
83
79
|
sys.exit(0)
|
|
84
80
|
|
|
85
81
|
tool_name = hook_input.get("tool_name", "")
|
|
@@ -94,7 +90,7 @@ def main() -> None:
|
|
|
94
90
|
"hookSpecificOutput": {
|
|
95
91
|
"hookEventName": "PreToolUse",
|
|
96
92
|
"permissionDecision": "deny",
|
|
97
|
-
"permissionDecisionReason":
|
|
93
|
+
"permissionDecisionReason": corrective_message,
|
|
98
94
|
}
|
|
99
95
|
}
|
|
100
96
|
print(json.dumps(deny_response))
|
|
@@ -11,7 +11,9 @@ SECONDS_PER_DAY = 24 * 60 * 60
|
|
|
11
11
|
STALE_AGE_DAYS = 7
|
|
12
12
|
STALE_AGE_SECONDS = STALE_AGE_DAYS * SECONDS_PER_DAY
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS: tuple[int, int] = (3, 12)
|
|
15
|
+
|
|
16
|
+
SESSION_ID_PAYLOAD_KEY: str = "session_id"
|
|
15
17
|
|
|
16
18
|
SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
17
19
|
|
|
@@ -11,7 +11,7 @@ for each_sys_path_entry in (str(_CONFIG_DIRECTORY), str(_HOOKS_ROOT)):
|
|
|
11
11
|
if each_sys_path_entry not in sys.path:
|
|
12
12
|
sys.path.insert(0, each_sys_path_entry)
|
|
13
13
|
|
|
14
|
-
from config.session_env_cleanup_constants import SESSION_ID_PATTERN
|
|
14
|
+
from config.session_env_cleanup_constants import SESSION_ID_PATTERN, SESSION_ID_PAYLOAD_KEY
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class TestSessionIdPatternAccepts:
|
|
@@ -31,6 +31,11 @@ class TestSessionIdPatternAccepts:
|
|
|
31
31
|
assert matched.group(0) == underscore_input
|
|
32
32
|
|
|
33
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
|
+
|
|
34
39
|
class TestSessionIdPatternRejects:
|
|
35
40
|
def test_rejects_forward_slash(self) -> None:
|
|
36
41
|
assert SESSION_ID_PATTERN.match("etc/passwd") is None
|
package/hooks/hooks.json
CHANGED
|
@@ -224,6 +224,18 @@
|
|
|
224
224
|
}
|
|
225
225
|
]
|
|
226
226
|
}
|
|
227
|
+
],
|
|
228
|
+
"InstructionsLoaded": [
|
|
229
|
+
{
|
|
230
|
+
"matcher": "session_start|nested_traversal|path_glob_match|include|compact",
|
|
231
|
+
"hooks": [
|
|
232
|
+
{
|
|
233
|
+
"type": "command",
|
|
234
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/observability/instructions_loaded_logger.py",
|
|
235
|
+
"timeout": 10
|
|
236
|
+
}
|
|
237
|
+
]
|
|
238
|
+
}
|
|
227
239
|
]
|
|
228
240
|
}
|
|
229
241
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> int:
|
|
8
|
+
log_path = Path.home() / ".claude" / "logs" / "instructions_loaded.jsonl"
|
|
9
|
+
payload_fields = (
|
|
10
|
+
"file_path",
|
|
11
|
+
"load_reason",
|
|
12
|
+
"memory_type",
|
|
13
|
+
"trigger_file_path",
|
|
14
|
+
"parent_file_path",
|
|
15
|
+
"globs",
|
|
16
|
+
"session_id",
|
|
17
|
+
)
|
|
18
|
+
try:
|
|
19
|
+
payload = json.load(sys.stdin)
|
|
20
|
+
record = {"timestamp": datetime.now(timezone.utc).isoformat()}
|
|
21
|
+
for each_field_name in payload_fields:
|
|
22
|
+
record[each_field_name] = payload.get(each_field_name)
|
|
23
|
+
except Exception as exception:
|
|
24
|
+
record = {
|
|
25
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
26
|
+
"error": str(exception),
|
|
27
|
+
}
|
|
28
|
+
try:
|
|
29
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
with log_path.open("a", encoding="utf-8") as log_file:
|
|
31
|
+
log_file.write(json.dumps(record) + "\n")
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tests for instructions_loaded_logger observability hook."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCRIPT_PATH = Path(__file__).parent / "instructions_loaded_logger.py"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run_hook(payload: dict, fake_home: Path) -> subprocess.CompletedProcess[str]:
|
|
15
|
+
child_environment = os.environ.copy()
|
|
16
|
+
child_environment["HOME"] = str(fake_home)
|
|
17
|
+
child_environment["USERPROFILE"] = str(fake_home)
|
|
18
|
+
return subprocess.run(
|
|
19
|
+
[sys.executable, str(SCRIPT_PATH)],
|
|
20
|
+
input=json.dumps(payload),
|
|
21
|
+
text=True,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
check=False,
|
|
24
|
+
env=child_environment,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_should_write_record_with_known_payload_fields_to_jsonl_log() -> None:
|
|
29
|
+
with tempfile.TemporaryDirectory() as fake_home_string:
|
|
30
|
+
fake_home = Path(fake_home_string)
|
|
31
|
+
payload = {
|
|
32
|
+
"file_path": "/tmp/CLAUDE.md",
|
|
33
|
+
"load_reason": "session_start",
|
|
34
|
+
"memory_type": "User",
|
|
35
|
+
"trigger_file_path": "/tmp/trigger",
|
|
36
|
+
"parent_file_path": "/tmp/parent",
|
|
37
|
+
"globs": ["**/*.py"],
|
|
38
|
+
"session_id": "abc-123",
|
|
39
|
+
}
|
|
40
|
+
completed = _run_hook(payload, fake_home)
|
|
41
|
+
assert completed.returncode == 0, completed.stderr
|
|
42
|
+
log_path = fake_home / ".claude" / "logs" / "instructions_loaded.jsonl"
|
|
43
|
+
assert log_path.exists()
|
|
44
|
+
record = json.loads(log_path.read_text(encoding="utf-8").strip())
|
|
45
|
+
assert record["file_path"] == "/tmp/CLAUDE.md"
|
|
46
|
+
assert record["load_reason"] == "session_start"
|
|
47
|
+
assert record["session_id"] == "abc-123"
|
|
48
|
+
assert "timestamp" in record
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_should_exit_zero_and_record_error_when_stdin_payload_is_invalid_json() -> None:
|
|
52
|
+
with tempfile.TemporaryDirectory() as fake_home_string:
|
|
53
|
+
fake_home = Path(fake_home_string)
|
|
54
|
+
completed = subprocess.run(
|
|
55
|
+
[sys.executable, str(SCRIPT_PATH)],
|
|
56
|
+
input="not json",
|
|
57
|
+
text=True,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
check=False,
|
|
60
|
+
env={**os.environ, "HOME": str(fake_home), "USERPROFILE": str(fake_home)},
|
|
61
|
+
)
|
|
62
|
+
assert completed.returncode == 0, completed.stderr
|
|
63
|
+
log_path = fake_home / ".claude" / "logs" / "instructions_loaded.jsonl"
|
|
64
|
+
assert log_path.exists()
|
|
65
|
+
record = json.loads(log_path.read_text(encoding="utf-8").strip())
|
|
66
|
+
assert "error" in record
|
|
67
|
+
assert "timestamp" in record
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_should_exit_zero_when_log_directory_creation_fails() -> None:
|
|
71
|
+
with tempfile.TemporaryDirectory() as fake_home_string:
|
|
72
|
+
fake_home = Path(fake_home_string)
|
|
73
|
+
blocking_file = fake_home / ".claude"
|
|
74
|
+
blocking_file.write_text("not a directory", encoding="utf-8")
|
|
75
|
+
payload = {
|
|
76
|
+
"file_path": "/tmp/CLAUDE.md",
|
|
77
|
+
"load_reason": "path_glob_match",
|
|
78
|
+
"memory_type": "User",
|
|
79
|
+
"trigger_file_path": None,
|
|
80
|
+
"parent_file_path": None,
|
|
81
|
+
"globs": None,
|
|
82
|
+
"session_id": "abc-123",
|
|
83
|
+
}
|
|
84
|
+
completed = _run_hook(payload, fake_home)
|
|
85
|
+
assert completed.returncode == 0, completed.stderr
|
|
@@ -36,9 +36,10 @@ def _insert_hooks_tree_for_imports() -> None:
|
|
|
36
36
|
_insert_hooks_tree_for_imports()
|
|
37
37
|
|
|
38
38
|
from config.session_env_cleanup_constants import (
|
|
39
|
-
|
|
39
|
+
ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS,
|
|
40
40
|
SESSION_ENV_DIRECTORY,
|
|
41
41
|
SESSION_ID_PATTERN,
|
|
42
|
+
SESSION_ID_PAYLOAD_KEY,
|
|
42
43
|
STALE_AGE_SECONDS,
|
|
43
44
|
WINDOWS_PLATFORM_TAG,
|
|
44
45
|
)
|
|
@@ -57,7 +58,7 @@ def _strip_read_only_and_retry(
|
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
def _force_rmtree(target_path: str) -> None:
|
|
60
|
-
rmtree_onexc_python_version =
|
|
61
|
+
rmtree_onexc_python_version = ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS
|
|
61
62
|
try:
|
|
62
63
|
if sys.version_info >= rmtree_onexc_python_version:
|
|
63
64
|
shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
|
|
@@ -103,10 +104,10 @@ def _read_session_id_from_stdin() -> str:
|
|
|
103
104
|
return ""
|
|
104
105
|
if not isinstance(payload, dict):
|
|
105
106
|
return ""
|
|
106
|
-
raw_session_id = payload.get(
|
|
107
|
+
raw_session_id = payload.get(SESSION_ID_PAYLOAD_KEY)
|
|
107
108
|
if not isinstance(raw_session_id, str):
|
|
108
109
|
return ""
|
|
109
|
-
if not session_id_pattern.
|
|
110
|
+
if not session_id_pattern.fullmatch(raw_session_id):
|
|
110
111
|
return ""
|
|
111
112
|
return raw_session_id
|
|
112
113
|
|
|
@@ -179,6 +179,7 @@ class TestMainReadsSessionIdFromStdin:
|
|
|
179
179
|
with (
|
|
180
180
|
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
181
181
|
patch("sys.stdin", stdin_payload),
|
|
182
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
182
183
|
):
|
|
183
184
|
cleanup.main()
|
|
184
185
|
assert captured_call["session_id"] == "session-from-stdin"
|
|
@@ -197,6 +198,7 @@ class TestMainReadsSessionIdFromStdin:
|
|
|
197
198
|
with (
|
|
198
199
|
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
199
200
|
patch("sys.stdin", stdin_payload),
|
|
201
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
200
202
|
):
|
|
201
203
|
cleanup.main()
|
|
202
204
|
assert captured_call["session_id"] == ""
|
package/package.json
CHANGED
package/rules/code-standards.md
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
> **MANDATORY REFERENCE:** CODE_RULES.md - Load for ALL code generation.
|
|
4
4
|
> This is the single source of truth for code standards. Non-negotiable.
|
|
5
5
|
|
|
6
|
-
@~/.claude/docs/CODE_RULES.md
|
|
7
|
-
|
|
8
6
|
**Key principles (see CODE_RULES.md for complete reference):**
|
|
9
7
|
- Self-documenting code (no comments)
|
|
10
8
|
- Centralized configuration (one source of truth)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Shell Invocation Policy (pwsh-only)
|
|
2
|
+
|
|
3
|
+
**When this applies:** Every shell command issued through the `Bash` tool on Windows.
|
|
4
|
+
|
|
5
|
+
## What to use
|
|
6
|
+
|
|
7
|
+
### Pattern A — Run a `.ps1` script with named arguments
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pwsh -NoProfile -File 'Y:\absolute\path\to\Build-Skyline.ps1' -RunTests -Tag staging
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Use this when a `.ps1` script accepts named parameters. The `-File` form exposes the script path as a flat token, so `permissions.allow` rules of the form `Bash(pwsh -NoProfile -File *)` match the invocation directly.
|
|
14
|
+
|
|
15
|
+
### Pattern B — Run an inline expression
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
pwsh -NoProfile -Command "Get-Date -Format o"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use this for one or two lines of work that does not need a script file. Quote the entire `-Command` argument with double quotes; use single quotes inside for embedded strings.
|
|
22
|
+
|
|
23
|
+
### Pattern C — Multi-line inline script with a here-string
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pwsh -NoProfile -Command @'
|
|
27
|
+
$projects = Get-ChildItem -Path 'Y:\Projects\LLM Plugins' -Directory
|
|
28
|
+
$projects | Where-Object Name -Like 'claude*' | Select-Object FullName
|
|
29
|
+
'@
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Use this for multi-line logic without a separate `.ps1` file. The `@'...'@` form is literal — variables and backticks inside are not expanded.
|
|
33
|
+
|
|
34
|
+
### Pattern D — The built-in `PowerShell` tool
|
|
35
|
+
|
|
36
|
+
Use the `PowerShell` tool directly when the entire workflow is PowerShell and does not pipe through external `Bash`-tool-native commands. The built-in tool already runs PowerShell 7+ from `C:\Program Files\PowerShell\7\pwsh.exe`. It supports `run_in_background` for long-running tasks, which `Bash` invocations of `pwsh` do not.
|
|
37
|
+
|
|
38
|
+
## Migration mapping (replace left with right)
|
|
39
|
+
|
|
40
|
+
| Existing pattern | Replacement |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `powershell -Command "X"` | `pwsh -NoProfile -Command "X"` |
|
|
43
|
+
| `powershell.exe -Command "X"` | `pwsh -NoProfile -Command "X"` |
|
|
44
|
+
| `powershell -File path.ps1` | `pwsh -NoProfile -File 'path.ps1'` |
|
|
45
|
+
| `powershell.exe -File path.ps1` | `pwsh -NoProfile -File 'path.ps1'` |
|
|
46
|
+
| `powershell -Command "& 'path.ps1' -A v"` | `pwsh -NoProfile -File 'path.ps1' -A v` |
|
|
47
|
+
| `bash -c "X"` | `pwsh -NoProfile -Command "X"` |
|
|
48
|
+
| `cmd /c X` | `pwsh -NoProfile -Command "X"` |
|
|
49
|
+
| `cmd.exe /c X` | `pwsh -NoProfile -Command "X"` |
|
|
50
|
+
| `Bash(powershell:*)` (settings.json) | `Bash(pwsh:*)` |
|
|
51
|
+
| `Bash(powershell.exe:*)` (settings.json) | `Bash(pwsh:*)` |
|
|
52
|
+
|
|
53
|
+
## Common operations in pwsh
|
|
54
|
+
|
|
55
|
+
| Task | pwsh syntax |
|
|
56
|
+
|---|---|
|
|
57
|
+
| List directory names | `Get-ChildItem -Path 'X' -Directory -Name` |
|
|
58
|
+
| Read a whole file | `Get-Content -Path 'X' -Raw` |
|
|
59
|
+
| Write file (UTF-8 no BOM) | `[IO.File]::WriteAllText('X', $content, [Text.UTF8Encoding]::new($false))` |
|
|
60
|
+
| Test a path | `Test-Path 'X'` |
|
|
61
|
+
| Remove a directory | `Remove-Item -Path 'X' -Recurse -Force` |
|
|
62
|
+
| Activate a venv | `& 'Y:\path\.venv\Scripts\Activate.ps1'` |
|
|
63
|
+
| Run venv-Python | `& 'Y:\path\.venv\Scripts\python.exe' script.py` |
|
|
64
|
+
| Set env var (current process) | `$env:NAME = 'value'` |
|
|
65
|
+
| Pipe to ripgrep | `Get-ChildItem | Select-String -Pattern 'X'` |
|
|
66
|
+
| First match in a stream | `Select-Object -First 1` |
|
|
67
|
+
|
|
68
|
+
The `&` call operator is appropriate for invoking an executable at a path — for example, `& '<venv>\Scripts\python.exe' script.py`. The forbidden form is wrapping a script path inside `pwsh -Command "& 'X' -A v"`, where the call operator is inside a `-Command` payload and breaks `permissions.allow` matching. Use `pwsh -File 'X' -A v` instead for that case.
|
|
69
|
+
|
|
70
|
+
## External binaries usable from pwsh
|
|
71
|
+
|
|
72
|
+
Invoke these directly without wrapping:
|
|
73
|
+
|
|
74
|
+
- `git` — `git status`, `git log --oneline -10`, `git -C 'path' status`
|
|
75
|
+
- `gh` — `gh pr create`, `gh issue list`
|
|
76
|
+
- `python`, `pip` (via venv path: `& '.venv\Scripts\python.exe'`)
|
|
77
|
+
- `node`, `npm`, `npx`
|
|
78
|
+
- `rg` (ripgrep), `fd`, `es.exe` (Everything search)
|
|
79
|
+
- `pytest`, `mypy`, `pyright` (via venv)
|
|
80
|
+
|
|
81
|
+
## Verification
|
|
82
|
+
|
|
83
|
+
To confirm pwsh is correctly installed and routed:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
pwsh -NoProfile -Command "$PSVersionTable.PSVersion.ToString()"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Expected output: `7.x.x.x` or higher. The verified install at the time of writing this rule is `7.5.5.0` at `C:\Program Files\PowerShell\7\pwsh.exe`.
|
|
90
|
+
|
|
91
|
+
## Permission allowlist (settings.json `permissions.allow`)
|
|
92
|
+
|
|
93
|
+
These entries pre-approve canonical pwsh invocations:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
Bash(pwsh -NoProfile -File *)
|
|
97
|
+
Bash(pwsh -File *)
|
|
98
|
+
Bash(pwsh -NoProfile -Command *)
|
|
99
|
+
Bash(pwsh -Command *)
|
|
100
|
+
Bash(pwsh:*)
|
|
101
|
+
PowerShell
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The `PowerShell` entry auto-approves the built-in tool.
|
|
105
|
+
|
|
106
|
+
## Permission denylist (settings.json `permissions.deny`)
|
|
107
|
+
|
|
108
|
+
These entries block legacy shells:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
Bash(powershell *)
|
|
112
|
+
Bash(powershell.exe *)
|
|
113
|
+
Bash(powershell:*)
|
|
114
|
+
Bash(powershell.exe:*)
|
|
115
|
+
Bash(bash -c *)
|
|
116
|
+
Bash(bash --login *)
|
|
117
|
+
Bash(bash --rcfile *)
|
|
118
|
+
Bash(bash --init-file *)
|
|
119
|
+
Bash(cmd /c *)
|
|
120
|
+
Bash(cmd.exe /c *)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Migration scripts
|
|
124
|
+
|
|
125
|
+
Two scripts ship with this rule, located at `packages/claude-dev-env/scripts/`:
|
|
126
|
+
|
|
127
|
+
- `Audit-ShellPolicy.ps1` — scans `settings*.json` files under the configured project roots and prints one summary line: `POLICY: OK` or `POLICY: VIOLATIONS=<count> IN=<n> FILES`. Exit code 0 when clean, 1 when violations remain. Use this as a check before merging.
|
|
128
|
+
- `Migrate-ShellPolicy.ps1` — applies the migration mapping to `settings*.json` files in place. Defaults to dry-run; pass `-Apply` to write changes. Prints one summary line: `MIGRATED: <count> rules IN=<n> FILES` or `DRY RUN: would migrate <count> rules IN=<n> FILES`.
|
|
129
|
+
|
|
130
|
+
Run order: audit → migrate (dry run) → migrate (apply) → audit.
|
|
131
|
+
|
|
132
|
+
## Enforcement layers
|
|
133
|
+
|
|
134
|
+
1. **`permissions.allow`** pre-approves the canonical patterns so Claude never gets a prompt for them.
|
|
135
|
+
2. **`permissions.deny`** blocks the legacy patterns at the permission layer.
|
|
136
|
+
3. **`pwsh_enforcer.py`** PreToolUse hook catches edge cases that wildcard syntax misses (compound commands, process wrappers, alternate spellings). Source: `packages/claude-dev-env/hooks/blocking/pwsh_enforcer.py`.
|
|
137
|
+
4. **Migration scripts** keep existing `settings.local.json` files in compliance.
|
|
138
|
+
|
|
139
|
+
## Precedent
|
|
140
|
+
|
|
141
|
+
This rule mirrors the convention from [ProteoWizard/pwiz-ai](https://github.com/ProteoWizard/pwiz-ai) `CLAUDE.md`:
|
|
142
|
+
|
|
143
|
+
> "Always use `pwsh` (PowerShell 7), never `powershell` (5.1). The Bash tool uses Git Bash, which has limited Windows tool access. Route commands through PowerShell when needed."
|
|
144
|
+
> "Never use the `&` call operator — it breaks permissions matching. Use `-File` instead, which supports arguments directly."
|