claude-dev-env 8.105.2 → 8.106.1

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.
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ """PowerShell cmdlet search starts for the transcript root search gate.
3
+
4
+ Reads the paths and recursion of a ``Get-ChildItem`` or ``Select-String``
5
+ call. ``-Path`` and ``-LiteralPath`` values and positional tokens are paths,
6
+ split on commas. An option off the switch list takes the next token as its
7
+ value unless the next token is an option, so ``-ErrorAction 0`` never reads
8
+ as a path and ``-Verbose -Recurse`` still recurses. An abbreviation that names
9
+ one option, such as ``-Pa`` or ``-Dep``, reads as its full name. ``-Depth`` decides
10
+ recursion when given, ``0`` meaning one level; otherwise ``-Recurse`` does.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import collections
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ try:
20
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
21
+ if _hooks_root_directory not in sys.path:
22
+ sys.path.insert(0, _hooks_root_directory)
23
+ from blocking.transcript_root_search_starts import (
24
+ OptionValue,
25
+ SearchStart,
26
+ is_option,
27
+ next_value,
28
+ starts_or_current_directory,
29
+ )
30
+ from hooks_constants import transcript_root_search_gate_constants as search_rules
31
+ from hooks_constants import unscoped_search_blocker_constants as walk_flags
32
+ from hooks_constants.pytest_invocation import unquoted_token
33
+ except ImportError as import_error:
34
+ raise ImportError(
35
+ "The transcript_root_powershell_starts module cannot import its dependencies; "
36
+ "ensure the hooks directory is importable."
37
+ ) from import_error
38
+
39
+
40
+ def _split_path_list(path_text: str) -> list[str]:
41
+ return [
42
+ each_part
43
+ for each_part in unquoted_token(path_text).split(
44
+ search_rules.POWERSHELL_PATH_LIST_SEPARATOR
45
+ )
46
+ if each_part
47
+ ]
48
+
49
+
50
+ def _canonical_option_name(option_name: str) -> str:
51
+ """Return the option a unique abbreviation such as ``-pa`` or ``-dep`` names, else the name."""
52
+ is_too_short = len(option_name) < search_rules.POWERSHELL_MINIMUM_ABBREVIATION_LENGTH
53
+ if is_too_short or option_name in search_rules.ALL_POWERSHELL_ABBREVIABLE_OPTIONS:
54
+ return option_name
55
+ all_matching_options = [
56
+ each_option
57
+ for each_option in search_rules.ALL_POWERSHELL_ABBREVIABLE_OPTIONS
58
+ if each_option.startswith(option_name)
59
+ ]
60
+ return all_matching_options[0] if len(all_matching_options) == 1 else option_name
61
+
62
+
63
+ def _powershell_arguments(all_arguments: list[str]) -> tuple[list[str], list[OptionValue]]:
64
+ """Return a cmdlet call's paths and options; an option off the switch list takes a value."""
65
+ all_paths: list[str] = []
66
+ all_options: list[OptionValue] = []
67
+ all_remaining = collections.deque(all_arguments)
68
+ while all_remaining:
69
+ bare_token = unquoted_token(all_remaining.popleft())
70
+ if not is_option(bare_token):
71
+ all_paths.extend(_split_path_list(bare_token))
72
+ continue
73
+ option_name, glued_separator, glued_value = bare_token.partition(
74
+ search_rules.POWERSHELL_GLUED_VALUE_SEPARATOR
75
+ )
76
+ option_name = _canonical_option_name(option_name.lower())
77
+ takes_spaced_value = (
78
+ not glued_separator
79
+ and option_name not in search_rules.ALL_POWERSHELL_SWITCH_OPTIONS
80
+ and bool(all_remaining)
81
+ and not is_option(all_remaining[0])
82
+ )
83
+ option_value = next_value(all_remaining) if takes_spaced_value else glued_value
84
+ all_options.append((option_name, unquoted_token(option_value)))
85
+ if option_name in walk_flags.ALL_POWERSHELL_PATH_FLAGS:
86
+ all_paths.extend(_split_path_list(option_value))
87
+ return all_paths, all_options
88
+
89
+
90
+ def _powershell_listing_is_recursive(all_options: list[OptionValue]) -> bool:
91
+ all_depth_values = [
92
+ each_value
93
+ for each_name, each_value in all_options
94
+ if each_name == search_rules.POWERSHELL_DEPTH_OPTION
95
+ ]
96
+ if all_depth_values:
97
+ return all_depth_values[-1] != search_rules.POWERSHELL_NON_RECURSIVE_DEPTH
98
+ return any(
99
+ each_name in walk_flags.ALL_POWERSHELL_RECURSE_FLAGS
100
+ and (not each_value or each_value.lower() in walk_flags.ALL_TRUTHY_RECURSE_COLON_VALUES)
101
+ for each_name, each_value in all_options
102
+ )
103
+
104
+
105
+ def powershell_listing_search_starts(all_arguments: list[str]) -> list[SearchStart]:
106
+ """Return the starts of a ``Get-ChildItem`` call, or of ``ls`` under PowerShell."""
107
+ all_paths, all_options = _powershell_arguments(all_arguments)
108
+ return starts_or_current_directory(all_paths, _powershell_listing_is_recursive(all_options))
109
+
110
+
111
+ def select_string_search_starts(all_arguments: list[str]) -> list[SearchStart]:
112
+ """Return the starts of a ``Select-String`` call, which reads only the paths it names."""
113
+ all_paths, _all_options = _powershell_arguments(all_arguments)
114
+ return [SearchStart(each_path, False) for each_path in all_paths]
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse gate: deny a recursive walk or content search over a Claude transcript root.
3
+
4
+ A transcript root is a ``projects`` folder directly under ``.claude``, under
5
+ ``.claude-<name>``, or under ``.claude-profiles/<profile>``. It holds one
6
+ folder per project and thousands of JSONL session transcripts, so a walk that
7
+ starts there reads every transcript and runs for minutes. The gate denies a
8
+ search start that names a transcript root with no project folder after it,
9
+ when the search recurses, and a start whose first segment after the root is
10
+ a bare wildcard such as ``*`` or ``**`` that descends into every project
11
+ folder. A name-filtered listing of the root, such as ``Get-ChildItem <root>
12
+ -Directory -Filter "*id*"`` or ``Glob(pattern="*id*", path=<root>)``, passes,
13
+ and so does any walk inside one project folder.
14
+
15
+ ::
16
+
17
+ Get-ChildItem $p -Recurse -Filter *.jsonl flag ($p bound to a root earlier in the command)
18
+ Get-ChildItem $p -Directory -Filter "*01WU*" ok (one-level name listing)
19
+ grep needle ~/.claude/projects/*/*.jsonl flag (bare wildcard over every folder)
20
+ Grep(path="~/.claude/projects") flag (Grep always recurses)
21
+
22
+ Shell variables assigned in the same command, such as ``$p="..."`` or
23
+ ``p=...``, are expanded before the path is read. The Bash and PowerShell
24
+ tools reach the gate through ``blocking/bash_pre_tool_use_dispatcher.py``;
25
+ ``hooks.json`` registers it directly for the Grep and Glob tools.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ try:
35
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
36
+ if _hooks_root_directory not in sys.path:
37
+ sys.path.insert(0, _hooks_root_directory)
38
+ from hooks_constants import transcript_root_search_gate_constants as search_rules
39
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
40
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
41
+ DENY_DECISION,
42
+ HOOK_EVENT_NAME,
43
+ )
44
+ from hooks_constants.hook_block_logger import log_hook_block
45
+ from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
46
+ from blocking.transcript_root_search_starts import (
47
+ SearchStart,
48
+ file_search_tool_start,
49
+ walked_transcript_root,
50
+ )
51
+ from blocking.transcript_root_shell_starts import shell_command_search_starts
52
+ except ImportError as import_error:
53
+ raise ImportError(
54
+ "The transcript root search gate cannot import its dependencies; "
55
+ "ensure the hooks directory is importable."
56
+ ) from import_error
57
+
58
+
59
+ def _call_search_starts(
60
+ tool_name: str, all_tool_input: dict[str, object], cwd: str
61
+ ) -> list[SearchStart]:
62
+ if tool_name in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
63
+ command = all_tool_input.get(search_rules.TOOL_INPUT_COMMAND_KEY)
64
+ if not isinstance(command, str):
65
+ return []
66
+ return shell_command_search_starts(
67
+ command, cwd, tool_name == search_rules.POWERSHELL_TOOL_NAME
68
+ )
69
+ if tool_name in search_rules.ALL_FILE_SEARCH_TOOL_NAMES:
70
+ return [file_search_tool_start(tool_name, all_tool_input, cwd)]
71
+ return []
72
+
73
+
74
+ def transcript_root_decision(all_hook_payload: dict[str, object]) -> dict[str, object] | None:
75
+ """Return the deny decision for a search that walks a transcript root, else None.
76
+
77
+ Args:
78
+ all_hook_payload: The PreToolUse payload the hook read from stdin.
79
+ """
80
+ tool_name = all_hook_payload.get(search_rules.PAYLOAD_TOOL_NAME_KEY)
81
+ all_tool_input = all_hook_payload.get(search_rules.PAYLOAD_TOOL_INPUT_KEY)
82
+ raw_cwd = all_hook_payload.get(search_rules.PAYLOAD_CWD_KEY)
83
+ if not isinstance(tool_name, str) or not isinstance(all_tool_input, dict):
84
+ return None
85
+ cwd = raw_cwd if isinstance(raw_cwd, str) else ""
86
+ all_walked_roots = (
87
+ walked_transcript_root(each_start)
88
+ for each_start in _call_search_starts(tool_name, all_tool_input, cwd)
89
+ )
90
+ transcript_root = next((each_root for each_root in all_walked_roots if each_root), None)
91
+ if transcript_root is None:
92
+ return None
93
+ deny_reason = search_rules.TRANSCRIPT_ROOT_DENY_REASON_TEMPLATE.format(root=transcript_root)
94
+ log_hook_block(
95
+ search_rules.GATE_HOOK_NAME,
96
+ HOOK_EVENT_NAME,
97
+ deny_reason,
98
+ tool_name,
99
+ json.dumps(all_tool_input),
100
+ )
101
+ return _deny_decision(deny_reason)
102
+
103
+
104
+ def _deny_decision(deny_reason: str) -> dict[str, object]:
105
+ return {
106
+ "hookSpecificOutput": {
107
+ "hookEventName": HOOK_EVENT_NAME,
108
+ "permissionDecision": DENY_DECISION,
109
+ "permissionDecisionReason": deny_reason,
110
+ }
111
+ }
112
+
113
+
114
+ def main() -> None:
115
+ """Deny a search that walks a Claude transcript root, or stay quiet."""
116
+ print_decision_from_stdin(transcript_root_decision)
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """Search starts for the transcript root search gate.
3
+
4
+ A search start is the path a search begins at and whether the search recurses
5
+ from it. This module decides whether a start walks every project folder under
6
+ a Claude transcript root, reads the start of a Grep or Glob tool call, and
7
+ holds the option helpers the shell readers share.
8
+ ``blocking/transcript_root_shell_starts.py`` and
9
+ ``blocking/transcript_root_powershell_starts.py`` read the starts of a shell
10
+ command, and ``blocking/transcript_root_search_gate.py`` denies the call when
11
+ one walks a root.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import collections
17
+ import posixpath
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ try:
23
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
24
+ if _hooks_root_directory not in sys.path:
25
+ sys.path.insert(0, _hooks_root_directory)
26
+ from hooks_constants import transcript_root_search_gate_constants as search_rules
27
+ from hooks_constants.pytest_invocation import unquoted_token
28
+ except ImportError as import_error:
29
+ raise ImportError(
30
+ "The transcript root gate helpers cannot import their dependencies; "
31
+ "ensure the hooks directory is importable."
32
+ ) from import_error
33
+
34
+
35
+ OptionValue = tuple[str, str]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SearchStart:
40
+ """One place a search begins.
41
+
42
+ Attributes:
43
+ path_text: The path or glob the search starts from, as the call names it.
44
+ is_recursive: True when the search descends below the path on its own.
45
+ """
46
+
47
+ path_text: str
48
+ is_recursive: bool
49
+
50
+
51
+ def normalized_path(path_text: str) -> str:
52
+ """Return the path with quotes removed, forward slashes, and ``.`` and ``..`` folded.
53
+
54
+ ::
55
+
56
+ '"C:\\\\h\\\\.claude\\\\projects\\\\"' -> "C:/h/.claude/projects"
57
+ "~/.claude/projects/C--x/../" -> "~/.claude/projects"
58
+
59
+ Args:
60
+ path_text: A path token, possibly quoted or with backslashes.
61
+ """
62
+ forward_slash_path = unquoted_token(path_text.strip()).replace(
63
+ search_rules.WINDOWS_PATH_SEPARATOR, search_rules.PATH_SEPARATOR
64
+ )
65
+ if not forward_slash_path:
66
+ return forward_slash_path
67
+ return posixpath.normpath(forward_slash_path)
68
+
69
+
70
+ def joined_path(base_directory: str, path_text: str) -> str:
71
+ """Return the path read from the base directory, or the path itself when it is absolute.
72
+
73
+ Args:
74
+ base_directory: The directory a relative path resolves from, or empty when unknown.
75
+ path_text: The path a command or tool call names.
76
+ """
77
+ bare_path = unquoted_token(path_text.strip())
78
+ if not base_directory or search_rules.ABSOLUTE_PATH_PATTERN.match(bare_path):
79
+ return bare_path
80
+ return normalized_path(base_directory) + search_rules.PATH_SEPARATOR + bare_path
81
+
82
+
83
+ def is_option(token: str) -> bool:
84
+ """Return True for a token that starts with ``-`` and holds more than the dash."""
85
+ bare_token = unquoted_token(token)
86
+ return len(bare_token) > 1 and bare_token.startswith(search_rules.OPTION_PREFIX)
87
+
88
+
89
+ def next_value(all_remaining: collections.deque[str]) -> str:
90
+ """Take the next token off the queue, unquoted, or empty when none is left."""
91
+ return unquoted_token(all_remaining.popleft()) if all_remaining else ""
92
+
93
+
94
+ def is_recursive_unless_shallow(all_depth_values: list[str], is_recursive_otherwise: bool) -> bool:
95
+ """Return whether a search recurses; the last depth value decides when one is given."""
96
+ if all_depth_values:
97
+ return all_depth_values[-1] not in search_rules.ALL_SHALLOW_DEPTH_VALUES
98
+ return is_recursive_otherwise
99
+
100
+
101
+ def starts_or_current_directory(all_paths: list[str], is_recursive: bool) -> list[SearchStart]:
102
+ """Return one start per path, or one at the current directory when no path is given."""
103
+ return [
104
+ SearchStart(each_path, is_recursive)
105
+ for each_path in all_paths or [search_rules.CURRENT_DIRECTORY]
106
+ ]
107
+
108
+
109
+ def _walks_every_project_folder(all_segments_below_root: list[str], is_recursive: bool) -> bool:
110
+ if not all_segments_below_root:
111
+ return is_recursive
112
+ first_segment, *all_deeper_segments = all_segments_below_root
113
+ if search_rules.WILDCARD_ONLY_SEGMENT_PATTERN.fullmatch(first_segment) is None:
114
+ return False
115
+ return is_recursive or bool(all_deeper_segments)
116
+
117
+
118
+ def walked_transcript_root(search_start: SearchStart) -> str | None:
119
+ """Return the transcript root a search start walks, or None when it stays scoped.
120
+
121
+ ::
122
+
123
+ SearchStart("~/.claude/projects", True) -> "~/.claude/projects"
124
+ SearchStart("~/.claude/projects", False) -> None
125
+ SearchStart("~/.claude/projects/*/*.jsonl", False) -> "~/.claude/projects"
126
+ SearchStart("~/.claude/projects/*01WU*", False) -> None
127
+ SearchStart("~/.claude/projects/C--repo", True) -> None
128
+
129
+ Args:
130
+ search_start: The path the search starts from and whether it recurses.
131
+ """
132
+ start_path = normalized_path(search_start.path_text)
133
+ root_match = search_rules.TRANSCRIPT_ROOT_PATTERN.search(start_path)
134
+ if root_match is None:
135
+ return None
136
+ all_segments_below_root = [
137
+ each_segment
138
+ for each_segment in start_path[root_match.end() :].split(search_rules.PATH_SEPARATOR)
139
+ if each_segment
140
+ ]
141
+ if not _walks_every_project_folder(all_segments_below_root, search_start.is_recursive):
142
+ return None
143
+ return start_path[: root_match.end()]
144
+
145
+
146
+ def file_search_tool_start(
147
+ tool_name: str, all_tool_input: dict[str, object], cwd: str
148
+ ) -> SearchStart:
149
+ """Return where a Grep or Glob tool call starts its search.
150
+
151
+ Grep always recurses from its path. Glob reads its pattern below its path,
152
+ or alone when the pattern is absolute, and recurses when the pattern holds
153
+ ``**``. A relative path resolves from the session working directory.
154
+
155
+ Args:
156
+ tool_name: ``Grep`` or ``Glob``.
157
+ all_tool_input: The tool call's input.
158
+ cwd: The session working directory, used when the call names no path.
159
+ """
160
+ raw_path = all_tool_input.get(search_rules.TOOL_INPUT_PATH_KEY)
161
+ base_path = joined_path(cwd, raw_path) if isinstance(raw_path, str) and raw_path else cwd
162
+ if tool_name != search_rules.GLOB_TOOL_NAME:
163
+ return SearchStart(base_path, True)
164
+ raw_pattern = all_tool_input.get(search_rules.TOOL_INPUT_PATTERN_KEY)
165
+ pattern = raw_pattern if isinstance(raw_pattern, str) else ""
166
+ return SearchStart(
167
+ joined_path(base_path, pattern), search_rules.RECURSIVE_GLOB_SEGMENT in pattern
168
+ )