claude-dev-env 8.105.2 → 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/policy_lint/config/constants.py +1 -0
|
@@ -6,7 +6,7 @@ Back to the [rules index](../../rules/index.md).
|
|
|
6
6
|
|
|
7
7
|
Scope every search to a project, worktree, package, or narrowing filter. Never start at a filesystem root, drive root, bare home, or share root. Read a known path directly; use scoped `es.exe`, Glob, or Grep for discovery. After `es.exe` Error 8, retry twice before falling back within the same scope. Run one large shell walk at a time.
|
|
8
8
|
|
|
9
|
-
**Enforcement:**
|
|
9
|
+
**Enforcement:** `hooks/blocking/transcript_root_search_gate.py` for Claude transcript roots only. The rest is guidance.
|
|
10
10
|
|
|
11
11
|
**When this applies:** Any search for files by name, path, extension, size, or date. That includes `es.exe`, a shell `find`, a recursive `Get-ChildItem` / `gci` / `dir` / `ls -R`, or the harness Grep and Glob tools.
|
|
12
12
|
|
|
@@ -55,5 +55,6 @@ Issue one shell search at a time when the walk is large. Parallel full-tree sear
|
|
|
55
55
|
|
|
56
56
|
## Search controls
|
|
57
57
|
|
|
58
|
-
-
|
|
58
|
+
- `hooks/blocking/transcript_root_search_gate.py` denies a recursive walk or content search that starts at a Claude transcript root, such as `~/.claude/projects` or `~/.claude-profiles/<profile>/projects`. List the project folders by name first, then read the one transcript.
|
|
59
|
+
- No hook denies a walk from any other unscoped root. The scope invariant above is guidance a reader follows.
|
|
59
60
|
- For Everything searches that use a project name, run `python "${CLAUDE_SKILL_DIR}/scripts/everything_search.py" <project-name> <search arguments>`. An exact project name becomes its path from `~/.claude/project-paths.json`. The command then starts `es.exe` without a shell. `scripts/setup_project_paths.py` writes the registry.
|
|
@@ -22,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
25
|
+
def test_bash_roster_starts_only_the_msys_rewriter_and_the_five_gates() -> None:
|
|
26
26
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
27
27
|
BashHostedHookEntry(
|
|
28
28
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
@@ -46,4 +46,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_four_gates() -> None:
|
|
|
46
46
|
script_relative_path="blocking/python_windows_invocation_gate.py",
|
|
47
47
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
48
48
|
),
|
|
49
|
+
BashHostedHookEntry(
|
|
50
|
+
script_relative_path="blocking/transcript_root_search_gate.py",
|
|
51
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
52
|
+
),
|
|
49
53
|
)
|
|
@@ -274,6 +274,7 @@ def test_should_drop_the_gates_the_shell_guards_mod_holds_while_it_is_on(
|
|
|
274
274
|
"blocking/msys_rev_path_rewriter.py",
|
|
275
275
|
"blocking/cloud_graphql_gate.py",
|
|
276
276
|
"blocking/python_windows_invocation_gate.py",
|
|
277
|
+
"blocking/transcript_root_search_gate.py",
|
|
277
278
|
]
|
|
278
279
|
|
|
279
280
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for transcript_root_powershell_starts, which reads a cmdlet call's search starts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from blocking.transcript_root_powershell_starts import (
|
|
6
|
+
powershell_listing_search_starts,
|
|
7
|
+
select_string_search_starts,
|
|
8
|
+
)
|
|
9
|
+
from blocking.transcript_root_search_starts import SearchStart
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_reads_an_unknown_option_value_as_no_path() -> None:
|
|
13
|
+
assert powershell_listing_search_starts(["-Recurse", "-ea", "0", "-Filter", "*.jsonl"]) == [
|
|
14
|
+
SearchStart(".", True)
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_splits_a_comma_path_list() -> None:
|
|
19
|
+
assert powershell_listing_search_starts(["-Path", "a,b"]) == [
|
|
20
|
+
SearchStart("a", False),
|
|
21
|
+
SearchStart("b", False),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_lets_depth_zero_override_recurse() -> None:
|
|
26
|
+
assert powershell_listing_search_starts(["x", "-Recurse", "-Depth", "0"]) == [
|
|
27
|
+
SearchStart("x", False)
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_reads_a_glued_depth_as_recursive() -> None:
|
|
32
|
+
assert powershell_listing_search_starts(["x", "-Depth:3"]) == [SearchStart("x", True)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_reads_a_false_recurse_value_as_one_level() -> None:
|
|
36
|
+
assert powershell_listing_search_starts(["x", "-Recurse:$false"]) == [SearchStart("x", False)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_select_string_search_starts_reads_only_its_named_paths() -> None:
|
|
40
|
+
assert select_string_search_starts(["-Path", "p/*.jsonl", "-Pattern", "needle"]) == [
|
|
41
|
+
SearchStart("p/*.jsonl", False)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_reads_recurse_after_a_switch_off_the_list() -> None:
|
|
46
|
+
assert powershell_listing_search_starts(["x", "-Verbose", "-Recurse"]) == [
|
|
47
|
+
SearchStart("x", True)
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_reads_a_switch_abbreviation_as_its_switch() -> None:
|
|
52
|
+
assert powershell_listing_search_starts(["x", "-Recu", "-Filter", "*.jsonl"]) == [
|
|
53
|
+
SearchStart("x", True)
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_reads_a_directory_abbreviation_without_taking_the_path() -> None:
|
|
58
|
+
assert powershell_listing_search_starts(["-Dire", "x"]) == [SearchStart("x", False)]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_reads_a_depth_abbreviation_as_depth() -> None:
|
|
62
|
+
assert powershell_listing_search_starts(["x", "-Dep", "2"]) == [SearchStart("x", True)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_reads_the_path_after_a_common_parameter_switch() -> None:
|
|
66
|
+
assert powershell_listing_search_starts(["-Verbose", "x", "-Recurse"]) == [
|
|
67
|
+
SearchStart("x", True)
|
|
68
|
+
]
|
|
69
|
+
assert powershell_listing_search_starts(["-WhatIf", "x", "-Recurse"]) == [
|
|
70
|
+
SearchStart("x", True)
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_reads_a_short_path_abbreviation_as_path() -> None:
|
|
75
|
+
assert powershell_listing_search_starts(["-Pa", "x", "-Recurse"]) == [SearchStart("x", True)]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_reads_an_ambiguous_abbreviation_as_an_option_value() -> None:
|
|
79
|
+
assert powershell_listing_search_starts(["-De", "2", "x"]) == [SearchStart("x", False)]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Tests for transcript_root_search_gate, which stops walks over a Claude transcript root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import transcript_root_search_gate as gate
|
|
12
|
+
|
|
13
|
+
_OVERDUE_SEARCH_COMMAND = (
|
|
14
|
+
'$p="C:/Users/dev/.claude-profiles/work/projects"; '
|
|
15
|
+
"Get-ChildItem $p -Recurse -File -Filter *.jsonl | "
|
|
16
|
+
"Where-Object LastWriteTime -gt (Get-Date).AddDays(-5) | "
|
|
17
|
+
"Select-String -Pattern 'lock-clock-digit-port' -List"
|
|
18
|
+
)
|
|
19
|
+
_SCOPED_LISTING_COMMAND = (
|
|
20
|
+
'$p="C:/Users/dev/.claude-profiles/work/projects"; '
|
|
21
|
+
'Get-ChildItem $p -Directory -Filter "*01WUQeBG*"'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _stdout_from_main(payload: dict[str, object]) -> str:
|
|
26
|
+
"""Return what main() wrote to stdout for one hook payload."""
|
|
27
|
+
captured_stdout = StringIO()
|
|
28
|
+
with (
|
|
29
|
+
patch("sys.stdin", StringIO(json.dumps(payload))),
|
|
30
|
+
patch("sys.stdout", captured_stdout),
|
|
31
|
+
patch.object(gate, "log_hook_block"),
|
|
32
|
+
):
|
|
33
|
+
gate.main()
|
|
34
|
+
return captured_stdout.getvalue()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _shell_payload(tool_name: str, command: str) -> dict[str, object]:
|
|
38
|
+
return {"tool_name": tool_name, "tool_input": {"command": command}, "cwd": "C:/dev/repo"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_denied(stdout_text: str) -> bool:
|
|
42
|
+
if not stdout_text.strip():
|
|
43
|
+
return False
|
|
44
|
+
return json.loads(stdout_text)["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize(
|
|
48
|
+
"command",
|
|
49
|
+
[
|
|
50
|
+
_OVERDUE_SEARCH_COMMAND,
|
|
51
|
+
r"Get-ChildItem C:\Users\dev\.claude\projects -Recurse -Filter *.jsonl",
|
|
52
|
+
r'gci -Path "$env:USERPROFILE\.claude\projects" -Recurse:$true',
|
|
53
|
+
r"dir C:\Users\dev\.claude\projects -Depth 3",
|
|
54
|
+
"$p = '~/.claude/projects'; Get-ChildItem -LiteralPath $p -Recurse",
|
|
55
|
+
r'Select-String -Path "$HOME\.claude\projects\*\*.jsonl" -Pattern needle',
|
|
56
|
+
"Get-ChildItem ~/.claude/projects/* -Recurse",
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
def test_should_deny_a_powershell_walk_over_a_transcript_root(command: str) -> None:
|
|
60
|
+
assert _is_denied(_stdout_from_main(_shell_payload("PowerShell", command)))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.parametrize(
|
|
64
|
+
"command",
|
|
65
|
+
[
|
|
66
|
+
"find ~/.claude/projects -name '*.jsonl'",
|
|
67
|
+
"find -L /c/Users/dev/.claude/projects/ -type f",
|
|
68
|
+
"grep -rn needle ~/.claude-profiles/work/projects/",
|
|
69
|
+
"grep -Rl needle /home/u/.claude/projects",
|
|
70
|
+
"rg needle /c/Users/dev/.claude-work/projects",
|
|
71
|
+
"ls -laR ~/.claude/projects",
|
|
72
|
+
'p=~/.claude/projects; grep -rl needle "$p"',
|
|
73
|
+
'export ROOT="$HOME/.claude/projects" && find "${ROOT}" -newer x',
|
|
74
|
+
"bash -c 'find ~/.claude/projects -name x'",
|
|
75
|
+
"grep needle ~/.claude/projects/*/*.jsonl",
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
def test_should_deny_a_bash_walk_over_a_transcript_root(command: str) -> None:
|
|
79
|
+
assert _is_denied(_stdout_from_main(_shell_payload("Bash", command)))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize(
|
|
83
|
+
("tool_name", "command"),
|
|
84
|
+
[
|
|
85
|
+
("PowerShell", _SCOPED_LISTING_COMMAND),
|
|
86
|
+
("PowerShell", r"Get-ChildItem C:\Users\dev\.claude\projects"),
|
|
87
|
+
("PowerShell", r"Get-ChildItem C:\Users\dev\.claude\projects\*01WUQeBG*"),
|
|
88
|
+
("PowerShell", r"Get-ChildItem C:\Users\dev\.claude\projects\C--dev-repo -Recurse"),
|
|
89
|
+
("PowerShell", "Select-String -Path ~/.claude/projects/C--dev-repo/a.jsonl -Pattern x"),
|
|
90
|
+
("PowerShell", r"dir C:\Users\dev\.claude\projects -Depth 0"),
|
|
91
|
+
("Bash", "find ~/.claude/projects -maxdepth 1 -name '*01WUQeBG*'"),
|
|
92
|
+
("Bash", "grep -rn needle ~/.claude/projects/C--dev-repo"),
|
|
93
|
+
("Bash", "grep -rn needle ~/.claude/skills"),
|
|
94
|
+
("Bash", "grep -n projects notes.txt"),
|
|
95
|
+
("Bash", "find . -name projects"),
|
|
96
|
+
("Bash", "ls -la ~/.claude/projects"),
|
|
97
|
+
("Bash", "rg needle packages/claude-dev-env"),
|
|
98
|
+
],
|
|
99
|
+
)
|
|
100
|
+
def test_should_allow_a_scoped_command(tool_name: str, command: str) -> None:
|
|
101
|
+
assert _stdout_from_main(_shell_payload(tool_name, command)) == ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.parametrize(
|
|
105
|
+
"tool_input",
|
|
106
|
+
[
|
|
107
|
+
{"pattern": "needle", "path": "C:/Users/dev/.claude/projects"},
|
|
108
|
+
{"pattern": "needle", "path": "C:\\Users\\dev\\.claude-profiles\\work\\projects\\"},
|
|
109
|
+
{"pattern": "needle", "path": "/home/u/.claude-work/projects", "glob": "*.jsonl"},
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
def test_should_deny_a_grep_tool_search_over_a_transcript_root(
|
|
113
|
+
tool_input: dict[str, str],
|
|
114
|
+
) -> None:
|
|
115
|
+
payload = {"tool_name": "Grep", "tool_input": tool_input, "cwd": "C:/dev/repo"}
|
|
116
|
+
assert _is_denied(_stdout_from_main(payload))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_should_deny_a_grep_tool_search_that_starts_at_a_transcript_root_cwd() -> None:
|
|
120
|
+
payload = {
|
|
121
|
+
"tool_name": "Grep",
|
|
122
|
+
"tool_input": {"pattern": "needle"},
|
|
123
|
+
"cwd": "C:/Users/dev/.claude/projects",
|
|
124
|
+
}
|
|
125
|
+
assert _is_denied(_stdout_from_main(payload))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_should_allow_a_grep_tool_search_inside_one_project_folder() -> None:
|
|
129
|
+
payload = {
|
|
130
|
+
"tool_name": "Grep",
|
|
131
|
+
"tool_input": {"pattern": "needle", "path": "C:/Users/dev/.claude/projects/C--dev-repo"},
|
|
132
|
+
"cwd": "C:/dev/repo",
|
|
133
|
+
}
|
|
134
|
+
assert _stdout_from_main(payload) == ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.parametrize(
|
|
138
|
+
"tool_input",
|
|
139
|
+
[
|
|
140
|
+
{"pattern": "**/*.jsonl", "path": "C:/Users/dev/.claude/projects"},
|
|
141
|
+
{"pattern": "C:/Users/dev/.claude/projects/**/*.jsonl"},
|
|
142
|
+
{"pattern": "*/*.jsonl", "path": "/home/u/.claude-profiles/work/projects"},
|
|
143
|
+
{"pattern": "**", "path": "/home/u/.claude/projects"},
|
|
144
|
+
],
|
|
145
|
+
)
|
|
146
|
+
def test_should_deny_a_glob_tool_walk_over_a_transcript_root(
|
|
147
|
+
tool_input: dict[str, str],
|
|
148
|
+
) -> None:
|
|
149
|
+
payload = {"tool_name": "Glob", "tool_input": tool_input, "cwd": "C:/dev/repo"}
|
|
150
|
+
assert _is_denied(_stdout_from_main(payload))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.parametrize(
|
|
154
|
+
"tool_input",
|
|
155
|
+
[
|
|
156
|
+
{"pattern": "*01WUQeBG*", "path": "C:/Users/dev/.claude/projects"},
|
|
157
|
+
{"pattern": "*01WUQeBG*/*.jsonl", "path": "C:/Users/dev/.claude/projects"},
|
|
158
|
+
{"pattern": "**/*.py", "path": "C:/dev/repo"},
|
|
159
|
+
],
|
|
160
|
+
)
|
|
161
|
+
def test_should_allow_a_glob_tool_listing_by_name(tool_input: dict[str, str]) -> None:
|
|
162
|
+
payload = {"tool_name": "Glob", "tool_input": tool_input, "cwd": "C:/dev/repo"}
|
|
163
|
+
assert _stdout_from_main(payload) == ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_should_name_the_list_then_read_fix_in_the_deny_reason() -> None:
|
|
167
|
+
stdout_text = _stdout_from_main(_shell_payload("PowerShell", _OVERDUE_SEARCH_COMMAND))
|
|
168
|
+
deny_reason = json.loads(stdout_text)["hookSpecificOutput"]["permissionDecisionReason"]
|
|
169
|
+
assert "-Directory -Filter" in deny_reason
|
|
170
|
+
assert "C:/Users/dev/.claude-profiles/work/projects" in deny_reason
|
|
171
|
+
assert "read the one transcript" in deny_reason
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_should_stay_quiet_for_another_tool() -> None:
|
|
175
|
+
payload = {
|
|
176
|
+
"tool_name": "Read",
|
|
177
|
+
"tool_input": {"file_path": "C:/Users/dev/.claude/projects"},
|
|
178
|
+
"cwd": "C:/dev/repo",
|
|
179
|
+
}
|
|
180
|
+
assert _stdout_from_main(payload) == ""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tests for transcript_root_search_starts, which reads where a search begins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from blocking.transcript_root_search_starts import (
|
|
8
|
+
SearchStart,
|
|
9
|
+
file_search_tool_start,
|
|
10
|
+
joined_path,
|
|
11
|
+
normalized_path,
|
|
12
|
+
walked_transcript_root,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.parametrize(
|
|
17
|
+
("search_start", "expected_root"),
|
|
18
|
+
[
|
|
19
|
+
(SearchStart("~/.claude/projects", True), "~/.claude/projects"),
|
|
20
|
+
(SearchStart("~/.claude/projects/", True), "~/.claude/projects"),
|
|
21
|
+
(SearchStart("~/.claude/projects/.", True), "~/.claude/projects"),
|
|
22
|
+
(SearchStart("~/.claude/projects/C--x/..", True), "~/.claude/projects"),
|
|
23
|
+
(
|
|
24
|
+
SearchStart(r"C:\Users\dev\.claude-work\projects", True),
|
|
25
|
+
"C:/Users/dev/.claude-work/projects",
|
|
26
|
+
),
|
|
27
|
+
(
|
|
28
|
+
SearchStart("/h/.claude-profiles/work/projects/*/*.jsonl", False),
|
|
29
|
+
"/h/.claude-profiles/work/projects",
|
|
30
|
+
),
|
|
31
|
+
(SearchStart("~/.claude/projects/**", True), "~/.claude/projects"),
|
|
32
|
+
(SearchStart("~/.claude/projects/**", False), None),
|
|
33
|
+
(SearchStart("~/.claude/projects", False), None),
|
|
34
|
+
(SearchStart("~/.claude/projects/*01WU*", False), None),
|
|
35
|
+
(SearchStart("~/.claude/projects/C--repo", True), None),
|
|
36
|
+
(SearchStart("~/.claude/skills", True), None),
|
|
37
|
+
(SearchStart("~/notes/projects", True), None),
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
def test_walked_transcript_root_reads_the_root_a_start_walks(
|
|
41
|
+
search_start: SearchStart, expected_root: str | None
|
|
42
|
+
) -> None:
|
|
43
|
+
assert walked_transcript_root(search_start) == expected_root
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_normalized_path_folds_quotes_backslashes_and_dot_segments() -> None:
|
|
47
|
+
assert normalized_path('"C:\\h\\.claude\\projects\\C--x\\..\\"') == "C:/h/.claude/projects"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_joined_path_reads_a_relative_path_from_the_base_directory() -> None:
|
|
51
|
+
assert joined_path("/home/u/.claude", "projects") == "/home/u/.claude/projects"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_joined_path_keeps_an_absolute_path() -> None:
|
|
55
|
+
assert joined_path("/cwd", "~/.claude/projects") == "~/.claude/projects"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_file_search_tool_start_reads_grep_as_recursive_from_its_path() -> None:
|
|
59
|
+
assert file_search_tool_start("Grep", {"path": "/h/.claude/projects"}, "/cwd") == SearchStart(
|
|
60
|
+
"/h/.claude/projects", True
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_file_search_tool_start_reads_a_relative_grep_path_from_the_session_directory() -> None:
|
|
65
|
+
assert file_search_tool_start("Grep", {"path": "projects"}, "/h/.claude") == SearchStart(
|
|
66
|
+
"/h/.claude/projects", True
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_file_search_tool_start_reads_grep_with_no_path_from_the_session_directory() -> None:
|
|
71
|
+
assert file_search_tool_start("Grep", {"pattern": "x"}, "/h/.claude/projects") == (
|
|
72
|
+
SearchStart("/h/.claude/projects", True)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_file_search_tool_start_joins_a_glob_pattern_below_its_path() -> None:
|
|
77
|
+
assert file_search_tool_start(
|
|
78
|
+
"Glob", {"pattern": "*/*.jsonl", "path": "C:\\h\\.claude\\projects\\"}, "/cwd"
|
|
79
|
+
) == SearchStart("C:/h/.claude/projects/*/*.jsonl", False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_file_search_tool_start_keeps_an_absolute_glob_pattern_and_reads_its_recursion() -> None:
|
|
83
|
+
assert file_search_tool_start("Glob", {"pattern": "/h/.claude/projects/**"}, "/cwd") == (
|
|
84
|
+
SearchStart("/h/.claude/projects/**", True)
|
|
85
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Tests for transcript_root_shell_starts, which reads where a shell search begins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from blocking.transcript_root_search_starts import SearchStart, walked_transcript_root
|
|
8
|
+
from blocking.transcript_root_shell_starts import shell_command_search_starts
|
|
9
|
+
|
|
10
|
+
ROOT = "C:/Users/dev/.claude/projects"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _walked_roots(command: str, cwd: str = "", is_powershell: bool = False) -> list[str]:
|
|
14
|
+
return [
|
|
15
|
+
each_root
|
|
16
|
+
for each_root in (
|
|
17
|
+
walked_transcript_root(each_start)
|
|
18
|
+
for each_start in shell_command_search_starts(command, cwd, is_powershell)
|
|
19
|
+
)
|
|
20
|
+
if each_root
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_expands_a_variable_the_command_assigns() -> None:
|
|
25
|
+
all_starts = shell_command_search_starts(
|
|
26
|
+
f'$p="{ROOT}"; Get-ChildItem $p -Recurse -Filter *.jsonl', "", True
|
|
27
|
+
)
|
|
28
|
+
assert SearchStart(ROOT, True) in all_starts
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_reads_a_name_listing_as_one_level() -> None:
|
|
32
|
+
all_starts = shell_command_search_starts(
|
|
33
|
+
f'Get-ChildItem {ROOT} -Directory -Filter "*01WUQeBG*"', "", True
|
|
34
|
+
)
|
|
35
|
+
assert SearchStart(ROOT, False) in all_starts
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_skips_a_command_that_never_names_a_projects_folder() -> None:
|
|
39
|
+
assert shell_command_search_starts("grep -r needle /src", "/home/u", False) == []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize(
|
|
43
|
+
("command", "cwd"),
|
|
44
|
+
[
|
|
45
|
+
pytest.param(f"grep -rC2 needle {ROOT}", "", id="short-cluster-with-digit"),
|
|
46
|
+
pytest.param(f"grep -R1 needle {ROOT}", "", id="short-cluster-r-digit"),
|
|
47
|
+
pytest.param(f"grep -r needle {ROOT}/.", "", id="dot-segment"),
|
|
48
|
+
pytest.param("cd ~/.claude/projects && grep -r needle", "", id="cd-then-default-path"),
|
|
49
|
+
pytest.param("cd ~/.claude/projects && grep -rn -C 2 needle", "", id="grep-option-value"),
|
|
50
|
+
pytest.param("cd ~/.claude/projects && rg -g '*.jsonl' needle", "", id="rg-option-value"),
|
|
51
|
+
pytest.param("grep -r -e a -e b", "/home/u/.claude/projects", id="grep-pattern-options"),
|
|
52
|
+
pytest.param("grep -r needle", "/home/u/.claude/projects", id="session-directory"),
|
|
53
|
+
pytest.param("cd && grep -r needle .claude/projects", "/x", id="bare-cd-goes-home"),
|
|
54
|
+
pytest.param(f"p={ROOT}; grep -r x $p; p={ROOT}/C--x", "", id="assignment-after-use"),
|
|
55
|
+
pytest.param(f"grep -d recurse needle {ROOT}", "", id="grep-directories-recurse"),
|
|
56
|
+
pytest.param(f"grep --directories=recurse needle {ROOT}", "", id="grep-glued-directories"),
|
|
57
|
+
pytest.param(f"rg needle {ROOT}", "", id="rg-default"),
|
|
58
|
+
pytest.param(f"rg --files {ROOT}", "", id="rg-files"),
|
|
59
|
+
pytest.param(f"rg --files -g '*.jsonl' {ROOT}", "", id="rg-files-glob"),
|
|
60
|
+
pytest.param(f"fd . {ROOT}", "", id="fd"),
|
|
61
|
+
pytest.param(f"tree {ROOT}", "", id="tree"),
|
|
62
|
+
pytest.param(f"find {ROOT} -name '*.jsonl'", "", id="find"),
|
|
63
|
+
pytest.param(f"ls -R {ROOT}", "", id="ls-recursive"),
|
|
64
|
+
pytest.param(f"cat {ROOT}/*/*.jsonl | grep needle", "", id="cat-every-transcript"),
|
|
65
|
+
pytest.param(f"pushd {ROOT} && grep -r needle .", "", id="pushd-then-walk"),
|
|
66
|
+
pytest.param(
|
|
67
|
+
f"pushd {ROOT} && pushd {ROOT}/C--x && popd && grep -r needle .",
|
|
68
|
+
"",
|
|
69
|
+
id="popd-one-level",
|
|
70
|
+
),
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
def test_reads_a_bash_walk_of_the_root(command: str, cwd: str) -> None:
|
|
74
|
+
assert _walked_roots(command, cwd)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.parametrize(
|
|
78
|
+
("command", "cwd"),
|
|
79
|
+
[
|
|
80
|
+
pytest.param(f'$P="{ROOT}"; gci $p -Recurse', "", id="variable-case"),
|
|
81
|
+
pytest.param(
|
|
82
|
+
'$h="C:/Users/dev"; $p="$h/.claude/projects"; gci $p -Recurse', "", id="chained"
|
|
83
|
+
),
|
|
84
|
+
pytest.param(
|
|
85
|
+
r"Set-Location C:\Users\dev\.claude\projects; Get-ChildItem -Recurse -Filter *.jsonl",
|
|
86
|
+
"",
|
|
87
|
+
id="backslash-set-location",
|
|
88
|
+
),
|
|
89
|
+
pytest.param("Set-Location C:/Users/dev/.claude; gci projects -Recurse", "", id="relative"),
|
|
90
|
+
pytest.param("Get-ChildItem -Recurse -ea 0 -Filter *.jsonl", ROOT, id="unknown-option"),
|
|
91
|
+
pytest.param(f"gci C:/x,{ROOT} -Recurse", "", id="comma-path-list"),
|
|
92
|
+
pytest.param(f"gci {ROOT} -Depth:3", "", id="glued-depth"),
|
|
93
|
+
pytest.param(f"ls -r {ROOT}", "", id="ls-alias-recurse"),
|
|
94
|
+
pytest.param(f"ls {ROOT} -Depth 2", "", id="ls-alias-depth"),
|
|
95
|
+
pytest.param(f"Get-Content {ROOT}/*/*.jsonl | Select-String x", "", id="get-content"),
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
def test_reads_a_powershell_walk_of_the_root(command: str, cwd: str) -> None:
|
|
99
|
+
assert _walked_roots(command, cwd, is_powershell=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize(
|
|
103
|
+
"command",
|
|
104
|
+
[
|
|
105
|
+
pytest.param(f"rg --max-depth 1 needle {ROOT}", id="rg-spaced-max-depth"),
|
|
106
|
+
pytest.param(f"rg --max-depth=1 needle {ROOT}", id="rg-glued-max-depth"),
|
|
107
|
+
pytest.param(f"find {ROOT} -maxdepth 1 -type d", id="find-maxdepth"),
|
|
108
|
+
pytest.param(f"tree -L 1 {ROOT}", id="tree-one-level"),
|
|
109
|
+
pytest.param(f"ls {ROOT}/**", id="ls-double-star"),
|
|
110
|
+
pytest.param(f"grep -r needle {ROOT}/C--repo", id="one-project-folder"),
|
|
111
|
+
pytest.param(f"grep -eremote {ROOT}", id="glued-pattern-is-no-recursion"),
|
|
112
|
+
pytest.param(f"git status {ROOT}", id="plain-program-on-the-root"),
|
|
113
|
+
pytest.param(f"p={ROOT}/C--x; grep -r x $p; p={ROOT}", id="assignment-after-scoped-use"),
|
|
114
|
+
pytest.param(f"pushd {ROOT} && popd && grep -r needle .", id="popd-returns"),
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
def test_reads_a_scoped_bash_search_as_no_walk(command: str) -> None:
|
|
118
|
+
assert _walked_roots(command) == []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.parametrize(
|
|
122
|
+
"command",
|
|
123
|
+
[
|
|
124
|
+
pytest.param(f"gci {ROOT} -Recurse -Depth 0", id="depth-zero-after-recurse"),
|
|
125
|
+
pytest.param(f"gci {ROOT} -Depth 0 -Recurse", id="depth-zero-before-recurse"),
|
|
126
|
+
pytest.param(f"ls {ROOT} -ReadOnly", id="ls-alias-switch"),
|
|
127
|
+
pytest.param(f"Select-String -Path {ROOT}/*01WU*/x.jsonl -Pattern a", id="select-string"),
|
|
128
|
+
],
|
|
129
|
+
)
|
|
130
|
+
def test_reads_a_scoped_powershell_search_as_no_walk(command: str) -> None:
|
|
131
|
+
assert _walked_roots(command, is_powershell=True) == []
|
|
@@ -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]
|