claude-dev-env 8.96.7 → 8.96.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook that keeps every SendUserFile path under the session's working directory.
3
+
4
+ The desktop file pane resolves a sent file only under the session's root
5
+ folder, the ``cwd`` in the hook payload. A file elsewhere, such as a network
6
+ share or the session scratchpad, passes SendUserFile's own check and then shows
7
+ "Couldn't find this file" to the user::
8
+
9
+ SendUserFile {"files": ["<cwd>\\.scratch\\a.md"]} -> allowed
10
+ SendUserFile {"files": ["Y:\\share\\a.md"]} -> denied
11
+ SendUserFile {"files": ["<cwd>\\..\\other\\a.md"]} -> denied
12
+ a relative path -> joined to cwd
13
+ no cwd, no files list, or another tool -> allowed
14
+
15
+ Paths compare case-insensitively with Windows rules when ``cwd`` is a Windows
16
+ path, and with POSIX rules otherwise. The deny reason lists each outside file
17
+ and names ``<cwd>\\.scratch\\`` as the folder to copy it into.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ntpath
23
+ import posixpath
24
+ import sys
25
+ from pathlib import Path
26
+ from types import ModuleType
27
+
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
+
32
+ from hooks_constants.context_budget_constants import CWD_KEY
33
+ from hooks_constants.hook_block_logger import log_hook_block
34
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
35
+ from hooks_constants.send_user_file_worktree_root_gate_constants import (
36
+ ALLOW_EXIT_CODE,
37
+ BLOCK_EXIT_CODE,
38
+ FILES_INPUT_KEY,
39
+ GATE_EVENT_NAME,
40
+ OFFENDING_INPUT_SEPARATOR,
41
+ OUTSIDE_ROOT_HEADER,
42
+ SCRATCH_FOLDER_INSTRUCTION,
43
+ SCRATCH_FOLDER_NAME,
44
+ SEND_USER_FILE_TOOL_NAME,
45
+ WINDOWS_PATH_PATTERN,
46
+ )
47
+ from hooks_constants.spawn_readiness_hook_constants import TOOL_INPUT_KEY, TOOL_NAME_KEY
48
+
49
+
50
+ def _path_rules_for(working_directory: str) -> ModuleType:
51
+ return ntpath if WINDOWS_PATH_PATTERN.match(working_directory) else posixpath
52
+
53
+
54
+ def is_under_working_directory(file_path: str, working_directory: str) -> bool:
55
+ """Return True when the file path names the working directory or a path inside it.
56
+
57
+ >>> is_under_working_directory("C:\\\\wt\\\\.scratch\\\\a.md", "c:/WT")
58
+ True
59
+ >>> is_under_working_directory("Y:\\\\share\\\\a.md", "C:\\\\wt")
60
+ False
61
+
62
+ Args:
63
+ file_path: One path from the SendUserFile ``files`` list.
64
+ working_directory: The session's ``cwd`` from the hook payload.
65
+
66
+ Returns:
67
+ True for the root itself or a path under it after joining and normalizing.
68
+ """
69
+ path_rules = _path_rules_for(working_directory)
70
+ root = path_rules.normcase(path_rules.normpath(working_directory))
71
+ candidate = path_rules.normcase(
72
+ path_rules.normpath(path_rules.join(working_directory, file_path))
73
+ )
74
+ if candidate == root:
75
+ return True
76
+ return candidate.startswith(root.rstrip(path_rules.sep) + path_rules.sep)
77
+
78
+
79
+ def outside_files(tool_input: object, working_directory: str) -> list[str]:
80
+ """Return each SendUserFile path that sits outside the working directory.
81
+
82
+ Args:
83
+ tool_input: The SendUserFile tool input.
84
+ working_directory: The session's ``cwd`` from the hook payload.
85
+
86
+ Returns:
87
+ The outside paths in call order, or an empty list when no files list is present.
88
+ """
89
+ all_files = tool_input.get(FILES_INPUT_KEY) if isinstance(tool_input, dict) else None
90
+ if not isinstance(all_files, list):
91
+ return []
92
+ return [
93
+ each_file
94
+ for each_file in all_files
95
+ if isinstance(each_file, str)
96
+ and not is_under_working_directory(each_file, working_directory)
97
+ ]
98
+
99
+
100
+ def format_block_reason(all_outside_files: list[str], working_directory: str) -> str:
101
+ """Return the deny message that lists the outside files and the folder to copy them into.
102
+
103
+ Args:
104
+ all_outside_files: Paths outside the working directory.
105
+ working_directory: The session's ``cwd`` from the hook payload.
106
+
107
+ Returns:
108
+ The message the session reads when the call is denied.
109
+ """
110
+ path_rules = _path_rules_for(working_directory)
111
+ scratch_folder = path_rules.join(working_directory, SCRATCH_FOLDER_NAME) + path_rules.sep
112
+ file_lines = "".join(f"- {each_file}\n" for each_file in all_outside_files)
113
+ return (
114
+ OUTSIDE_ROOT_HEADER.format(cwd=working_directory)
115
+ + file_lines
116
+ + SCRATCH_FOLDER_INSTRUCTION.format(scratch_folder=scratch_folder)
117
+ )
118
+
119
+
120
+ def main() -> int:
121
+ hook_input = read_hook_input_dictionary_from_stdin()
122
+ if hook_input is None or hook_input.get(TOOL_NAME_KEY) != SEND_USER_FILE_TOOL_NAME:
123
+ return ALLOW_EXIT_CODE
124
+ working_directory = hook_input.get(CWD_KEY)
125
+ if not isinstance(working_directory, str) or not working_directory:
126
+ return ALLOW_EXIT_CODE
127
+ all_outside_files = outside_files(hook_input.get(TOOL_INPUT_KEY), working_directory)
128
+ if not all_outside_files:
129
+ return ALLOW_EXIT_CODE
130
+ block_reason = format_block_reason(all_outside_files, working_directory)
131
+ log_hook_block(
132
+ Path(__file__).name,
133
+ GATE_EVENT_NAME,
134
+ block_reason,
135
+ tool_name=SEND_USER_FILE_TOOL_NAME,
136
+ offending_input_preview=OFFENDING_INPUT_SEPARATOR.join(all_outside_files),
137
+ )
138
+ sys.stderr.write(block_reason)
139
+ return BLOCK_EXIT_CODE
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
@@ -0,0 +1,126 @@
1
+ import io
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ import send_user_file_worktree_root_gate
8
+
9
+ WORKTREE_CWD = "C:\\dev\\project\\.claude\\worktrees\\feature-branch"
10
+ SHARE_PATH = "Y:\\shared\\reports\\reflect.md"
11
+ SCRATCHPAD_PATH = "C:\\Users\\example\\AppData\\Local\\Temp\\claude\\scratchpad\\reflect.md"
12
+ SCRATCH_FOLDER_PATH = WORKTREE_CWD + "\\.scratch\\reflect.md"
13
+
14
+
15
+ def run_gate(
16
+ monkeypatch: pytest.MonkeyPatch,
17
+ capsys: pytest.CaptureFixture[str],
18
+ hook_input: dict[str, object],
19
+ ) -> tuple[int, str]:
20
+ monkeypatch.setattr(
21
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
22
+ )
23
+ exit_code = send_user_file_worktree_root_gate.main()
24
+ return exit_code, capsys.readouterr().err
25
+
26
+
27
+ def send_user_file_input(all_files: object, cwd: object = WORKTREE_CWD) -> dict[str, object]:
28
+ return {
29
+ "hook_event_name": "PreToolUse",
30
+ "tool_name": "SendUserFile",
31
+ "cwd": cwd,
32
+ "tool_input": {"files": all_files, "status": "normal", "display": "render"},
33
+ }
34
+
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
38
+ monkeypatch.setenv("HOME", str(tmp_path))
39
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
40
+
41
+
42
+ @pytest.mark.parametrize("outside_path", [SHARE_PATH, SCRATCHPAD_PATH])
43
+ def test_should_deny_a_file_outside_the_working_directory_and_name_the_scratch_folder(
44
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], outside_path: str
45
+ ) -> None:
46
+ exit_code, block_reason = run_gate(monkeypatch, capsys, send_user_file_input([outside_path]))
47
+ assert exit_code == 2
48
+ assert outside_path in block_reason
49
+ assert WORKTREE_CWD + "\\.scratch\\" in block_reason
50
+
51
+
52
+ @pytest.mark.parametrize(
53
+ "inside_path",
54
+ [
55
+ SCRATCH_FOLDER_PATH,
56
+ SCRATCH_FOLDER_PATH.lower(),
57
+ SCRATCH_FOLDER_PATH.replace("\\", "/"),
58
+ ".scratch\\reflect.md",
59
+ WORKTREE_CWD,
60
+ ],
61
+ )
62
+ def test_should_allow_a_file_under_the_working_directory(
63
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], inside_path: str
64
+ ) -> None:
65
+ assert run_gate(monkeypatch, capsys, send_user_file_input([inside_path])) == (0, "")
66
+
67
+
68
+ def test_should_deny_a_path_that_climbs_out_of_the_working_directory(
69
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
70
+ ) -> None:
71
+ climbing_path = WORKTREE_CWD + "\\.scratch\\..\\..\\other-worktree\\reflect.md"
72
+ exit_code, block_reason = run_gate(monkeypatch, capsys, send_user_file_input([climbing_path]))
73
+ assert exit_code == 2
74
+ assert climbing_path in block_reason
75
+
76
+
77
+ def test_should_deny_a_sibling_folder_that_shares_the_working_directory_prefix(
78
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
79
+ ) -> None:
80
+ sibling_path = WORKTREE_CWD + "-copy\\reflect.md"
81
+ exit_code, block_reason = run_gate(monkeypatch, capsys, send_user_file_input([sibling_path]))
82
+ assert exit_code == 2
83
+ assert sibling_path in block_reason
84
+
85
+
86
+ def test_should_name_only_the_outside_files_when_a_call_mixes_both(
87
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
88
+ ) -> None:
89
+ exit_code, block_reason = run_gate(
90
+ monkeypatch, capsys, send_user_file_input([SCRATCH_FOLDER_PATH, SHARE_PATH])
91
+ )
92
+ assert exit_code == 2
93
+ assert SHARE_PATH in block_reason
94
+ assert "- " + SCRATCH_FOLDER_PATH not in block_reason
95
+
96
+
97
+ def test_should_compare_posix_paths_under_a_posix_working_directory(
98
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
99
+ ) -> None:
100
+ posix_cwd = "/home/user/worktree"
101
+ assert run_gate(
102
+ monkeypatch, capsys, send_user_file_input([posix_cwd + "/.scratch/a.md"], posix_cwd)
103
+ ) == (0, "")
104
+ exit_code, block_reason = run_gate(
105
+ monkeypatch, capsys, send_user_file_input(["/tmp/a.md"], posix_cwd)
106
+ )
107
+ assert exit_code == 2
108
+ assert "/tmp/a.md" in block_reason
109
+
110
+
111
+ @pytest.mark.parametrize(
112
+ "hook_input",
113
+ [
114
+ send_user_file_input([SHARE_PATH], cwd=None),
115
+ send_user_file_input([SHARE_PATH], cwd=""),
116
+ send_user_file_input(None),
117
+ send_user_file_input(SHARE_PATH),
118
+ {**send_user_file_input([SHARE_PATH]), "tool_name": "Read"},
119
+ ],
120
+ )
121
+ def test_should_allow_a_call_the_gate_cannot_judge(
122
+ monkeypatch: pytest.MonkeyPatch,
123
+ capsys: pytest.CaptureFixture[str],
124
+ hook_input: dict[str, object],
125
+ ) -> None:
126
+ assert run_gate(monkeypatch, capsys, hook_input) == (0, "")
@@ -20,6 +20,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
20
20
  - `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
21
21
  - `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
22
22
  - `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
23
+ - `blocking/send_user_file_worktree_root_gate.py` denies a `SendUserFile` call that names a file outside the session's working directory, the `cwd` in the hook payload. The desktop file pane opens a sent file only under that folder and shows "Couldn't find this file" for any other path. The reason lists each outside file and names `<cwd>\.scratch\` as the folder to copy it into.
23
24
 
24
25
  ## When it fires
25
26
 
@@ -39,6 +40,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
39
40
  - `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
40
41
  - `blocking/merge_over_review_offer_gate.py` runs on `PreToolUse`, matcher `AskUserQuestion|mcp__.*__ask_decision`, timeout `10` seconds in `hooks.json`.
41
42
  - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
43
+ - `blocking/send_user_file_worktree_root_gate.py` runs on `PreToolUse`, matcher `SendUserFile`, timeout `10` seconds in `hooks.json`.
42
44
 
43
45
  ## Proving it
44
46
 
@@ -62,6 +64,7 @@ Preconditions:
62
64
  - **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge with no head. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge or auto-merge pinned to the clean head and a merge in an exempt repository pass.
63
65
  - **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
64
66
  - **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
67
+ - **Sent file root.** Input is a `SendUserFile` call for a file on `Y:\` or in the session scratchpad, with `cwd` set to a worktree. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_send_user_file_worktree_root_gate.py -q`. The adjacent test observes a denial that names the file and `<cwd>\.scratch\`; a file under `<cwd>\.scratch\` passes.
65
68
 
66
69
  ## Gotchas
67
70
 
package/hooks/hooks.json CHANGED
@@ -214,6 +214,16 @@
214
214
  }
215
215
  ]
216
216
  },
217
+ {
218
+ "matcher": "SendUserFile",
219
+ "hooks": [
220
+ {
221
+ "type": "command",
222
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/send_user_file_worktree_root_gate.py",
223
+ "timeout": 10
224
+ }
225
+ ]
226
+ },
217
227
  {
218
228
  "matcher": "Artifact|Skill",
219
229
  "hooks": [
@@ -0,0 +1,36 @@
1
+ """Names and messages for the send_user_file_worktree_root_gate PreToolUse hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ __all__ = [
8
+ "ALLOW_EXIT_CODE",
9
+ "BLOCK_EXIT_CODE",
10
+ "FILES_INPUT_KEY",
11
+ "GATE_EVENT_NAME",
12
+ "OFFENDING_INPUT_SEPARATOR",
13
+ "OUTSIDE_ROOT_HEADER",
14
+ "SCRATCH_FOLDER_NAME",
15
+ "SCRATCH_FOLDER_INSTRUCTION",
16
+ "SEND_USER_FILE_TOOL_NAME",
17
+ "WINDOWS_PATH_PATTERN",
18
+ ]
19
+
20
+ ALLOW_EXIT_CODE = 0
21
+ BLOCK_EXIT_CODE = 2
22
+ SEND_USER_FILE_TOOL_NAME = "SendUserFile"
23
+ FILES_INPUT_KEY = "files"
24
+ GATE_EVENT_NAME = "PreToolUse"
25
+ OFFENDING_INPUT_SEPARATOR = ", "
26
+ SCRATCH_FOLDER_NAME = ".scratch"
27
+ WINDOWS_PATH_PATTERN = re.compile(r"^(?:[A-Za-z]:|\\\\)")
28
+ OUTSIDE_ROOT_HEADER = (
29
+ "The desktop file pane opens a sent file only when it sits under this session's"
30
+ " working directory, {cwd}. These files are outside it, so the user sees"
31
+ ' "Couldn\'t find this file":\n'
32
+ )
33
+ SCRATCH_FOLDER_INSTRUCTION = (
34
+ "\nCopy each file into {scratch_folder} and call SendUserFile with the copied paths."
35
+ " If git does not ignore that folder, add .scratch/ to .git/info/exclude first."
36
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.96.7",
3
+ "version": "8.96.8",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -136,6 +136,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
136
136
  hooks/blocking/verify_before_acting.py -> exempt
137
137
  hooks/blocking/session_title_format_gate.py -> exempt
138
138
  hooks/blocking/artifact_dark_mode_gate.py -> exempt
139
+ hooks/blocking/send_user_file_worktree_root_gate.py -> exempt
139
140
  hooks/blocking/build_eval_before_eval_write_gate.py -> exempt
140
141
  hooks/blocking/merge_over_review_offer_gate.py -> exempt
141
142
  hooks/blocking/final_reply_gate.py -> exempt
@@ -174,6 +175,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
174
175
  The artifact page gate denies a page publish whose text is unreadable in
175
176
  dark mode. It decides how a page renders and no code or safety policy.
176
177
 
178
+ The send user file gate denies a SendUserFile path outside the session's
179
+ working directory, which the desktop file pane cannot open. It decides
180
+ where a sent file sits and no code or safety policy.
181
+
177
182
  The eval write gate denies a write to an eval file until the session
178
183
  invokes ``/claude-api build-eval``. It holds eval writes until a skill
179
184
  loads, and it judges no code or safety content.
@@ -74,6 +74,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
74
74
  "blocking/session_title_format_gate.py",
75
75
  "blocking/image_read_size_gate.py",
76
76
  "blocking/artifact_dark_mode_gate.py",
77
+ "blocking/send_user_file_worktree_root_gate.py",
77
78
  "blocking/haiku_spawn_slim_gate.py",
78
79
  "blocking/build_eval_before_eval_write_gate.py",
79
80
  "blocking/merge_over_review_offer_gate.py",
@@ -154,6 +154,14 @@ def test_new_artifact_page_gate_registration_is_clean(tmp_path: Path) -> None:
154
154
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
155
155
 
156
156
 
157
+ def test_new_send_user_file_gate_registration_is_clean(tmp_path: Path) -> None:
158
+ current_document = _hook_document(
159
+ ["hooks/blocking/send_user_file_worktree_root_gate.py"],
160
+ '{"hooks": {}}',
161
+ )
162
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
163
+
164
+
157
165
  def test_new_eval_write_gate_registration_is_clean(tmp_path: Path) -> None:
158
166
  current_document = _hook_document(
159
167
  ["hooks/blocking/build_eval_before_eval_write_gate.py"],