claude-dev-env 8.105.1 → 8.106.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/docs/rule-guides/filesystem-search.md +3 -2
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_transcript_root_powershell_starts.py +79 -0
- package/hooks/blocking/test_transcript_root_search_gate.py +180 -0
- package/hooks/blocking/test_transcript_root_search_starts.py +85 -0
- package/hooks/blocking/test_transcript_root_shell_starts.py +131 -0
- package/hooks/blocking/transcript_root_powershell_starts.py +114 -0
- package/hooks/blocking/transcript_root_search_gate.py +120 -0
- package/hooks/blocking/transcript_root_search_starts.py +168 -0
- package/hooks/blocking/transcript_root_shell_starts.py +368 -0
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/transcript_root_search_gate_constants.py +301 -0
- package/package.json +1 -1
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +22 -4
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +20 -6
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shell command search starts for the transcript root search gate.
|
|
3
|
+
|
|
4
|
+
Reads where each program in a Bash or PowerShell command starts reading and
|
|
5
|
+
whether it recurses. ``find``, ``grep``, ``rg``, ``ag``, ``ack``, ``fd``,
|
|
6
|
+
``ls``, ``tree``, ``Get-ChildItem`` and ``Select-String`` follow their own
|
|
7
|
+
option grammar; any other program reads its plain arguments without
|
|
8
|
+
recursion, so ``cat <root>/*/*.jsonl`` still counts as a walk.
|
|
9
|
+
|
|
10
|
+
Each tokenization of the command runs in order with its own state: variables
|
|
11
|
+
expand from the assignments before them, PowerShell names without regard to
|
|
12
|
+
case, and a relative path resolves from the session directory or the last
|
|
13
|
+
``cd`` or ``Set-Location`` before it, and ``popd`` or ``Pop-Location``
|
|
14
|
+
returns to the folder before the matching ``pushd`` or ``Push-Location``. A
|
|
15
|
+
search with no path starts there.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import collections
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from functools import partial
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
29
|
+
if _hooks_root_directory not in sys.path:
|
|
30
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
31
|
+
from blocking.transcript_root_powershell_starts import (
|
|
32
|
+
powershell_listing_search_starts,
|
|
33
|
+
select_string_search_starts,
|
|
34
|
+
)
|
|
35
|
+
from blocking.transcript_root_search_starts import (
|
|
36
|
+
OptionValue,
|
|
37
|
+
SearchStart,
|
|
38
|
+
is_option,
|
|
39
|
+
is_recursive_unless_shallow,
|
|
40
|
+
joined_path,
|
|
41
|
+
next_value,
|
|
42
|
+
starts_or_current_directory,
|
|
43
|
+
)
|
|
44
|
+
from hooks_constants import transcript_root_search_gate_constants as search_rules
|
|
45
|
+
from hooks_constants import unscoped_search_blocker_constants as walk_flags
|
|
46
|
+
from hooks_constants.destructive_command_segment_constants import FIND_PROGRAM_NAME
|
|
47
|
+
from hooks_constants.pytest_invocation import unquoted_token
|
|
48
|
+
from hooks_constants.shell_command_pipeline import (
|
|
49
|
+
all_operator_aware_tokenizations,
|
|
50
|
+
join_line_continuations,
|
|
51
|
+
scannable_command_lines,
|
|
52
|
+
segments_with_following_operator,
|
|
53
|
+
)
|
|
54
|
+
from hooks_constants.shell_command_wrappers import (
|
|
55
|
+
all_wrapped_command_texts,
|
|
56
|
+
segment_program_and_arguments,
|
|
57
|
+
)
|
|
58
|
+
except ImportError as import_error:
|
|
59
|
+
raise ImportError(
|
|
60
|
+
"The transcript_root_shell_starts module cannot import its dependencies; "
|
|
61
|
+
"ensure the hooks directory is importable."
|
|
62
|
+
) from import_error
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _assignment_from_segment(all_segment_tokens: list[str]) -> tuple[str, str] | None:
|
|
66
|
+
"""Return the name and value a segment assigns, such as ``p=x`` or ``$p = "x"``, else None."""
|
|
67
|
+
prefix_word_count = 0
|
|
68
|
+
while (
|
|
69
|
+
prefix_word_count < len(all_segment_tokens)
|
|
70
|
+
and all_segment_tokens[prefix_word_count].lower()
|
|
71
|
+
in search_rules.ALL_ASSIGNMENT_PREFIX_WORDS
|
|
72
|
+
):
|
|
73
|
+
prefix_word_count += 1
|
|
74
|
+
all_tokens = all_segment_tokens[prefix_word_count:]
|
|
75
|
+
if len(all_tokens) == 1:
|
|
76
|
+
assignment_match = search_rules.SHELL_ASSIGNMENT_PATTERN.match(all_tokens[0])
|
|
77
|
+
if assignment_match is not None:
|
|
78
|
+
return assignment_match["name"], unquoted_token(assignment_match["value"])
|
|
79
|
+
if (
|
|
80
|
+
len(all_tokens) == search_rules.SPACED_ASSIGNMENT_TOKEN_COUNT
|
|
81
|
+
and all_tokens[1] == search_rules.SPACED_ASSIGNMENT_OPERATOR
|
|
82
|
+
):
|
|
83
|
+
name_match = search_rules.SPACED_ASSIGNMENT_NAME_PATTERN.match(all_tokens[0])
|
|
84
|
+
if name_match is not None:
|
|
85
|
+
return name_match["name"], unquoted_token(all_tokens[2])
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _expanded_token(token: str, all_bindings: dict[str, str]) -> str:
|
|
90
|
+
if search_rules.SHELL_VARIABLE_SIGIL not in token:
|
|
91
|
+
return token
|
|
92
|
+
expanded_token = token
|
|
93
|
+
for each_name, each_value in all_bindings.items():
|
|
94
|
+
reference_pattern = search_rules.SHELL_VARIABLE_REFERENCE_TEMPLATE.format(
|
|
95
|
+
name=re.escape(each_name)
|
|
96
|
+
)
|
|
97
|
+
expanded_token = re.sub(
|
|
98
|
+
reference_pattern,
|
|
99
|
+
each_value.replace("\\", "\\\\"),
|
|
100
|
+
expanded_token,
|
|
101
|
+
flags=re.IGNORECASE,
|
|
102
|
+
)
|
|
103
|
+
return expanded_token
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _long_option(
|
|
107
|
+
grammar: search_rules.PosixSearchGrammar,
|
|
108
|
+
bare_token: str,
|
|
109
|
+
all_remaining: collections.deque[str],
|
|
110
|
+
) -> OptionValue:
|
|
111
|
+
option_name, glued_separator, glued_value = bare_token.partition(
|
|
112
|
+
search_rules.GLUED_OPTION_VALUE_SEPARATOR
|
|
113
|
+
)
|
|
114
|
+
if glued_separator:
|
|
115
|
+
return option_name, unquoted_token(glued_value)
|
|
116
|
+
if option_name in grammar.all_value_options:
|
|
117
|
+
return option_name, next_value(all_remaining)
|
|
118
|
+
return option_name, ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _short_option_cluster(
|
|
122
|
+
grammar: search_rules.PosixSearchGrammar,
|
|
123
|
+
bare_token: str,
|
|
124
|
+
all_remaining: collections.deque[str],
|
|
125
|
+
) -> list[OptionValue]:
|
|
126
|
+
"""Return each option in a cluster such as ``-rC2``; a value letter takes the rest or the next token."""
|
|
127
|
+
all_options: list[OptionValue] = []
|
|
128
|
+
for each_index, each_letter in enumerate(bare_token[1:], start=1):
|
|
129
|
+
option_name = search_rules.OPTION_PREFIX + each_letter
|
|
130
|
+
if each_letter in grammar.all_value_letters:
|
|
131
|
+
glued_value = bare_token[each_index + 1 :].removeprefix(
|
|
132
|
+
search_rules.GLUED_OPTION_VALUE_SEPARATOR
|
|
133
|
+
)
|
|
134
|
+
all_options.append((option_name, glued_value or next_value(all_remaining)))
|
|
135
|
+
return all_options
|
|
136
|
+
all_options.append((option_name, ""))
|
|
137
|
+
return all_options
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _options_in_token(
|
|
141
|
+
grammar: search_rules.PosixSearchGrammar,
|
|
142
|
+
bare_token: str,
|
|
143
|
+
all_remaining: collections.deque[str],
|
|
144
|
+
) -> list[OptionValue]:
|
|
145
|
+
if bare_token.startswith(search_rules.LONG_OPTION_PREFIX):
|
|
146
|
+
return [_long_option(grammar, bare_token, all_remaining)]
|
|
147
|
+
return _short_option_cluster(grammar, bare_token, all_remaining)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _posix_arguments(
|
|
151
|
+
grammar: search_rules.PosixSearchGrammar, all_arguments: list[str]
|
|
152
|
+
) -> tuple[list[str], list[OptionValue]]:
|
|
153
|
+
all_positionals: list[str] = []
|
|
154
|
+
all_options: list[OptionValue] = []
|
|
155
|
+
all_remaining = collections.deque(all_arguments)
|
|
156
|
+
while all_remaining:
|
|
157
|
+
each_token = all_remaining.popleft()
|
|
158
|
+
bare_token = unquoted_token(each_token)
|
|
159
|
+
if bare_token == search_rules.END_OF_OPTIONS_TOKEN:
|
|
160
|
+
all_positionals.extend(all_remaining)
|
|
161
|
+
break
|
|
162
|
+
if is_option(each_token):
|
|
163
|
+
all_options.extend(_options_in_token(grammar, bare_token, all_remaining))
|
|
164
|
+
else:
|
|
165
|
+
all_positionals.append(each_token)
|
|
166
|
+
return all_positionals, all_options
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _posix_search_starts(
|
|
170
|
+
grammar: search_rules.PosixSearchGrammar, all_arguments: list[str]
|
|
171
|
+
) -> list[SearchStart]:
|
|
172
|
+
all_positionals, all_options = _posix_arguments(grammar, all_arguments)
|
|
173
|
+
all_option_names = {each_name for each_name, _each_value in all_options}
|
|
174
|
+
is_recursive_by_option = (
|
|
175
|
+
grammar.is_recursive_by_default
|
|
176
|
+
or bool(all_option_names & grammar.all_recurse_options)
|
|
177
|
+
or any(
|
|
178
|
+
each_name in grammar.all_recurse_value_options
|
|
179
|
+
and each_value == search_rules.RECURSE_DIRECTORIES_ACTION
|
|
180
|
+
for each_name, each_value in all_options
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
all_depth_values = [
|
|
184
|
+
each_value
|
|
185
|
+
for each_name, each_value in all_options
|
|
186
|
+
if each_name in grammar.all_depth_options
|
|
187
|
+
]
|
|
188
|
+
has_pattern_positional = grammar.takes_pattern_positional and not (
|
|
189
|
+
all_option_names & grammar.all_pattern_options
|
|
190
|
+
)
|
|
191
|
+
return starts_or_current_directory(
|
|
192
|
+
all_positionals[1:] if has_pattern_positional else all_positionals,
|
|
193
|
+
is_recursive_unless_shallow(all_depth_values, is_recursive_by_option),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _find_search_starts(all_arguments: list[str]) -> list[SearchStart]:
|
|
198
|
+
all_starting_points: list[str] = []
|
|
199
|
+
all_remaining = collections.deque(all_arguments)
|
|
200
|
+
while all_remaining:
|
|
201
|
+
each_token = all_remaining.popleft()
|
|
202
|
+
if each_token in walk_flags.ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE:
|
|
203
|
+
next_value(all_remaining)
|
|
204
|
+
continue
|
|
205
|
+
if each_token in walk_flags.ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE or (
|
|
206
|
+
each_token.startswith(walk_flags.FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX)
|
|
207
|
+
):
|
|
208
|
+
continue
|
|
209
|
+
if each_token in walk_flags.ALL_FIND_EXPRESSION_INTRODUCER_TOKENS or is_option(each_token):
|
|
210
|
+
break
|
|
211
|
+
all_starting_points.append(each_token)
|
|
212
|
+
all_depth_values = [
|
|
213
|
+
unquoted_token(all_arguments[each_index + 1])
|
|
214
|
+
for each_index, each_token in enumerate(all_arguments[:-1])
|
|
215
|
+
if each_token == search_rules.FIND_MAXIMUM_DEPTH_OPTION
|
|
216
|
+
]
|
|
217
|
+
return starts_or_current_directory(
|
|
218
|
+
all_starting_points, is_recursive_unless_shallow(all_depth_values, True)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _plain_argument_starts(all_arguments: list[str]) -> list[SearchStart]:
|
|
223
|
+
return [
|
|
224
|
+
SearchStart(each_argument, False)
|
|
225
|
+
for each_argument in all_arguments
|
|
226
|
+
if not is_option(each_argument)
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_SEARCH_STARTS_BY_PROGRAM: dict[str, Callable[[list[str]], list[SearchStart]]] = {
|
|
231
|
+
FIND_PROGRAM_NAME: _find_search_starts,
|
|
232
|
+
**dict.fromkeys(
|
|
233
|
+
search_rules.ALL_GREP_PROGRAM_NAMES,
|
|
234
|
+
partial(_posix_search_starts, search_rules.GREP_GRAMMAR),
|
|
235
|
+
),
|
|
236
|
+
**dict.fromkeys(
|
|
237
|
+
search_rules.ALL_INDEXED_SEARCH_PROGRAM_NAMES,
|
|
238
|
+
partial(_posix_search_starts, search_rules.INDEXED_SEARCH_GRAMMAR),
|
|
239
|
+
),
|
|
240
|
+
**dict.fromkeys(
|
|
241
|
+
walk_flags.ALL_UNIX_LS_PROGRAM_BASENAMES,
|
|
242
|
+
partial(_posix_search_starts, search_rules.UNIX_LS_GRAMMAR),
|
|
243
|
+
),
|
|
244
|
+
search_rules.TREE_PROGRAM_NAME: partial(_posix_search_starts, search_rules.TREE_GRAMMAR),
|
|
245
|
+
**dict.fromkeys(
|
|
246
|
+
walk_flags.ALL_POWERSHELL_LISTING_PROGRAM_BASENAMES, powershell_listing_search_starts
|
|
247
|
+
),
|
|
248
|
+
**dict.fromkeys(search_rules.ALL_SELECT_STRING_PROGRAM_NAMES, select_string_search_starts),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _program_search_starts(
|
|
253
|
+
program_name: str, all_arguments: list[str], is_powershell: bool
|
|
254
|
+
) -> list[SearchStart]:
|
|
255
|
+
if is_powershell and program_name in walk_flags.ALL_UNIX_LS_PROGRAM_BASENAMES:
|
|
256
|
+
return powershell_listing_search_starts(all_arguments)
|
|
257
|
+
search_starts_for = _SEARCH_STARTS_BY_PROGRAM.get(program_name, _plain_argument_starts)
|
|
258
|
+
return search_starts_for(all_arguments)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _changed_directory(current_directory: str, program_name: str, all_arguments: list[str]) -> str:
|
|
262
|
+
all_targets = [each_argument for each_argument in all_arguments if not is_option(each_argument)]
|
|
263
|
+
if all_targets:
|
|
264
|
+
return joined_path(current_directory, all_targets[0])
|
|
265
|
+
if program_name in search_rules.ALL_HOME_DEFAULT_CHANGE_DIRECTORY_PROGRAM_NAMES:
|
|
266
|
+
return search_rules.HOME_DIRECTORY
|
|
267
|
+
return current_directory
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _moved_directory(
|
|
271
|
+
current_directory: str,
|
|
272
|
+
all_pushed_directories: list[str],
|
|
273
|
+
program_name: str,
|
|
274
|
+
all_arguments: list[str],
|
|
275
|
+
) -> str | None:
|
|
276
|
+
"""Return the folder a ``cd``, ``pushd`` or ``popd`` moves to, else None."""
|
|
277
|
+
if program_name in search_rules.ALL_POP_DIRECTORY_PROGRAM_NAMES:
|
|
278
|
+
return all_pushed_directories.pop() if all_pushed_directories else current_directory
|
|
279
|
+
if program_name not in search_rules.ALL_CHANGE_DIRECTORY_PROGRAM_NAMES:
|
|
280
|
+
return None
|
|
281
|
+
if program_name in search_rules.ALL_PUSH_DIRECTORY_PROGRAM_NAMES:
|
|
282
|
+
all_pushed_directories.append(current_directory)
|
|
283
|
+
return _changed_directory(current_directory, program_name, all_arguments)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _expanded_program_call(
|
|
287
|
+
all_segment_tokens: list[str], all_bindings: dict[str, str]
|
|
288
|
+
) -> tuple[str, list[str]]:
|
|
289
|
+
"""Return a segment's lowercase program name and its arguments with variables expanded."""
|
|
290
|
+
program_name, all_raw_arguments = segment_program_and_arguments(all_segment_tokens)
|
|
291
|
+
return program_name.lower(), [
|
|
292
|
+
_expanded_token(each_argument, all_bindings) for each_argument in all_raw_arguments
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _run_search_starts(
|
|
297
|
+
all_segments: list[list[str]], cwd: str, is_powershell: bool
|
|
298
|
+
) -> list[SearchStart]:
|
|
299
|
+
"""Return the starts of one tokenization's segments, read in order with their own state."""
|
|
300
|
+
all_bindings: dict[str, str] = {}
|
|
301
|
+
current_directory = cwd
|
|
302
|
+
all_pushed_directories: list[str] = []
|
|
303
|
+
all_search_starts: list[SearchStart] = []
|
|
304
|
+
for each_segment in all_segments:
|
|
305
|
+
assignment = _assignment_from_segment(each_segment)
|
|
306
|
+
if assignment is not None:
|
|
307
|
+
all_bindings[assignment[0].lower()] = _expanded_token(assignment[1], all_bindings)
|
|
308
|
+
continue
|
|
309
|
+
program_name, all_arguments = _expanded_program_call(each_segment, all_bindings)
|
|
310
|
+
moved_directory = _moved_directory(
|
|
311
|
+
current_directory, all_pushed_directories, program_name, all_arguments
|
|
312
|
+
)
|
|
313
|
+
if moved_directory is not None:
|
|
314
|
+
current_directory = moved_directory
|
|
315
|
+
continue
|
|
316
|
+
all_search_starts.extend(
|
|
317
|
+
SearchStart(
|
|
318
|
+
joined_path(current_directory, each_start.path_text), each_start.is_recursive
|
|
319
|
+
)
|
|
320
|
+
for each_start in _program_search_starts(program_name, all_arguments, is_powershell)
|
|
321
|
+
)
|
|
322
|
+
return all_search_starts
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _segments_in_mode(
|
|
326
|
+
all_line_tokenizations: list[list[list[str]]], mode_index: int
|
|
327
|
+
) -> list[list[str]]:
|
|
328
|
+
return [
|
|
329
|
+
each_segment
|
|
330
|
+
for each_tokenizations in all_line_tokenizations
|
|
331
|
+
if each_tokenizations
|
|
332
|
+
for each_segment, _following_operator in segments_with_following_operator(
|
|
333
|
+
each_tokenizations[min(mode_index, len(each_tokenizations) - 1)]
|
|
334
|
+
)
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _tokenization_runs(command: str) -> list[list[list[str]]]:
|
|
339
|
+
"""Return the segments of each command text once per tokenization mode, lines in order."""
|
|
340
|
+
all_runs: list[list[list[str]]] = []
|
|
341
|
+
for each_text in all_wrapped_command_texts(command):
|
|
342
|
+
all_line_tokenizations = [
|
|
343
|
+
all_operator_aware_tokenizations(each_line)
|
|
344
|
+
for each_line in scannable_command_lines(join_line_continuations(each_text))
|
|
345
|
+
if each_line.strip()
|
|
346
|
+
]
|
|
347
|
+
mode_count = max(map(len, all_line_tokenizations), default=0)
|
|
348
|
+
all_runs.extend(
|
|
349
|
+
_segments_in_mode(all_line_tokenizations, each_mode) for each_mode in range(mode_count)
|
|
350
|
+
)
|
|
351
|
+
return all_runs
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def shell_command_search_starts(command: str, cwd: str, is_powershell: bool) -> list[SearchStart]:
|
|
355
|
+
"""Return every search start the shell command's programs name.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
command: The Bash or PowerShell command text the agent is about to run.
|
|
359
|
+
cwd: The session working directory a relative path resolves from.
|
|
360
|
+
is_powershell: True for the PowerShell tool, where ``ls`` is ``Get-ChildItem``.
|
|
361
|
+
"""
|
|
362
|
+
if search_rules.TRANSCRIPT_ROOT_MARKER not in (command + cwd).lower():
|
|
363
|
+
return []
|
|
364
|
+
return [
|
|
365
|
+
each_start
|
|
366
|
+
for each_run in _tokenization_runs(command)
|
|
367
|
+
for each_start in _run_search_starts(each_run, cwd, is_powershell)
|
|
368
|
+
]
|
|
@@ -10,6 +10,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
10
10
|
- `blocking/gh_global_account_switch_gate.py` denies `gh auth switch`, `gh auth login`, and `gh auth logout` and names the per-command `GH_TOKEN` form to run instead.
|
|
11
11
|
- `blocking/cloud_graphql_gate.py` denies the `gh` commands that call GraphQL (`gh api graphql` with any flags before the endpoint, `gh pr`, `gh issue`, `gh project` and `gh discussion` except `gh pr diff` and `gh pr list --web`, `gh status`, `gh org list`, `gh release list`, `gh release view` with a tag, `gh workflow run` without `--ref`, and a few `repo`, `label`, `search`, `ruleset` and `gist` commands, read past `if`, `!` and command substitutions) and an HTTP client or `python -c` style call to `api.github.com/graphql`, with or without a port, inside a cloud session, where the proxy answers GraphQL with HTTP 403, and names the REST routes to run instead.
|
|
12
12
|
- `blocking/python_windows_invocation_gate.py` denies, on a Windows host, `python`, `python3` or `py` run with `-I` and without `-X utf8`, because `-I` ignores `PYTHONUTF8` and non-ASCII output then stops with `UnicodeEncodeError: 'charmap'`. Under the PowerShell tool it also denies a `python -c "..."` argument that holds `\"`, because PowerShell ends the string at that quote, and names a script file or a single-quoted here-string to use instead.
|
|
13
|
+
- `blocking/transcript_root_search_gate.py` denies a recursive walk or content search that starts at a Claude transcript root, a `projects` folder directly under `.claude`, `.claude-<name>` or `.claude-profiles/<profile>`, with no project folder after it, or with a bare `*` or `**` segment that descends into every project folder. It reads `find`, `grep -r`, `rg`, `ls -R`, `Get-ChildItem -Recurse` or `-Depth`, `Select-String`, and the Grep and Glob tools, and expands variables the command assigns, such as `$p="..."`. A one-level name listing, such as `Get-ChildItem <root> -Directory -Filter "*id*"`, passes. The deny reason names the fix: list the project folders by name, then read the one transcript. The gate reads the root path as the command spells it, so a walk that starts above the root, or a glob that spells the root name or a project-name prefix such as `pro*` or `C--*`, passes.
|
|
13
14
|
- `blocking/bash_post_call_dispatcher.py` runs hosted observers and joins their context output without blocking the call.
|
|
14
15
|
- `advisory/pr_done_reminder.py` adds a pull request checklist after a successful push or pull request creation.
|
|
15
16
|
- `advisory/owned_pr_review_loop.py` adds review-loop instructions after the agent opens a pull request on an owned repository outside the preset list of repositories that run their own automated review: run efficient-review until clean, mark the pull request ready, babysit it, and ship it to merge. It also records the pull request for the session in `owned_pr_session_record.py`.
|
|
@@ -23,6 +24,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
23
24
|
- `blocking/gh_global_account_switch_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools.
|
|
24
25
|
- `blocking/cloud_graphql_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools, when `CLAUDE_CODE_REMOTE` is `true`.
|
|
25
26
|
- `blocking/python_windows_invocation_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools. The `-I` check runs only when `os.name` is `nt`.
|
|
27
|
+
- `blocking/transcript_root_search_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools, and on its own on `PreToolUse`, matcher `Grep|Glob`, timeout `10` seconds in `hooks.json`.
|
|
26
28
|
- `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
|
|
27
29
|
- `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
|
|
28
30
|
- `advisory/owned_pr_review_loop.py` runs on `PostToolUse`, matcher `Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)`, timeout `10` seconds in `hooks.json`. It wakes on `gh pr create`, `gh pr new`, a POST to `gh api repos/<owner>/<repo>/pulls`, a `pull_request.py create` call to the pull request publisher, and an MCP pull request creation. The installer also registers it on Codex `PostToolUse` through `ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES`.
|
|
@@ -39,6 +41,7 @@ Preconditions:
|
|
|
39
41
|
- **Broker gate.** Input is a `claude -p task` command. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_headless_claude_broker_gate.py -q`. The adjacent test observes a denial that names the broker command.
|
|
40
42
|
- **Cloud GraphQL gate.** Input is a `gh api graphql` command with `CLAUDE_CODE_REMOTE=true`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_cloud_graphql_gate.py -q`. The adjacent test observes a denial that names the REST routes, and silence outside a cloud session.
|
|
41
43
|
- **Python Windows invocation gate.** Input is `python -I s.py` on Windows, and a PowerShell `python -c "print(\"x\")"`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_python_windows_invocation_gate.py -q`. The adjacent test observes a denial that names `-X utf8` or the here-string, and a pass for `-IX utf8`, `-I -X utf8`, and the Bash tool.
|
|
44
|
+
- **Transcript root search gate.** Input is `Get-ChildItem $p -Recurse -File -Filter *.jsonl` with `$p` bound to `~/.claude-profiles/work/projects`, and a Grep call whose path is `~/.claude/projects`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_transcript_root_search_gate.py -q`. The adjacent test observes a denial that names the list-then-read fix, and a pass for `Get-ChildItem $p -Directory -Filter "*01WUQeBG*"` and a walk inside one project folder.
|
|
42
45
|
- **Post-call dispatch.** Input is a Bash call that yields hosted context. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_post_call_dispatcher.py -q`. The adjacent test observes a joined context payload and exit code zero.
|
|
43
46
|
- **Pull request reminder.** Input is a completed push or pull request creation. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_pr_done_reminder.py -q`. The adjacent test checks the reminder trigger and checklist verdict.
|
|
44
47
|
- **Owned pull request review loop.** Input is a pull request creation result on an owned repository. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_owned_pr_review_loop.py -q`. The adjacent test observes the review-loop block for owned repositories and silence for the preset and foreign repositories.
|