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.
@@ -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:** none.
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
- - No hook denies a walk from an unscoped root. The scope invariant above is guidance a reader follows.
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 test_bash_roster_starts_only_the_msys_rewriter_and_the_four_gates() -> None:
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]