claude-dev-env 8.96.6 → 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.
- package/hooks/blocking/send_user_file_worktree_root_gate.py +143 -0
- package/hooks/blocking/test_final_reply_gate.py +17 -0
- package/hooks/blocking/test_reply_length_gate.py +2 -2
- package/hooks/blocking/test_send_user_file_worktree_root_gate.py +126 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +2 -1
- package/hooks/hooks_constants/send_user_file_worktree_root_gate_constants.py +36 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -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())
|
|
@@ -87,6 +87,23 @@ def test_should_allow_a_run_a_tool_result_shows(
|
|
|
87
87
|
assert run_gate(monkeypatch, capsys, MEASURED_REPLY, transcript_path) == (0, "")
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def test_should_steer_a_skipped_check_to_an_open_line(
|
|
91
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
92
|
+
) -> None:
|
|
93
|
+
skipped_check_reply = "I have not checked the pivot, reach, or screen 26 captures yet."
|
|
94
|
+
_, stdout_text = run_gate(monkeypatch, capsys, skipped_check_reply)
|
|
95
|
+
block_reason = json.loads(stdout_text)["reason"]
|
|
96
|
+
assert "`Open: <check> - <next step>`" in block_reason
|
|
97
|
+
assert "leave the claim out" not in block_reason
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_should_allow_an_open_line_for_a_skipped_check(
|
|
101
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
102
|
+
) -> None:
|
|
103
|
+
open_line = "Open: pivot, reach, and screen 26 checks - the next check-in measures them."
|
|
104
|
+
assert run_gate(monkeypatch, capsys, open_line) == (0, "")
|
|
105
|
+
|
|
106
|
+
|
|
90
107
|
def test_should_allow_a_closing_reply_with_no_message(
|
|
91
108
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
92
109
|
) -> None:
|
|
@@ -1000,8 +1000,8 @@ def test_should_collect_each_question_card_prose_field() -> None:
|
|
|
1000
1000
|
def test_should_quote_the_hedged_sentence() -> None:
|
|
1001
1001
|
assert reply_length_gate.unvalidated_claim_violation("Done. It should take a few minutes.") == (
|
|
1002
1002
|
'No tool validated this: "It should take a few minutes.". Run the check yourself or through'
|
|
1003
|
-
" a subagent, then state what the run, log line, output, or file and line shows
|
|
1004
|
-
"
|
|
1003
|
+
" a subagent, then state what the run, log line, output, or file and line shows. When you"
|
|
1004
|
+
" cannot run it this turn, report it as `Open: <check> - <next step>`."
|
|
1005
1005
|
)
|
|
1006
1006
|
|
|
1007
1007
|
|
|
@@ -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": [
|
|
@@ -147,7 +147,8 @@ DURATION_CLAIM_PATTERN = re.compile(
|
|
|
147
147
|
)
|
|
148
148
|
UNVALIDATED_CLAIM_MESSAGE = (
|
|
149
149
|
'No tool validated this: "{claim}". Run the check yourself or through a subagent,'
|
|
150
|
-
" then state what the run, log line, output, or file and line shows
|
|
150
|
+
" then state what the run, log line, output, or file and line shows. When you cannot run it"
|
|
151
|
+
" this turn, report it as `Open: <check> - <next step>`."
|
|
151
152
|
)
|
|
152
153
|
CITED_NUMBER_PATTERN = re.compile(
|
|
153
154
|
r"/actions/runs/(\d+)|/pull/(\d+)|\b(?:run|job|check)\s+#?(\d{5,})\b", re.IGNORECASE
|
|
@@ -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
|
@@ -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"],
|