claude-dev-env 8.43.3 → 8.43.5
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.mjs +39 -3
- package/bin/install.shared-settings.test.mjs +46 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +23 -1
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +27 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +6 -1
- package/package.json +1 -1
- package/scripts/policy_lint/config/approved_test_pairs.py +1 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +13 -0
package/bin/install.mjs
CHANGED
|
@@ -1363,15 +1363,16 @@ function commandRunsHookOfRootSharingSettings(normalizedCommand, relativePath, s
|
|
|
1363
1363
|
while (tailStart !== -1) {
|
|
1364
1364
|
const characterAfterPath = normalizedCommand[tailStart + hookPathTail.length];
|
|
1365
1365
|
if (characterAfterPath === undefined || commandArgumentBoundary.test(characterAfterPath)) {
|
|
1366
|
-
let
|
|
1366
|
+
let rootPath;
|
|
1367
1367
|
if (characterAfterPath === '"' || characterAfterPath === "'") {
|
|
1368
|
-
|
|
1368
|
+
rootPath = quotedRootPathBeforeTail(normalizedCommand.slice(0, tailStart), characterAfterPath);
|
|
1369
1369
|
} else {
|
|
1370
|
+
let rootStart = tailStart;
|
|
1370
1371
|
while (rootStart > 0 && !commandArgumentBoundary.test(normalizedCommand[rootStart - 1])) {
|
|
1371
1372
|
rootStart--;
|
|
1372
1373
|
}
|
|
1374
|
+
rootPath = normalizedCommand.slice(rootStart, tailStart);
|
|
1373
1375
|
}
|
|
1374
|
-
const rootPath = normalizedCommand.slice(rootStart, tailStart);
|
|
1375
1376
|
const rootSettingsRealPath = rootPath
|
|
1376
1377
|
? realPathOrNull(join(rootPath, SETTINGS_FILE_NAME))
|
|
1377
1378
|
: null;
|
|
@@ -1384,6 +1385,41 @@ function commandRunsHookOfRootSharingSettings(normalizedCommand, relativePath, s
|
|
|
1384
1385
|
return false;
|
|
1385
1386
|
}
|
|
1386
1387
|
|
|
1388
|
+
/**
|
|
1389
|
+
* How each quote style of a hook path argument joins the segments around an
|
|
1390
|
+
* escaped character, keyed by the quote, in the forward-slash command form.
|
|
1391
|
+
*/
|
|
1392
|
+
const QUOTED_ARGUMENT_ESCAPES = {
|
|
1393
|
+
'"': { segmentJoin: '"^%"', joinedCharacter: '%' },
|
|
1394
|
+
"'": { segmentJoin: "'/''", joinedCharacter: "'" },
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Decode the root part of a quoted argument that `commandArgumentFromPath`
|
|
1399
|
+
* wrote, read from the command text that ends where the hook path tail starts.
|
|
1400
|
+
*
|
|
1401
|
+
* The win32 form splits a path at each `%` into `"a"^%"b"`. The POSIX form
|
|
1402
|
+
* escapes each `'` as `'\''`, which reads `'/''` once backslashes become
|
|
1403
|
+
* forward slashes.
|
|
1404
|
+
*
|
|
1405
|
+
* @param {string} commandBeforeTail Normalized command text up to the hook path tail.
|
|
1406
|
+
* @param {string} closingQuote The quote that closes the argument after the tail.
|
|
1407
|
+
* @returns {string} The root path with each escaped character restored.
|
|
1408
|
+
*/
|
|
1409
|
+
function quotedRootPathBeforeTail(commandBeforeTail, closingQuote) {
|
|
1410
|
+
const { segmentJoin, joinedCharacter } = QUOTED_ARGUMENT_ESCAPES[closingQuote];
|
|
1411
|
+
const segments = [];
|
|
1412
|
+
let segmentEnd = commandBeforeTail.length;
|
|
1413
|
+
let openingQuote = commandBeforeTail.lastIndexOf(closingQuote, segmentEnd - 1);
|
|
1414
|
+
while (commandBeforeTail.slice(openingQuote - segmentJoin.length + 1, openingQuote + 1) === segmentJoin) {
|
|
1415
|
+
segments.unshift(commandBeforeTail.slice(openingQuote + 1, segmentEnd));
|
|
1416
|
+
segmentEnd = openingQuote - segmentJoin.length + 1;
|
|
1417
|
+
openingQuote = commandBeforeTail.lastIndexOf(closingQuote, segmentEnd - 1);
|
|
1418
|
+
}
|
|
1419
|
+
segments.unshift(commandBeforeTail.slice(openingQuote + 1, segmentEnd));
|
|
1420
|
+
return segments.join(joinedCharacter);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1387
1423
|
function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
|
|
1388
1424
|
if (commandHoldsPathAtBoundary(normalizedCommand, `/.claude/hooks/${relativePath}`)) {
|
|
1389
1425
|
return true;
|
|
@@ -243,3 +243,49 @@ test('the merge prunes a quoted hook of a sharing root whose path holds a space'
|
|
|
243
243
|
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
244
244
|
}
|
|
245
245
|
});
|
|
246
|
+
|
|
247
|
+
for (const { shape, rootDirectoryName, platform } of [
|
|
248
|
+
{ shape: 'a percent sign', rootDirectoryName: 'Jane 100% Doe', platform: 'win32' },
|
|
249
|
+
{ shape: 'an apostrophe', rootDirectoryName: "Jane O'Doe", platform: 'linux' },
|
|
250
|
+
]) {
|
|
251
|
+
test(`the merge prunes a ${platform}-quoted hook of a sharing root whose path holds ${shape}`, t => {
|
|
252
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-shared-settings-escape-'));
|
|
253
|
+
try {
|
|
254
|
+
const installRoot = join(sandboxRoot, 'install-root');
|
|
255
|
+
const sharingRoot = join(sandboxRoot, rootDirectoryName, 'profiles', 'second');
|
|
256
|
+
for (const eachRoot of [installRoot, sharingRoot]) mkdirSync(eachRoot, { recursive: true });
|
|
257
|
+
const settingsPath = join(installRoot, 'settings.json');
|
|
258
|
+
writeFileSync(settingsPath, '{}\n');
|
|
259
|
+
try {
|
|
260
|
+
symlinkSync(settingsPath, join(sharingRoot, 'settings.json'), 'file');
|
|
261
|
+
} catch (linkError) {
|
|
262
|
+
t.skip(`this host cannot create a file symlink (${linkError.code})`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const hooksConfig = {
|
|
266
|
+
hooks: {
|
|
267
|
+
SessionStart: [{
|
|
268
|
+
matcher: '',
|
|
269
|
+
hooks: [{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/start.py' }],
|
|
270
|
+
}],
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
const forwardSlashed = rootPath => rootPath.replace(/\\/g, '/');
|
|
274
|
+
const sharingHookPath = `${forwardSlashed(sharingRoot)}/hooks/session/start.py`;
|
|
275
|
+
const sharingCommand = `python3 ${commandArgumentFromPath(sharingHookPath, platform)}`;
|
|
276
|
+
const settings = {
|
|
277
|
+
hooks: {
|
|
278
|
+
SessionStart: [{ matcher: '', hooks: [{ type: 'command', command: sharingCommand }] }],
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
mergeHooksIntoSettings(settings, hooksConfig, forwardSlashed(installRoot), 'python3', settingsPath);
|
|
283
|
+
|
|
284
|
+
const allCommands = settings.hooks.SessionStart.flatMap(group => group.hooks.map(hook => hook.command));
|
|
285
|
+
assert.ok(!allCommands.includes(sharingCommand), `the sharing root entry ${sharingCommand} is pruned`);
|
|
286
|
+
assert.equal(allCommands.length, 1, 'only the install root entry stays after the merge');
|
|
287
|
+
} finally {
|
|
288
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
@@ -17,6 +17,8 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
17
17
|
ALL_COMMAND_PREFIX_WORDS,
|
|
18
18
|
ALL_GITHUB_MCP_TOOL_SUFFIXES,
|
|
19
19
|
ALL_PREFIX_OPTIONS_WITH_VALUE,
|
|
20
|
+
ALL_PYTHON_EXECUTABLES,
|
|
21
|
+
ALL_SHELL_WRAPPER_EXECUTABLES,
|
|
20
22
|
ALL_SLASH_COMMAND_MARKERS,
|
|
21
23
|
ALL_TRANSCRIPT_PATH_FIELDS,
|
|
22
24
|
COMMAND_SEPARATORS,
|
|
@@ -33,8 +35,11 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
33
35
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
34
36
|
MERGE_PATH_SUFFIX,
|
|
35
37
|
OPTION_AND_VALUE_WORD_COUNT,
|
|
38
|
+
PATH_SEPARATOR_PATTERN,
|
|
36
39
|
PERMISSION_DECISION_KEY,
|
|
37
40
|
PERMISSION_DECISION_REASON_KEY,
|
|
41
|
+
PULL_REQUEST_SCRIPT_NAME,
|
|
42
|
+
PYTHON_OPTIONS_WITH_VALUE,
|
|
38
43
|
SHELL_TOOL_NAMES,
|
|
39
44
|
SKILL_NAME,
|
|
40
45
|
)
|
|
@@ -117,12 +122,29 @@ def _words_from_executable(all_words: list[str]) -> list[str]:
|
|
|
117
122
|
return []
|
|
118
123
|
|
|
119
124
|
|
|
125
|
+
def _file_name(path_word: str) -> str:
|
|
126
|
+
return re.split(PATH_SEPARATOR_PATTERN, path_word)[-1].lower()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _runs_pull_request_script(all_words: list[str]) -> bool:
|
|
130
|
+
script, _ = _next_command_word(all_words, 1, PYTHON_OPTIONS_WITH_VALUE)
|
|
131
|
+
return script is not None and _file_name(script) == PULL_REQUEST_SCRIPT_NAME
|
|
132
|
+
|
|
133
|
+
|
|
120
134
|
def _matches_shell_command(command: str) -> bool:
|
|
121
135
|
for each_segment in _command_segments(command):
|
|
122
136
|
all_command_words = _words_from_executable(each_segment)
|
|
123
137
|
if not all_command_words:
|
|
124
138
|
continue
|
|
125
|
-
executable = all_command_words[0]
|
|
139
|
+
executable = _file_name(all_command_words[0])
|
|
140
|
+
if executable == PULL_REQUEST_SCRIPT_NAME:
|
|
141
|
+
return True
|
|
142
|
+
if executable in ALL_PYTHON_EXECUTABLES and _runs_pull_request_script(all_command_words):
|
|
143
|
+
return True
|
|
144
|
+
if executable in ALL_SHELL_WRAPPER_EXECUTABLES and any(
|
|
145
|
+
_matches_shell_command(each_argument) for each_argument in all_command_words[1:]
|
|
146
|
+
):
|
|
147
|
+
return True
|
|
126
148
|
if executable in {"git", "git.exe"} and _is_git_action(all_command_words):
|
|
127
149
|
return True
|
|
128
150
|
if executable in {"gh", "gh.exe"} and _is_gh_action(all_command_words):
|
|
@@ -128,6 +128,33 @@ def test_non_action_command_shapes(tmp_path: Path) -> None:
|
|
|
128
128
|
assert gate.decision_for(_payload(command, path)) is None, command
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
def test_wrapped_and_scripted_action_shapes_are_denied(tmp_path: Path) -> None:
|
|
132
|
+
path = _transcript(tmp_path)
|
|
133
|
+
commands = (
|
|
134
|
+
'bash -c "git push"',
|
|
135
|
+
"sh -lc 'gh pr create'",
|
|
136
|
+
'pwsh -NoProfile -Command "git commit -F message.txt"',
|
|
137
|
+
"echo `git push`",
|
|
138
|
+
"python ~/.agents/skills/pull-request/scripts/pull_request.py create --title-file t.txt",
|
|
139
|
+
"python3 -X utf8 'C:\\repo\\scripts\\pull_request.py' edit",
|
|
140
|
+
)
|
|
141
|
+
for command in commands:
|
|
142
|
+
_assert_denied(_payload(command, path))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_wrapped_and_scripted_non_action_shapes_are_allowed(tmp_path: Path) -> None:
|
|
146
|
+
path = _transcript(tmp_path)
|
|
147
|
+
commands = (
|
|
148
|
+
'bash -c "git status"',
|
|
149
|
+
"bash script.sh",
|
|
150
|
+
"cat scripts/pull_request.py",
|
|
151
|
+
"python -m pytest scripts/test_pull_request.py",
|
|
152
|
+
"echo `git log`",
|
|
153
|
+
)
|
|
154
|
+
for command in commands:
|
|
155
|
+
assert gate.decision_for(_payload(command, path)) is None, command
|
|
156
|
+
|
|
157
|
+
|
|
131
158
|
def test_matching_github_mcp_tools_are_denied(tmp_path: Path) -> None:
|
|
132
159
|
path = _transcript(tmp_path)
|
|
133
160
|
for suffix in ("create_pull_request", "merge_pull_request", "enable_pr_auto_merge", "update_pull_request"):
|
|
@@ -18,7 +18,7 @@ ALL_GITHUB_MCP_TOOL_SUFFIXES = (
|
|
|
18
18
|
"__update_pull_request",
|
|
19
19
|
)
|
|
20
20
|
ALL_TRANSCRIPT_PATH_FIELDS = ("transcript_path", "agent_transcript_path")
|
|
21
|
-
COMMAND_SEPARATORS = ";|&\n()"
|
|
21
|
+
COMMAND_SEPARATORS = ";|&\n()`"
|
|
22
22
|
COMMAND_WHITESPACE = " \t\r"
|
|
23
23
|
OPTION_AND_VALUE_WORD_COUNT = 2
|
|
24
24
|
GIT_GLOBAL_OPTIONS_WITH_VALUE = frozenset({"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"})
|
|
@@ -31,3 +31,8 @@ ALL_API_ACTION_NAMES = ("auto-merge", "enablePullRequestAutoMerge")
|
|
|
31
31
|
ALL_COMMAND_PREFIX_WORDS = frozenset({"env", "sudo", "command", "exec", "nohup", "time"})
|
|
32
32
|
ALL_PREFIX_OPTIONS_WITH_VALUE = frozenset({"-u", "--unset", "-C", "--chdir", "-g", "--group", "--user"})
|
|
33
33
|
ENVIRONMENT_ASSIGNMENT_PATTERN = r"^[A-Za-z_][A-Za-z0-9_]*="
|
|
34
|
+
ALL_SHELL_WRAPPER_EXECUTABLES = frozenset({"bash", "sh", "zsh", "dash", "pwsh", "pwsh.exe", "powershell", "powershell.exe"})
|
|
35
|
+
ALL_PYTHON_EXECUTABLES = frozenset({"python", "python3", "python.exe", "python3.exe", "py", "py.exe"})
|
|
36
|
+
PULL_REQUEST_SCRIPT_NAME = "pull_request.py"
|
|
37
|
+
PATH_SEPARATOR_PATTERN = r"[\\/]"
|
|
38
|
+
PYTHON_OPTIONS_WITH_VALUE = frozenset({"-X", "-W"})
|
package/package.json
CHANGED
|
@@ -23,6 +23,7 @@ APPROVED_TEST_PATHS_BY_PRODUCTION_PATH: dict[
|
|
|
23
23
|
{
|
|
24
24
|
PurePosixPath("packages/claude-dev-env/bin/install.agents-home.test.mjs"),
|
|
25
25
|
PurePosixPath("packages/claude-dev-env/bin/install.cursor-rules.test.mjs"),
|
|
26
|
+
PurePosixPath("packages/claude-dev-env/bin/install.shared-settings.test.mjs"),
|
|
26
27
|
}
|
|
27
28
|
),
|
|
28
29
|
PurePosixPath(
|
|
@@ -557,6 +557,19 @@ def test_pairing_accepts_installer_module_with_the_agents_home_suite(
|
|
|
557
557
|
assert all_paths == ()
|
|
558
558
|
|
|
559
559
|
|
|
560
|
+
def test_pairing_accepts_installer_module_with_the_shared_settings_suite(
|
|
561
|
+
tmp_path: Path,
|
|
562
|
+
) -> None:
|
|
563
|
+
all_paths = _diagnostic_paths(
|
|
564
|
+
tmp_path,
|
|
565
|
+
_body_change_at(_INSTALLER_MODULE_PATH),
|
|
566
|
+
_body_change_at(
|
|
567
|
+
PurePosixPath("packages/claude-dev-env/bin/install.shared-settings.test.mjs")
|
|
568
|
+
),
|
|
569
|
+
)
|
|
570
|
+
assert all_paths == ()
|
|
571
|
+
|
|
572
|
+
|
|
560
573
|
_COMMENT_RULES_PRODUCTION_PATH = PurePosixPath(
|
|
561
574
|
"packages/claude-dev-env/hooks/blocking/code_rules_comments.py"
|
|
562
575
|
)
|