claude-dev-env 8.96.7 → 8.97.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/bin/install-pstack-plugin.mjs +31 -4
- package/bin/install-pstack-plugin.test.mjs +23 -0
- package/bin/install.prompt-context.test.mjs +2 -1
- package/hooks/blocking/send_user_file_worktree_root_gate.py +143 -0
- package/hooks/blocking/test_send_user_file_worktree_root_gate.py +126 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/features/session-context.md +4 -0
- package/hooks/hooks.json +20 -0
- package/hooks/hooks_constants/plugin_session_baseline_constants.py +50 -0
- package/hooks/hooks_constants/send_user_file_worktree_root_gate_constants.py +36 -0
- package/hooks/plugin_session_baseline.py +190 -0
- package/hooks/session/plugin_change_warning.py +90 -0
- package/hooks/session/plugin_session_baseline_writer.py +53 -0
- package/hooks/session/test_plugin_change_warning.py +216 -0
- package/hooks/session/test_plugin_session_baseline_writer.py +136 -0
- package/hooks/test_context_budget_hooks.py +2 -0
- package/hooks/test_plugin_session_baseline.py +98 -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
|
@@ -318,6 +318,23 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
318
318
|
return { host, executable, status: 'installed', warning: null };
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Build the notice that tells the user to restart each session the install reached.
|
|
323
|
+
*
|
|
324
|
+
* A running Claude Code or Codex session keeps the plugin files it loaded at
|
|
325
|
+
* start, so a plugin installed or updated under it stays unavailable there
|
|
326
|
+
* until the session restarts.
|
|
327
|
+
*
|
|
328
|
+
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins the install wrote.
|
|
329
|
+
* @param {ReadonlyArray<string>} allInstalledHosts The hosts the install reached.
|
|
330
|
+
* @returns {string} The restart notice.
|
|
331
|
+
*/
|
|
332
|
+
export function restartNotice(allSpecs, allInstalledHosts) {
|
|
333
|
+
const pluginLabels = allSpecs.map(eachSpec => eachSpec.label).join(', ');
|
|
334
|
+
return `Restart each open ${allInstalledHosts.join(' and ')} session to load ${pluginLabels}. `
|
|
335
|
+
+ 'A running session keeps the plugin version it loaded at start.';
|
|
336
|
+
}
|
|
337
|
+
|
|
321
338
|
/**
|
|
322
339
|
* Install a set of plugins from one marketplace into each host's own home.
|
|
323
340
|
*
|
|
@@ -334,7 +351,9 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
334
351
|
* `cmd.exe`'s command-not-found exit code on Windows, and both read as
|
|
335
352
|
* skipped. When a host cannot replace a plugin folder because something holds
|
|
336
353
|
* it, the warning names each process that holds it, so the user knows what to
|
|
337
|
-
* close before running the install again.
|
|
354
|
+
* close before running the install again. When any host installs, the step
|
|
355
|
+
* prints one restart notice, because an open session keeps the plugin
|
|
356
|
+
* version it loaded at start.
|
|
338
357
|
*
|
|
339
358
|
* @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
|
|
340
359
|
* @param {object} [options] Install targets.
|
|
@@ -342,8 +361,8 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
342
361
|
* @param {string} [options.codexHome] The Codex home to install into.
|
|
343
362
|
* @param {string[]} [options.hosts] The hosts to install. Defaults to the hosts every spec names.
|
|
344
363
|
* @param {Record<string, string|undefined>} [options.environment] The process environment.
|
|
345
|
-
* @param {object} [dependencies] Seams for the command runner
|
|
346
|
-
* @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
|
|
364
|
+
* @param {object} [dependencies] Seams for the command runner, the folder locker search, and the line writer.
|
|
365
|
+
* @returns {{status: string, hosts: object[], warning: string|null, restartNotice: string|null}} The outcome per host.
|
|
347
366
|
*/
|
|
348
367
|
export function installMarketplacePlugins(allSpecs, options = {}, dependencies = {}) {
|
|
349
368
|
const environment = options.environment ?? process.env;
|
|
@@ -365,7 +384,15 @@ export function installMarketplacePlugins(allSpecs, options = {}, dependencies =
|
|
|
365
384
|
const installedCount = hostOutcomes.filter(outcome => outcome.status === 'installed').length;
|
|
366
385
|
const status = failedCount > 0 ? 'failed' : (installedCount > 0 ? 'installed' : 'skipped');
|
|
367
386
|
const warnings = hostOutcomes.map(outcome => outcome.warning).filter(Boolean);
|
|
368
|
-
|
|
387
|
+
const allInstalledHosts = hostOutcomes
|
|
388
|
+
.filter(outcome => outcome.status === 'installed')
|
|
389
|
+
.map(outcome => outcome.host);
|
|
390
|
+
const notice = allInstalledHosts.length > 0 ? restartNotice(allSpecs, allInstalledHosts) : null;
|
|
391
|
+
if (notice) {
|
|
392
|
+
const writeLine = dependencies.writeLine ?? (line => console.log(line));
|
|
393
|
+
writeLine(notice);
|
|
394
|
+
}
|
|
395
|
+
return { status, hosts: hostOutcomes, warning: warnings.join(' ') || null, restartNotice: notice };
|
|
369
396
|
}
|
|
370
397
|
|
|
371
398
|
/**
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
removeOrphanedPluginVersions,
|
|
18
18
|
removeStaleMarketplaceClone,
|
|
19
19
|
removeRetiredMarketplacePlugins,
|
|
20
|
+
restartNotice,
|
|
20
21
|
shouldInstallMarketplacePlugin,
|
|
21
22
|
} from './install-pstack-plugin.mjs';
|
|
22
23
|
|
|
@@ -500,3 +501,25 @@ test('should delete a half-made marketplace clone before the shared marketplace
|
|
|
500
501
|
test('should report no removal when no stale marketplace clone exists', () => {
|
|
501
502
|
assert.equal(removeStaleMarketplaceClone(join(tmpdir(), 'cde-no-such-clone')), false);
|
|
502
503
|
});
|
|
504
|
+
|
|
505
|
+
test('an install prints one restart notice that names each plugin and host', () => {
|
|
506
|
+
const allLoggedLines = [];
|
|
507
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
|
|
508
|
+
...recordingRunner(),
|
|
509
|
+
writeLine: line => allLoggedLines.push(line),
|
|
510
|
+
});
|
|
511
|
+
assert.deepEqual(allLoggedLines, [restartNotice([PSTACK_PLUGIN_SPEC], ['claude', 'codex'])]);
|
|
512
|
+
assert.equal(outcome.restartNotice, allLoggedLines[0]);
|
|
513
|
+
assert.match(allLoggedLines[0], /Restart each open claude and codex session to load Pstack\./);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('a run that installs nothing prints no restart notice', () => {
|
|
517
|
+
const allLoggedLines = [];
|
|
518
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
|
|
519
|
+
...recordingRunner(() => ({ status: null, error: Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) })),
|
|
520
|
+
writeLine: line => allLoggedLines.push(line),
|
|
521
|
+
});
|
|
522
|
+
assert.equal(outcome.status, 'skipped');
|
|
523
|
+
assert.deepEqual(allLoggedLines, []);
|
|
524
|
+
assert.equal(outcome.restartNotice, null);
|
|
525
|
+
});
|
|
@@ -35,11 +35,12 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
|
|
|
35
35
|
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
|
|
36
36
|
|
|
37
37
|
const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
|
|
38
|
-
assert.equal(userPromptHooks.length,
|
|
38
|
+
assert.equal(userPromptHooks.length, 5);
|
|
39
39
|
assert.equal(userPromptHooks[0].command, userCommand);
|
|
40
40
|
assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
|
|
41
41
|
assert.match(userPromptHooks[2].command, /lifecycle\/nested_project_hooks\.py$/);
|
|
42
42
|
assert.match(userPromptHooks[3].command, /blocking\/chat_reply_gate\.py$/);
|
|
43
|
+
assert.match(userPromptHooks[4].command, /session\/plugin_change_warning\.py$/);
|
|
43
44
|
assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
|
|
44
45
|
|
|
45
46
|
const firstMerge = JSON.stringify(settings);
|
|
@@ -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
|
|
|
@@ -10,6 +10,8 @@ This family injects reminders and task guidance when an agent starts, resumes, s
|
|
|
10
10
|
- `session/working_style_prompt.py` injects the session's working style at session start.
|
|
11
11
|
- `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
|
|
12
12
|
- `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
|
|
13
|
+
- `session/plugin_session_baseline_writer.py` records each plugin's `version` and `installPath` from `installed_plugins.json` to a per-session file in the system temp folder. It writes on `startup`, or when the session has no baseline yet, and leaves an existing baseline alone on `resume`, `clear`, and `compact`.
|
|
14
|
+
- `session/plugin_change_warning.py` compares `installed_plugins.json` with that baseline before each prompt. It names each plugin whose version or install path changed, or that the baseline lacks, once per session, and tells the user to restart Claude Code.
|
|
13
15
|
- `session/artifact_template_pointer.py` routes an agent that starts an artifact page by scope. A status or progress report prefers the status flowchart from `docs/templates/artifact-page/status-flowchart.html`, unless the person asks for another shape. One flow or one comparison is an inline SVG flowchart with minimal text. A multi-part plan or evidence is an html-plan page from `docs/templates/html-plan/README.md`. When the request names the older artifact page template, it points at `docs/templates/artifact-page/README.md`, which names the tracker template and the diagram template and which ask each fits.
|
|
14
16
|
- `advisory/auto_mode_denial_quick_fix.py` proposes one `autoMode.allow` entry and a PowerShell block that writes it after an auto mode denial. It never retries the denied call.
|
|
15
17
|
|
|
@@ -18,6 +20,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
|
|
|
18
20
|
- `session/skill_loaded_reminder.py` runs on `PreToolUse` matcher `Agent|Task` at `10` seconds, `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
|
|
19
21
|
- `session/compaction_recall_prompt.py` runs on `SessionStart` matcher `compact` at `10` seconds in `hooks.json`. The installer also writes it to the Codex `hooks.json`, the only `SessionStart` hook Codex gets.
|
|
20
22
|
- `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, and `session/orchestrator_auto_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
|
|
23
|
+
- `session/plugin_session_baseline_writer.py` runs on `SessionStart`, matcher empty, at `10` seconds, so it sees every source. `session/plugin_change_warning.py` runs on `UserPromptSubmit`, matcher empty, at `10` seconds in `hooks.json`.
|
|
21
24
|
- `session/artifact_template_pointer.py` runs on `PreToolUse` matcher `Artifact|Skill` at `10` seconds in `hooks.json`. It speaks only on an `Artifact` call with action `quickstart` or a `Skill` call that loads `artifact-design`.
|
|
22
25
|
- `advisory/auto_mode_denial_quick_fix.py` runs on `PermissionDenied`, matcher `*`, timeout `10` seconds in `hooks.json`.
|
|
23
26
|
|
|
@@ -33,6 +36,7 @@ Preconditions:
|
|
|
33
36
|
- **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
|
|
34
37
|
- **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
|
|
35
38
|
- **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
|
|
39
|
+
- **Plugin restart warning.** Input is a SessionStart event, an `installed_plugins.json` change, and a prompt. Run `python -m pytest packages/claude-dev-env/hooks/session/test_plugin_session_baseline_writer.py packages/claude-dev-env/hooks/session/test_plugin_change_warning.py packages/claude-dev-env/hooks/test_plugin_session_baseline.py -q`. The tests observe one warning for a changed version, a changed install path, and a plugin new since start, silence on a `lastUpdated` refresh and on a second prompt, and a warning that still fires after a `compact` SessionStart.
|
|
36
40
|
- **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the status flowchart path first, the html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
|
|
37
41
|
- **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
|
|
38
42
|
|
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": [
|
|
@@ -303,6 +313,11 @@
|
|
|
303
313
|
"type": "command",
|
|
304
314
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
|
|
305
315
|
"timeout": 10
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"type": "command",
|
|
319
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_change_warning.py",
|
|
320
|
+
"timeout": 10
|
|
306
321
|
}
|
|
307
322
|
]
|
|
308
323
|
}
|
|
@@ -346,6 +361,11 @@
|
|
|
346
361
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/orchestrator_auto_starter.py",
|
|
347
362
|
"timeout": 10
|
|
348
363
|
},
|
|
364
|
+
{
|
|
365
|
+
"type": "command",
|
|
366
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_session_baseline_writer.py",
|
|
367
|
+
"timeout": 10
|
|
368
|
+
},
|
|
349
369
|
{
|
|
350
370
|
"type": "command",
|
|
351
371
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Constants for the plugin session baseline and the plugin change warning hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"BASELINE_PLUGINS_KEY",
|
|
7
|
+
"BASELINE_RECORD_FILE_TEMPLATE",
|
|
8
|
+
"BASELINE_RECORD_TEMPORARY_PREFIX",
|
|
9
|
+
"BASELINE_TEXT_ENCODING",
|
|
10
|
+
"BASELINE_WARNED_KEY",
|
|
11
|
+
"CLAUDE_CONFIG_DIR_ENV_VAR",
|
|
12
|
+
"DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME",
|
|
13
|
+
"INSTALLED_PLUGINS_FILE_NAME",
|
|
14
|
+
"INSTALLED_PLUGINS_RECORD_KEY",
|
|
15
|
+
"INSTALL_PATH_FIELD",
|
|
16
|
+
"PLUGINS_DIRECTORY_NAME",
|
|
17
|
+
"PLUGIN_CHANGE_WARNING_TEMPLATE",
|
|
18
|
+
"PLUGIN_ID_SEPARATOR",
|
|
19
|
+
"SESSION_ID_FIELD",
|
|
20
|
+
"SESSION_SOURCE_FIELD",
|
|
21
|
+
"STARTUP_SOURCE",
|
|
22
|
+
"USER_PROMPT_SUBMIT_EVENT_NAME",
|
|
23
|
+
"VERSION_FIELD",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
CLAUDE_CONFIG_DIR_ENV_VAR = "CLAUDE_CONFIG_DIR"
|
|
27
|
+
DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME = ".claude"
|
|
28
|
+
PLUGINS_DIRECTORY_NAME = "plugins"
|
|
29
|
+
INSTALLED_PLUGINS_FILE_NAME = "installed_plugins.json"
|
|
30
|
+
INSTALLED_PLUGINS_RECORD_KEY = "plugins"
|
|
31
|
+
VERSION_FIELD = "version"
|
|
32
|
+
INSTALL_PATH_FIELD = "installPath"
|
|
33
|
+
|
|
34
|
+
SESSION_ID_FIELD = "session_id"
|
|
35
|
+
SESSION_SOURCE_FIELD = "source"
|
|
36
|
+
STARTUP_SOURCE = "startup"
|
|
37
|
+
USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
|
|
38
|
+
|
|
39
|
+
BASELINE_RECORD_FILE_TEMPLATE = "plugin-session-baseline-{session_file_id}.json"
|
|
40
|
+
BASELINE_RECORD_TEMPORARY_PREFIX = "plugin-session-baseline-"
|
|
41
|
+
BASELINE_TEXT_ENCODING = "utf-8"
|
|
42
|
+
BASELINE_PLUGINS_KEY = "plugins"
|
|
43
|
+
BASELINE_WARNED_KEY = "warned"
|
|
44
|
+
|
|
45
|
+
PLUGIN_ID_SEPARATOR = ", "
|
|
46
|
+
PLUGIN_CHANGE_WARNING_TEMPLATE = (
|
|
47
|
+
"Plugin files changed after this session started: {plugin_ids}."
|
|
48
|
+
" This session keeps the plugin versions it loaded at start, so their new skills,"
|
|
49
|
+
" commands, and hooks stay unavailable here. Restart Claude Code to load them."
|
|
50
|
+
)
|
|
@@ -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
|
+
)
|