claude-dev-env 8.43.2 → 8.43.4
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/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/dev_env_scripts_constants/account_broker_constants.py +11 -4
- package/scripts/test_account_broker.py +22 -0
|
@@ -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
|
@@ -224,9 +224,13 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
|
|
|
224
224
|
return None
|
|
225
225
|
if is_spent:
|
|
226
226
|
return min((reset for reset in (meters.session_resets_at, meters.weekly_resets_at) if reset is not None), default=None)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
is_claude_main = reading.account.product is Product.CLAUDE and reading.account.is_main
|
|
228
|
+
if is_claude_main:
|
|
229
|
+
blocking = (
|
|
230
|
+
(meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"], meters.session_resets_at),
|
|
231
|
+
(meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"], meters.weekly_resets_at),
|
|
232
|
+
)
|
|
233
|
+
elif reading.account.product is Product.CLAUDE:
|
|
230
234
|
blocking = (
|
|
231
235
|
(meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"], meters.session_resets_at),
|
|
232
236
|
(meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"], meters.weekly_resets_at),
|
|
@@ -236,7 +240,10 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
|
|
|
236
240
|
(meters.session_percent_left, ALL_CODEX_FLOORS["luna_short_minimum_left"], meters.session_resets_at),
|
|
237
241
|
(meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"], meters.weekly_resets_at),
|
|
238
242
|
)
|
|
239
|
-
|
|
243
|
+
all_blocking_resets = [reset for room, floor, reset in blocking if room is not None and room <= floor and reset is not None]
|
|
244
|
+
if is_claude_main and meters.weekly_resets_at is not None:
|
|
245
|
+
all_blocking_resets.append(meters.weekly_resets_at - ALL_CLAUDE_FLOORS["main_spend_window"])
|
|
246
|
+
return max(all_blocking_resets, default=None)
|
|
240
247
|
|
|
241
248
|
|
|
242
249
|
def _rank_key(reading: Reading) -> float:
|
|
@@ -127,6 +127,28 @@ def test_should_prioritize_main_when_its_guard_passes() -> None:
|
|
|
127
127
|
assert decision.account == main.account
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
@pytest.mark.parametrize(
|
|
131
|
+
("short_left", "weekly_left", "weekly_reset", "expected_reset"),
|
|
132
|
+
(
|
|
133
|
+
(40, 20, NOW + timedelta(hours=4), NOW + timedelta(hours=2)),
|
|
134
|
+
(80, 5, NOW + timedelta(hours=4), NOW + timedelta(hours=4)),
|
|
135
|
+
(80, 50, NOW + timedelta(days=3), NOW + timedelta(days=2)),
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
def test_should_wait_for_the_meter_or_window_that_blocks_main(
|
|
139
|
+
short_left: float, weekly_left: float, weekly_reset: datetime, expected_reset: datetime
|
|
140
|
+
) -> None:
|
|
141
|
+
main = Reading(
|
|
142
|
+
_account("main", Product.CLAUDE, main=True),
|
|
143
|
+
_meters(short_left, weekly_left, short_reset=NOW + timedelta(hours=2), weekly_reset=weekly_reset),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
decision = choose_from_readings(Product.CLAUDE, (main,), now=NOW)
|
|
147
|
+
|
|
148
|
+
assert decision.action == "wait"
|
|
149
|
+
assert decision.resets_at == expected_reset
|
|
150
|
+
|
|
151
|
+
|
|
130
152
|
def test_should_try_next_account_after_usage_limit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
131
153
|
accounts = (_account("first"), _account("second"))
|
|
132
154
|
adapter = _adapter(accounts, {"first": _meters(80, 80), "second": _meters(70, 70)})
|