claude-dev-env 8.52.0 → 8.53.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/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +12 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +93 -6
- package/_shared/process-tree/scripts/test_process_tree_kill.py +149 -0
- package/hooks/advisory/auto_mode_denial_quick_fix.py +156 -0
- package/hooks/advisory/test_auto_mode_denial_quick_fix.py +113 -0
- package/hooks/hooks.json +12 -0
- package/hooks/hooks_constants/auto_mode_denial_quick_fix_constants.py +79 -0
- package/package.json +1 -1
- package/scripts/account_broker_support.py +22 -6
- package/scripts/test_account_broker_support.py +83 -24
|
@@ -25,3 +25,15 @@ PROCESS_TREE_KILL_TIMEOUT_SECONDS: int = 10
|
|
|
25
25
|
Gates the kill command alone. Each caller sets its own bound on the drain that
|
|
26
26
|
follows the kill.
|
|
27
27
|
"""
|
|
28
|
+
|
|
29
|
+
PROCESS_TREE_EXIT_WAIT_SECONDS: float = 2.0
|
|
30
|
+
"""Seconds ``terminate_process_tree`` waits for the signalled POSIX group to exit.
|
|
31
|
+
|
|
32
|
+
A SIGKILL takes effect when the kernel next schedules each target, so a
|
|
33
|
+
descendant can still run for a moment after ``killpg`` returns. The wait ends
|
|
34
|
+
as soon as no group member is still running; the bound applies only when a
|
|
35
|
+
member survived the signal.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
PROCESS_TREE_EXIT_POLL_SECONDS: float = 0.01
|
|
39
|
+
"""Pause between checks for running members of the signalled process group."""
|
|
@@ -25,6 +25,7 @@ import os
|
|
|
25
25
|
import signal
|
|
26
26
|
import subprocess
|
|
27
27
|
import sys
|
|
28
|
+
import time
|
|
28
29
|
from pathlib import Path
|
|
29
30
|
|
|
30
31
|
_config_directory = str(Path(__file__).resolve().parent / "config")
|
|
@@ -32,6 +33,8 @@ if _config_directory not in sys.path:
|
|
|
32
33
|
sys.path.insert(0, _config_directory)
|
|
33
34
|
|
|
34
35
|
from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
|
|
36
|
+
PROCESS_TREE_EXIT_POLL_SECONDS,
|
|
37
|
+
PROCESS_TREE_EXIT_WAIT_SECONDS,
|
|
35
38
|
PROCESS_TREE_KILL_TIMEOUT_SECONDS,
|
|
36
39
|
WINDOWS_TASKKILL_COMMAND,
|
|
37
40
|
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
@@ -126,19 +129,103 @@ def terminate_process_tree(process: subprocess.Popen[str]) -> None:
|
|
|
126
129
|
tree kill takes ok: grandchildren die, pipes close
|
|
127
130
|
tree kill misses the child ok: Popen.kill() ends the direct child
|
|
128
131
|
child exits mid-kill ok: the raised lookup error is swallowed
|
|
132
|
+
grandchild still running ok: returns once the POSIX group has exited
|
|
129
133
|
|
|
130
134
|
Falls back to ``Popen.kill()`` when the direct child survives the tree kill,
|
|
131
|
-
so the caller never waits on a live process.
|
|
135
|
+
so the caller never waits on a live process. On POSIX it then waits, bounded
|
|
136
|
+
by ``PROCESS_TREE_EXIT_WAIT_SECONDS``, until no group member is running, so
|
|
137
|
+
a descendant cannot touch files after this call returns.
|
|
132
138
|
|
|
133
139
|
Args:
|
|
134
140
|
process: The process whose tree is ended.
|
|
135
141
|
"""
|
|
136
142
|
if process.poll() is not None:
|
|
137
143
|
return
|
|
144
|
+
process_group_identifier = _posix_process_group(process.pid)
|
|
138
145
|
kill_process_tree_by_identifier(process.pid)
|
|
139
|
-
if process.poll() is
|
|
140
|
-
|
|
146
|
+
if process.poll() is None:
|
|
147
|
+
try:
|
|
148
|
+
process.kill()
|
|
149
|
+
except ProcessLookupError:
|
|
150
|
+
pass
|
|
151
|
+
if process_group_identifier is not None:
|
|
152
|
+
_wait_for_process_group_exit(process, process_group_identifier)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _posix_process_group(process_identifier: int) -> int | None:
|
|
156
|
+
if sys.platform == "win32":
|
|
157
|
+
return None
|
|
141
158
|
try:
|
|
142
|
-
|
|
143
|
-
except
|
|
144
|
-
return
|
|
159
|
+
return os.getpgid(process_identifier)
|
|
160
|
+
except OSError:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _wait_for_process_group_exit(
|
|
165
|
+
process: subprocess.Popen[str], process_group_identifier: int
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Block until no member of the signalled group is still running, bounded.
|
|
168
|
+
|
|
169
|
+
``killpg`` returns once the signal is queued, and each target dies only when
|
|
170
|
+
the kernel next schedules it. A grandchild preempted inside a system call
|
|
171
|
+
finishes that call first, so it can still truncate or write a file after
|
|
172
|
+
the caller has moved on. Waiting here keeps that work inside the call.
|
|
173
|
+
"""
|
|
174
|
+
deadline = time.monotonic() + PROCESS_TREE_EXIT_WAIT_SECONDS
|
|
175
|
+
while running_process_group_members(process, process_group_identifier):
|
|
176
|
+
if time.monotonic() >= deadline:
|
|
177
|
+
return
|
|
178
|
+
time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def running_process_group_members(
|
|
182
|
+
process: subprocess.Popen[str], process_group_identifier: int
|
|
183
|
+
) -> list[int]:
|
|
184
|
+
"""List the process ids in a POSIX group that have not exited yet.
|
|
185
|
+
|
|
186
|
+
Reads ``/proc`` where it exists, which tells a running member from a zombie
|
|
187
|
+
waiting on its reaper. Elsewhere it reaps the direct child and asks the
|
|
188
|
+
kernel whether the group still has any member.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
process: The direct child that leads the group.
|
|
192
|
+
process_group_identifier: The group the direct child leads.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Process ids still running, or an empty list once the group has exited.
|
|
196
|
+
"""
|
|
197
|
+
if sys.platform == "win32":
|
|
198
|
+
return []
|
|
199
|
+
proc_directory = Path("/proc")
|
|
200
|
+
if proc_directory.is_dir():
|
|
201
|
+
return _running_members_from_proc(proc_directory, process_group_identifier)
|
|
202
|
+
try:
|
|
203
|
+
process.wait(timeout=PROCESS_TREE_EXIT_POLL_SECONDS)
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
return [process.pid]
|
|
206
|
+
try:
|
|
207
|
+
os.killpg(process_group_identifier, 0)
|
|
208
|
+
except OSError:
|
|
209
|
+
return []
|
|
210
|
+
return [process_group_identifier]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _running_members_from_proc(
|
|
214
|
+
proc_directory: Path, process_group_identifier: int
|
|
215
|
+
) -> list[int]:
|
|
216
|
+
all_running: list[int] = []
|
|
217
|
+
for each_entry in proc_directory.iterdir():
|
|
218
|
+
if not each_entry.name.isdigit():
|
|
219
|
+
continue
|
|
220
|
+
try:
|
|
221
|
+
stat_text = (each_entry / "stat").read_text(
|
|
222
|
+
encoding="ascii", errors="replace"
|
|
223
|
+
)
|
|
224
|
+
except OSError:
|
|
225
|
+
continue
|
|
226
|
+
state, _parent_identifier, group_identifier, *_later_fields = stat_text.rsplit(
|
|
227
|
+
")", 1
|
|
228
|
+
)[1].split()
|
|
229
|
+
if int(group_identifier) == process_group_identifier and state != "Z":
|
|
230
|
+
all_running.append(int(each_entry.name))
|
|
231
|
+
return all_running
|
|
@@ -7,9 +7,12 @@ the taskkill argv it builds, the failures it swallows, and the
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import os
|
|
10
11
|
import subprocess
|
|
11
12
|
import sys
|
|
13
|
+
import time
|
|
12
14
|
from pathlib import Path
|
|
15
|
+
from typing import cast
|
|
13
16
|
|
|
14
17
|
import pytest
|
|
15
18
|
|
|
@@ -23,6 +26,8 @@ from process_tree_kill import ( # noqa: E402
|
|
|
23
26
|
terminate_process_tree,
|
|
24
27
|
)
|
|
25
28
|
from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
|
|
29
|
+
PROCESS_TREE_EXIT_POLL_SECONDS,
|
|
30
|
+
PROCESS_TREE_EXIT_WAIT_SECONDS,
|
|
26
31
|
PROCESS_TREE_KILL_TIMEOUT_SECONDS,
|
|
27
32
|
WINDOWS_TASKKILL_COMMAND,
|
|
28
33
|
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
@@ -276,3 +281,147 @@ def test_new_session_is_requested_off_windows_only(
|
|
|
276
281
|
|
|
277
282
|
_install_windows_platform(monkeypatch)
|
|
278
283
|
assert process_tree_kill.should_start_new_session() is False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _install_posix_group_wait(
|
|
287
|
+
monkeypatch: pytest.MonkeyPatch, all_member_snapshots: list[list[int]]
|
|
288
|
+
) -> list[float]:
|
|
289
|
+
"""Script what the group wait sees after the kill, and record each pause.
|
|
290
|
+
|
|
291
|
+
The last snapshot repeats once the list runs dry, so a never-empty script
|
|
292
|
+
models a group that outlives the deadline.
|
|
293
|
+
"""
|
|
294
|
+
all_sleeps: list[float] = []
|
|
295
|
+
remaining_snapshots = list(all_member_snapshots)
|
|
296
|
+
|
|
297
|
+
def scripted_members(process: object, process_group_identifier: int) -> list[int]:
|
|
298
|
+
del process, process_group_identifier
|
|
299
|
+
if len(remaining_snapshots) > 1:
|
|
300
|
+
return remaining_snapshots.pop(0)
|
|
301
|
+
return remaining_snapshots[0]
|
|
302
|
+
|
|
303
|
+
_install_posix_platform(monkeypatch)
|
|
304
|
+
monkeypatch.setattr(
|
|
305
|
+
process_tree_kill.os,
|
|
306
|
+
"getpgid",
|
|
307
|
+
lambda process_identifier: FAKE_PROCESS_GROUP_IDENTIFIER,
|
|
308
|
+
raising=False,
|
|
309
|
+
)
|
|
310
|
+
monkeypatch.setattr(
|
|
311
|
+
process_tree_kill.os,
|
|
312
|
+
"killpg",
|
|
313
|
+
lambda group_identifier, signal_number: None,
|
|
314
|
+
raising=False,
|
|
315
|
+
)
|
|
316
|
+
monkeypatch.setattr(
|
|
317
|
+
process_tree_kill, "running_process_group_members", scripted_members
|
|
318
|
+
)
|
|
319
|
+
monkeypatch.setattr(process_tree_kill.time, "sleep", all_sleeps.append)
|
|
320
|
+
return all_sleeps
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_terminate_waits_until_no_group_member_is_running(
|
|
324
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""The POSIX kill returns only once the signalled group has emptied.
|
|
327
|
+
|
|
328
|
+
::
|
|
329
|
+
|
|
330
|
+
members [7], [7], [] ok: two pauses, then return
|
|
331
|
+
no wait flag: a grandchild still inside a system call
|
|
332
|
+
writes after the caller has moved on
|
|
333
|
+
"""
|
|
334
|
+
all_sleeps = _install_posix_group_wait(monkeypatch, [[7], [7], []])
|
|
335
|
+
|
|
336
|
+
terminate_process_tree(_as_popen(_FakeProcess([None, 0])))
|
|
337
|
+
|
|
338
|
+
assert all_sleeps == [PROCESS_TREE_EXIT_POLL_SECONDS] * 2
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_terminate_stops_waiting_at_the_exit_deadline(
|
|
342
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
343
|
+
) -> None:
|
|
344
|
+
"""A group member that survives the signal does not hold the caller forever."""
|
|
345
|
+
all_sleeps = _install_posix_group_wait(monkeypatch, [[7]])
|
|
346
|
+
clock = iter(
|
|
347
|
+
[0.0, 0.0, PROCESS_TREE_EXIT_WAIT_SECONDS / 2, PROCESS_TREE_EXIT_WAIT_SECONDS]
|
|
348
|
+
)
|
|
349
|
+
monkeypatch.setattr(process_tree_kill.time, "monotonic", lambda: next(clock))
|
|
350
|
+
|
|
351
|
+
terminate_process_tree(_as_popen(_FakeProcess([None, 0])))
|
|
352
|
+
|
|
353
|
+
assert all_sleeps == [PROCESS_TREE_EXIT_POLL_SECONDS] * 2
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _as_popen(fake_process: _FakeProcess) -> subprocess.Popen[str]:
|
|
357
|
+
return cast("subprocess.Popen[str]", fake_process)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _group_led_by(process: subprocess.Popen[str]) -> int:
|
|
361
|
+
"""Name the group a session-leading child owns; Windows has none, so its pid."""
|
|
362
|
+
if process_tree_kill.should_start_new_session():
|
|
363
|
+
return os.getpgid(process.pid)
|
|
364
|
+
return process.pid
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_running_members_leave_out_a_zombie() -> None:
|
|
368
|
+
"""A member that died but awaits its reaper is not running.
|
|
369
|
+
|
|
370
|
+
::
|
|
371
|
+
|
|
372
|
+
child sleeping ok: listed on POSIX, [] on Windows
|
|
373
|
+
child killed, not yet reaped ok: dropped, so the wait ends
|
|
374
|
+
"""
|
|
375
|
+
sleeper = subprocess.Popen(
|
|
376
|
+
[sys.executable, "-c", "import time; time.sleep(60)"],
|
|
377
|
+
start_new_session=process_tree_kill.should_start_new_session(),
|
|
378
|
+
)
|
|
379
|
+
group_identifier = _group_led_by(sleeper)
|
|
380
|
+
all_expected_running = (
|
|
381
|
+
[sleeper.pid] if process_tree_kill.should_start_new_session() else []
|
|
382
|
+
)
|
|
383
|
+
try:
|
|
384
|
+
assert (
|
|
385
|
+
process_tree_kill.running_process_group_members(sleeper, group_identifier)
|
|
386
|
+
== all_expected_running
|
|
387
|
+
)
|
|
388
|
+
sleeper.kill()
|
|
389
|
+
deadline = time.monotonic() + PROCESS_TREE_EXIT_WAIT_SECONDS
|
|
390
|
+
while process_tree_kill.running_process_group_members(
|
|
391
|
+
sleeper, group_identifier
|
|
392
|
+
):
|
|
393
|
+
assert time.monotonic() < deadline, "the killed sleeper still counts"
|
|
394
|
+
time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
|
|
395
|
+
finally:
|
|
396
|
+
sleeper.wait()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_terminate_returns_with_the_grandchild_already_gone(tmp_path: Path) -> None:
|
|
400
|
+
"""Right after the kill returns, no process in the child's group still runs."""
|
|
401
|
+
heartbeat_file = tmp_path / "heartbeat.txt"
|
|
402
|
+
grandchild_source = (
|
|
403
|
+
"import pathlib, sys\n"
|
|
404
|
+
"heartbeat = pathlib.Path(sys.argv[1])\n"
|
|
405
|
+
"while True:\n"
|
|
406
|
+
" heartbeat.write_text('beat', encoding='utf-8')\n"
|
|
407
|
+
)
|
|
408
|
+
child_source = (
|
|
409
|
+
"import subprocess, sys, time\n"
|
|
410
|
+
"subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]])\n"
|
|
411
|
+
"time.sleep(60)\n"
|
|
412
|
+
)
|
|
413
|
+
child = subprocess.Popen(
|
|
414
|
+
[sys.executable, "-c", child_source, grandchild_source, str(heartbeat_file)],
|
|
415
|
+
start_new_session=process_tree_kill.should_start_new_session(),
|
|
416
|
+
)
|
|
417
|
+
group_identifier = _group_led_by(child)
|
|
418
|
+
while not heartbeat_file.exists():
|
|
419
|
+
time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
|
|
420
|
+
|
|
421
|
+
terminate_process_tree(child)
|
|
422
|
+
still_running = process_tree_kill.running_process_group_members(
|
|
423
|
+
child, group_identifier
|
|
424
|
+
)
|
|
425
|
+
child.wait()
|
|
426
|
+
|
|
427
|
+
assert still_running == []
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PermissionDenied advisor: a proposed quick fix for each auto mode denial.
|
|
3
|
+
|
|
4
|
+
This hook never retries the denied call. It reads the denial, names the
|
|
5
|
+
classifier rule from the bracketed label in ``denial_reason``, drafts one
|
|
6
|
+
``autoMode.allow`` entry for the denied action, and adds a PowerShell 7 block
|
|
7
|
+
that writes the entry to ``~/.claude/settings.json`` with ``"$defaults"``
|
|
8
|
+
kept. The agent receives the proposal as ``additionalContext``; the user
|
|
9
|
+
receives a one-line ``systemMessage``.
|
|
10
|
+
|
|
11
|
+
A denial with no ``classifier_verdict`` gets a short note and no block,
|
|
12
|
+
because an allow entry cannot clear it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
23
|
+
if _hooks_root_directory not in sys.path:
|
|
24
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.auto_mode_denial_quick_fix_constants import (
|
|
27
|
+
ALL_COMMAND_INPUT_KEYS,
|
|
28
|
+
ALLOW_ENTRY_TEMPLATE,
|
|
29
|
+
HOOK_EVENT_NAME,
|
|
30
|
+
MAXIMUM_ACTION_SUMMARY_CHARACTERS,
|
|
31
|
+
NO_VERDICT_CONTEXT_TEMPLATE,
|
|
32
|
+
POWERSHELL_BLOCK_TEMPLATE,
|
|
33
|
+
RULE_LABEL_PATTERN,
|
|
34
|
+
SUMMARY_WORD_SEPARATOR,
|
|
35
|
+
TRUNCATION_MARKER,
|
|
36
|
+
UNNAMED_RULE_LABEL,
|
|
37
|
+
USER_MESSAGE_TEMPLATE,
|
|
38
|
+
VERDICT_CONTEXT_TEMPLATE,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rule_label_from(denial_reason: str) -> str:
|
|
43
|
+
"""Return the classifier rule name from a denial reason.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
denial_reason: The ``denial_reason`` text, such as
|
|
47
|
+
``"[Security Weaken] lowers a guard"``.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The first bracketed label, or the unnamed-rule label.
|
|
51
|
+
"""
|
|
52
|
+
label_match = re.search(RULE_LABEL_PATTERN, denial_reason)
|
|
53
|
+
return label_match.group(1).strip() if label_match else UNNAMED_RULE_LABEL
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _summary_source_text(tool_input: object) -> str:
|
|
57
|
+
if not isinstance(tool_input, dict):
|
|
58
|
+
return json.dumps(tool_input, sort_keys=True)
|
|
59
|
+
for each_key in ALL_COMMAND_INPUT_KEYS:
|
|
60
|
+
if isinstance(tool_input.get(each_key), str):
|
|
61
|
+
return tool_input[each_key]
|
|
62
|
+
return json.dumps(tool_input, sort_keys=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def action_summary_from(tool_input: object) -> str:
|
|
66
|
+
"""Return one short line that names the denied action.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
tool_input: The denied call's ``tool_input``.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The command, URL or path when the input has one, else the compact
|
|
73
|
+
JSON input, cut to the summary length limit.
|
|
74
|
+
"""
|
|
75
|
+
single_line_text = SUMMARY_WORD_SEPARATOR.join(_summary_source_text(tool_input).split())
|
|
76
|
+
if len(single_line_text) <= MAXIMUM_ACTION_SUMMARY_CHARACTERS:
|
|
77
|
+
return single_line_text
|
|
78
|
+
kept_length = MAXIMUM_ACTION_SUMMARY_CHARACTERS - len(TRUNCATION_MARKER)
|
|
79
|
+
return single_line_text[:kept_length] + TRUNCATION_MARKER
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def powershell_block_for(allow_entry: str) -> str:
|
|
83
|
+
"""Return the PowerShell 7 block that adds one allow entry.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
allow_entry: The ``autoMode.allow`` entry text.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The block, with the entry in a single-quoted PowerShell literal.
|
|
90
|
+
"""
|
|
91
|
+
escaped_entry = allow_entry.replace("'", "''")
|
|
92
|
+
return POWERSHELL_BLOCK_TEMPLATE.replace("{escaped_entry}", escaped_entry)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _verdict_context_text(
|
|
96
|
+
tool_name: str, denial_reason: str, action_summary: str, rule_label: str
|
|
97
|
+
) -> str:
|
|
98
|
+
allow_entry = ALLOW_ENTRY_TEMPLATE.format(
|
|
99
|
+
rule_label=rule_label,
|
|
100
|
+
tool_name=tool_name,
|
|
101
|
+
action_summary=action_summary,
|
|
102
|
+
)
|
|
103
|
+
return VERDICT_CONTEXT_TEMPLATE.format(
|
|
104
|
+
tool_name=tool_name,
|
|
105
|
+
rule_label=rule_label,
|
|
106
|
+
denial_reason=denial_reason,
|
|
107
|
+
action_summary=action_summary,
|
|
108
|
+
powershell_block=powershell_block_for(allow_entry),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_hook_output(all_denial_fields: dict[str, object]) -> dict[str, object]:
|
|
113
|
+
"""Return the PermissionDenied hook output for one denial payload.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
all_denial_fields: The hook input JSON.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A ``hookSpecificOutput`` with ``additionalContext`` for the agent and
|
|
120
|
+
a top-level ``systemMessage`` for the user.
|
|
121
|
+
"""
|
|
122
|
+
tool_name = str(all_denial_fields.get("tool_name") or "tool")
|
|
123
|
+
denial_reason = str(all_denial_fields.get("denial_reason") or "")
|
|
124
|
+
action_summary = action_summary_from(all_denial_fields.get("tool_input"))
|
|
125
|
+
rule_label = rule_label_from(denial_reason)
|
|
126
|
+
if all_denial_fields.get("classifier_verdict"):
|
|
127
|
+
context_text = _verdict_context_text(tool_name, denial_reason, action_summary, rule_label)
|
|
128
|
+
else:
|
|
129
|
+
context_text = NO_VERDICT_CONTEXT_TEMPLATE.format(
|
|
130
|
+
tool_name=tool_name,
|
|
131
|
+
denial_reason=denial_reason,
|
|
132
|
+
action_summary=action_summary,
|
|
133
|
+
)
|
|
134
|
+
return {
|
|
135
|
+
"hookSpecificOutput": {
|
|
136
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
137
|
+
"additionalContext": context_text,
|
|
138
|
+
},
|
|
139
|
+
"systemMessage": USER_MESSAGE_TEMPLATE.format(tool_name=tool_name, rule_label=rule_label),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main() -> int:
|
|
144
|
+
try:
|
|
145
|
+
payload = json.load(sys.stdin)
|
|
146
|
+
except (json.JSONDecodeError, ValueError):
|
|
147
|
+
return 0
|
|
148
|
+
if not isinstance(payload, dict):
|
|
149
|
+
return 0
|
|
150
|
+
sys.stdout.write(json.dumps(build_hook_output(payload)))
|
|
151
|
+
sys.stdout.flush()
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Behavior tests for the auto mode denial quick-fix advisor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
advisory_directory = str(Path(__file__).resolve().parent)
|
|
14
|
+
if advisory_directory not in sys.path:
|
|
15
|
+
sys.path.insert(0, advisory_directory)
|
|
16
|
+
import auto_mode_denial_quick_fix
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _denial_payload(**overrides: object) -> dict[str, object]:
|
|
20
|
+
payload: dict[str, object] = {
|
|
21
|
+
"hook_event_name": "PermissionDenied",
|
|
22
|
+
"tool_name": "mcp__github__resolve_review_thread",
|
|
23
|
+
"tool_input": {"threadId": "PRRT_abc"},
|
|
24
|
+
"denial_reason": "[Security Weaken] resolving review threads bypasses review",
|
|
25
|
+
"classifier_verdict": "deny",
|
|
26
|
+
}
|
|
27
|
+
payload.update(overrides)
|
|
28
|
+
return payload
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_should_name_the_bracketed_rule() -> None:
|
|
32
|
+
assert (
|
|
33
|
+
auto_mode_denial_quick_fix.rule_label_from("[Git Destructive] force") == "Git Destructive"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_should_fall_back_when_the_reason_has_no_label() -> None:
|
|
38
|
+
assert auto_mode_denial_quick_fix.rule_label_from("Blocked by classifier") == "unnamed rule"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_should_summarize_a_bash_call_by_its_command() -> None:
|
|
42
|
+
summary = auto_mode_denial_quick_fix.action_summary_from(
|
|
43
|
+
{"command": "git push\n --force origin x"}
|
|
44
|
+
)
|
|
45
|
+
assert summary == "git push --force origin x"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_should_cut_a_long_summary_to_the_limit() -> None:
|
|
49
|
+
summary = auto_mode_denial_quick_fix.action_summary_from({"command": "x" * 500})
|
|
50
|
+
assert len(summary) == 160
|
|
51
|
+
assert summary.endswith("...")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_should_escape_single_quotes_in_the_powershell_entry() -> None:
|
|
55
|
+
block = auto_mode_denial_quick_fix.powershell_block_for("it's allowed")
|
|
56
|
+
assert "$entry = 'it''s allowed'" in block
|
|
57
|
+
assert "$allowList.Add('$defaults')" in block
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_should_propose_rule_entry_and_block_for_a_classifier_denial() -> None:
|
|
61
|
+
hook_output = auto_mode_denial_quick_fix.build_hook_output(_denial_payload())
|
|
62
|
+
context_text = hook_output["hookSpecificOutput"]["additionalContext"]
|
|
63
|
+
assert "rule [Security Weaken]" in context_text
|
|
64
|
+
assert (
|
|
65
|
+
"Security Weaken exception: mcp__github__resolve_review_thread calls like" in context_text
|
|
66
|
+
)
|
|
67
|
+
assert "```powershell" in context_text
|
|
68
|
+
assert "retry" not in hook_output["hookSpecificOutput"]
|
|
69
|
+
assert hook_output["systemMessage"].startswith(
|
|
70
|
+
"Auto mode blocked mcp__github__resolve_review_thread"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_should_omit_the_block_when_no_verdict_exists() -> None:
|
|
75
|
+
payload = _denial_payload(denial_reason="classifier unavailable")
|
|
76
|
+
del payload["classifier_verdict"]
|
|
77
|
+
context_text = auto_mode_denial_quick_fix.build_hook_output(payload)["hookSpecificOutput"][
|
|
78
|
+
"additionalContext"
|
|
79
|
+
]
|
|
80
|
+
assert "no classifier verdict" in context_text
|
|
81
|
+
assert "powershell" not in context_text
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_should_print_the_output_from_stdin(
|
|
85
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
86
|
+
) -> None:
|
|
87
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(_denial_payload())))
|
|
88
|
+
assert auto_mode_denial_quick_fix.main() == 0
|
|
89
|
+
assert (
|
|
90
|
+
json.loads(capsys.readouterr().out)["hookSpecificOutput"]["hookEventName"]
|
|
91
|
+
== "PermissionDenied"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_should_stay_quiet_on_malformed_input(
|
|
96
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
97
|
+
) -> None:
|
|
98
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
|
|
99
|
+
assert auto_mode_denial_quick_fix.main() == 0
|
|
100
|
+
assert capsys.readouterr().out == ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_should_write_the_entry_with_defaults_when_powershell_runs(tmp_path: Path) -> None:
|
|
104
|
+
block = (
|
|
105
|
+
auto_mode_denial_quick_fix.powershell_block_for("it's allowed")
|
|
106
|
+
.replace("claude auto-mode config", "")
|
|
107
|
+
.replace("Join-Path $HOME", f"Join-Path '{tmp_path}'")
|
|
108
|
+
)
|
|
109
|
+
subprocess.run(["pwsh", "-NoProfile", "-Command", block], check=True)
|
|
110
|
+
written_settings = json.loads(
|
|
111
|
+
(tmp_path / ".claude" / "settings.json").read_text(encoding="utf-8")
|
|
112
|
+
)
|
|
113
|
+
assert written_settings["autoMode"]["allow"] == ["$defaults", "it's allowed"]
|
package/hooks/hooks.json
CHANGED
|
@@ -335,6 +335,18 @@
|
|
|
335
335
|
}
|
|
336
336
|
]
|
|
337
337
|
}
|
|
338
|
+
],
|
|
339
|
+
"PermissionDenied": [
|
|
340
|
+
{
|
|
341
|
+
"matcher": "*",
|
|
342
|
+
"hooks": [
|
|
343
|
+
{
|
|
344
|
+
"type": "command",
|
|
345
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/advisory/auto_mode_denial_quick_fix.py",
|
|
346
|
+
"timeout": 10
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
}
|
|
338
350
|
]
|
|
339
351
|
}
|
|
340
352
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Constants for the auto mode denial quick-fix advisor.
|
|
2
|
+
|
|
3
|
+
The advisor is a PermissionDenied hook. It never retries the call. It turns
|
|
4
|
+
each auto mode denial into a proposed fix: the rule name, a draft
|
|
5
|
+
``autoMode.allow`` entry, and a PowerShell 7 block that adds the entry to
|
|
6
|
+
``~/.claude/settings.json`` with ``"$defaults"`` kept in the list.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HOOK_EVENT_NAME",
|
|
13
|
+
"RULE_LABEL_PATTERN",
|
|
14
|
+
"UNNAMED_RULE_LABEL",
|
|
15
|
+
"MAXIMUM_ACTION_SUMMARY_CHARACTERS",
|
|
16
|
+
"TRUNCATION_MARKER",
|
|
17
|
+
"SUMMARY_WORD_SEPARATOR",
|
|
18
|
+
"ALL_COMMAND_INPUT_KEYS",
|
|
19
|
+
"ALLOW_ENTRY_TEMPLATE",
|
|
20
|
+
"POWERSHELL_BLOCK_TEMPLATE",
|
|
21
|
+
"VERDICT_CONTEXT_TEMPLATE",
|
|
22
|
+
"NO_VERDICT_CONTEXT_TEMPLATE",
|
|
23
|
+
"USER_MESSAGE_TEMPLATE",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
HOOK_EVENT_NAME = "PermissionDenied"
|
|
27
|
+
RULE_LABEL_PATTERN = r"\[([^\]]+)\]"
|
|
28
|
+
UNNAMED_RULE_LABEL = "unnamed rule"
|
|
29
|
+
MAXIMUM_ACTION_SUMMARY_CHARACTERS = 160
|
|
30
|
+
TRUNCATION_MARKER = "..."
|
|
31
|
+
SUMMARY_WORD_SEPARATOR = " "
|
|
32
|
+
ALL_COMMAND_INPUT_KEYS = ("command", "url", "file_path", "path")
|
|
33
|
+
|
|
34
|
+
ALLOW_ENTRY_TEMPLATE = (
|
|
35
|
+
"{rule_label} exception: {tool_name} calls like `{action_summary}` "
|
|
36
|
+
"are allowed when they serve the task the user gave."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
POWERSHELL_BLOCK_TEMPLATE = """\
|
|
40
|
+
$settingsPath = Join-Path $HOME '.claude/settings.json'
|
|
41
|
+
New-Item -ItemType Directory -Force -Path (Split-Path $settingsPath) | Out-Null
|
|
42
|
+
$settings = if (Test-Path $settingsPath) { Get-Content -Raw $settingsPath | ConvertFrom-Json -AsHashtable } else { @{} }
|
|
43
|
+
if (-not $settings.ContainsKey('autoMode')) { $settings['autoMode'] = @{} }
|
|
44
|
+
$allowList = [System.Collections.Generic.List[string]]::new()
|
|
45
|
+
if ($settings['autoMode'].ContainsKey('allow')) { $allowList.AddRange([string[]]@($settings['autoMode']['allow'])) } else { $allowList.Add('$defaults') }
|
|
46
|
+
$entry = '{escaped_entry}'
|
|
47
|
+
if ($allowList -notcontains $entry) { $allowList.Add($entry) }
|
|
48
|
+
$settings['autoMode']['allow'] = $allowList.ToArray()
|
|
49
|
+
$settings | ConvertTo-Json -Depth 32 | Set-Content -Encoding utf8NoBOM $settingsPath
|
|
50
|
+
claude auto-mode config"""
|
|
51
|
+
|
|
52
|
+
VERDICT_CONTEXT_TEMPLATE = """\
|
|
53
|
+
=== AUTO MODE DENIAL QUICK FIX ===
|
|
54
|
+
Auto mode denied {tool_name} under rule [{rule_label}].
|
|
55
|
+
Denial reason: {denial_reason}
|
|
56
|
+
Denied action: {action_summary}
|
|
57
|
+
|
|
58
|
+
In your next message to the user, propose these two fixes. Do not retry and do not run the block yourself.
|
|
59
|
+
1. One-off: the user replies with a message that names this exact action and its target; then you retry once.
|
|
60
|
+
2. Lasting: the user runs this PowerShell 7 block on their machine. It adds one autoMode.allow entry to ~/.claude/settings.json and keeps "$defaults". Narrow the entry text to this action before you send it.
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
{powershell_block}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For cloud sessions, the same entry must land in the ~/.claude/settings.json that the session's setup installs.
|
|
67
|
+
Read the rule wording with: claude auto-mode defaults --label '{rule_label}'"""
|
|
68
|
+
|
|
69
|
+
NO_VERDICT_CONTEXT_TEMPLATE = """\
|
|
70
|
+
=== AUTO MODE DENIAL QUICK FIX ===
|
|
71
|
+
Auto mode denied {tool_name} with no classifier verdict.
|
|
72
|
+
Denial reason: {denial_reason}
|
|
73
|
+
Denied action: {action_summary}
|
|
74
|
+
|
|
75
|
+
An autoMode.allow entry cannot clear a denial with no verdict. Tell the user the action and the reason, and ask them to run it themselves or to retry it later."""
|
|
76
|
+
|
|
77
|
+
USER_MESSAGE_TEMPLATE = (
|
|
78
|
+
"Auto mode blocked {tool_name} [{rule_label}]. Claude will propose a quick fix."
|
|
79
|
+
)
|
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import dataclasses
|
|
6
6
|
import errno
|
|
7
|
+
import importlib
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
9
10
|
import shutil
|
|
@@ -63,6 +64,16 @@ from dev_env_scripts_constants.codex_account_constants import (
|
|
|
63
64
|
WEEKLY_WINDOW_MINUTES,
|
|
64
65
|
)
|
|
65
66
|
from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
|
|
67
|
+
from shared_tree_paths import resolve_shared_process_tree_scripts_directory
|
|
68
|
+
|
|
69
|
+
_shared_process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
|
|
70
|
+
__file__,
|
|
71
|
+
all_environment=os.environ,
|
|
72
|
+
)
|
|
73
|
+
if str(_shared_process_tree_scripts_directory) not in sys.path:
|
|
74
|
+
sys.path.insert(0, str(_shared_process_tree_scripts_directory))
|
|
75
|
+
|
|
76
|
+
_process_tree_kill = importlib.import_module("process_tree_kill")
|
|
66
77
|
|
|
67
78
|
|
|
68
79
|
def _read_list(path: Path) -> object:
|
|
@@ -492,21 +503,26 @@ def _run_captured_subprocess(all_argv: Sequence[str], **options: object) -> subp
|
|
|
492
503
|
errors = str(options.get("errors") or "replace")
|
|
493
504
|
stdin_bytes = options.get("input")
|
|
494
505
|
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
|
|
495
|
-
|
|
506
|
+
with subprocess.Popen(
|
|
496
507
|
_resolve_command(all_argv),
|
|
497
|
-
|
|
508
|
+
stdin=subprocess.PIPE if stdin_bytes is not None else None,
|
|
498
509
|
stdout=stdout_file,
|
|
499
510
|
stderr=stderr_file,
|
|
500
511
|
env=options.get("env"),
|
|
501
512
|
cwd=options.get("cwd"),
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
513
|
+
start_new_session=_process_tree_kill.should_start_new_session(),
|
|
514
|
+
) as process:
|
|
515
|
+
try:
|
|
516
|
+
process.communicate(input=stdin_bytes, timeout=options.get("timeout"))
|
|
517
|
+
except (subprocess.TimeoutExpired, KeyboardInterrupt):
|
|
518
|
+
_process_tree_kill.terminate_process_tree(process)
|
|
519
|
+
process.wait()
|
|
520
|
+
raise
|
|
505
521
|
stdout_file.seek(0)
|
|
506
522
|
stderr_file.seek(0)
|
|
507
523
|
stdout = stdout_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
|
|
508
524
|
stderr = stderr_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
|
|
509
|
-
return subprocess.CompletedProcess(list(all_argv),
|
|
525
|
+
return subprocess.CompletedProcess(list(all_argv), process.returncode, stdout, stderr)
|
|
510
526
|
|
|
511
527
|
|
|
512
528
|
subprocess_runner: SubprocessRunner = _run_captured_subprocess
|
|
@@ -5,6 +5,8 @@ import json
|
|
|
5
5
|
import os
|
|
6
6
|
import shutil
|
|
7
7
|
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
8
10
|
from datetime import datetime, timezone
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from types import SimpleNamespace
|
|
@@ -222,16 +224,85 @@ def test_should_raise_windows_lock_error_other_than_contention(tmp_path: Path) -
|
|
|
222
224
|
assert all_calls == [lock_descriptor]
|
|
223
225
|
|
|
224
226
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
GRANDCHILD_HEARTBEAT_SCRIPT = (
|
|
228
|
+
"import pathlib, sys, time\n"
|
|
229
|
+
"heartbeat = pathlib.Path(sys.argv[1])\n"
|
|
230
|
+
"for each_beat in range(300):\n"
|
|
231
|
+
" heartbeat.write_text(str(each_beat), encoding='utf-8')\n"
|
|
232
|
+
" time.sleep(0.1)\n"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
CHILD_SCRIPT = (
|
|
236
|
+
"import subprocess, sys, time\n"
|
|
237
|
+
"subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]])\n"
|
|
238
|
+
"time.sleep(60)\n"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_should_end_grandchildren_when_the_job_times_out(tmp_path: Path) -> None:
|
|
243
|
+
heartbeat_file = tmp_path / "heartbeat.txt"
|
|
244
|
+
|
|
245
|
+
with pytest.raises(subprocess.TimeoutExpired):
|
|
246
|
+
support._run_captured_subprocess(
|
|
247
|
+
[sys.executable, "-c", CHILD_SCRIPT, GRANDCHILD_HEARTBEAT_SCRIPT, str(heartbeat_file)],
|
|
248
|
+
timeout=5,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
beat_after_timeout = heartbeat_file.read_text(encoding="utf-8")
|
|
252
|
+
time.sleep(0.5)
|
|
253
|
+
assert heartbeat_file.read_text(encoding="utf-8") == beat_after_timeout
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_should_end_grandchildren_when_the_broker_is_interrupted(
|
|
257
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
258
|
+
) -> None:
|
|
259
|
+
heartbeat_file = tmp_path / "heartbeat.txt"
|
|
260
|
+
original_communicate = subprocess.Popen.communicate
|
|
261
|
+
|
|
262
|
+
def interrupted_communicate(process: subprocess.Popen[bytes], *args: object, **kwargs: object) -> object:
|
|
263
|
+
try:
|
|
264
|
+
return original_communicate(process, *args, timeout=5)
|
|
265
|
+
except subprocess.TimeoutExpired:
|
|
266
|
+
raise KeyboardInterrupt from None
|
|
267
|
+
|
|
268
|
+
monkeypatch.setattr(subprocess.Popen, "communicate", interrupted_communicate)
|
|
269
|
+
|
|
270
|
+
with pytest.raises(KeyboardInterrupt):
|
|
271
|
+
support._run_captured_subprocess(
|
|
272
|
+
[sys.executable, "-c", CHILD_SCRIPT, GRANDCHILD_HEARTBEAT_SCRIPT, str(heartbeat_file)],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
beat_after_interrupt = heartbeat_file.read_text(encoding="utf-8")
|
|
276
|
+
time.sleep(0.5)
|
|
277
|
+
assert heartbeat_file.read_text(encoding="utf-8") == beat_after_interrupt
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _record_launches(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
|
228
281
|
all_launched_argv: list[list[str]] = []
|
|
229
282
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
283
|
+
class FakeProcess:
|
|
284
|
+
returncode = 0
|
|
285
|
+
|
|
286
|
+
def __init__(self, all_argv: list[str], **options: object) -> None:
|
|
287
|
+
all_launched_argv.append(list(all_argv))
|
|
288
|
+
|
|
289
|
+
def __enter__(self) -> "FakeProcess":
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
def __exit__(self, *exception_info: object) -> None:
|
|
293
|
+
return None
|
|
233
294
|
|
|
234
|
-
|
|
295
|
+
def communicate(self, **options: object) -> tuple[None, None]:
|
|
296
|
+
return None, None
|
|
297
|
+
|
|
298
|
+
monkeypatch.setattr(support.subprocess, "Popen", FakeProcess)
|
|
299
|
+
return all_launched_argv
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_should_launch_the_path_resolved_command_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
303
|
+
resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
|
|
304
|
+
monkeypatch.setattr(shutil, "which", lambda name: resolved_command if name == "claude" else None)
|
|
305
|
+
all_launched_argv = _record_launches(monkeypatch)
|
|
235
306
|
|
|
236
307
|
completion = support.subprocess_runner(["claude", "-p"], input=b"", encoding="utf-8", errors="replace")
|
|
237
308
|
|
|
@@ -241,7 +312,7 @@ def test_should_launch_the_path_resolved_command_file(monkeypatch: pytest.Monkey
|
|
|
241
312
|
|
|
242
313
|
def test_should_refuse_to_launch_a_bare_command_missing_from_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
243
314
|
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
244
|
-
monkeypatch.setattr(support.subprocess, "
|
|
315
|
+
monkeypatch.setattr(support.subprocess, "Popen", lambda *arguments, **options: pytest.fail("a missing command ran"))
|
|
245
316
|
|
|
246
317
|
with pytest.raises(FileNotFoundError) as raised:
|
|
247
318
|
support.subprocess_runner(["claude", "-p"])
|
|
@@ -256,7 +327,7 @@ def test_should_refuse_a_batch_file_argument_that_cmd_would_parse(
|
|
|
256
327
|
) -> None:
|
|
257
328
|
resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
|
|
258
329
|
monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
|
|
259
|
-
monkeypatch.setattr(support.subprocess, "
|
|
330
|
+
monkeypatch.setattr(support.subprocess, "Popen", lambda *arguments, **options: pytest.fail("a batch file ran"))
|
|
260
331
|
|
|
261
332
|
with pytest.raises(OSError) as raised:
|
|
262
333
|
support.subprocess_runner(["claude", "-p", f"fix{metacharacter}test"])
|
|
@@ -269,7 +340,7 @@ def test_should_refuse_a_batch_file_argument_that_cmd_would_parse(
|
|
|
269
340
|
def test_should_refuse_metacharacters_for_an_uppercase_bat_extension(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
270
341
|
resolved_command = r"C:\tools\CLAUDE.BAT"
|
|
271
342
|
monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
|
|
272
|
-
monkeypatch.setattr(support.subprocess, "
|
|
343
|
+
monkeypatch.setattr(support.subprocess, "Popen", lambda *arguments, **options: pytest.fail("a batch file ran"))
|
|
273
344
|
|
|
274
345
|
with pytest.raises(OSError) as raised:
|
|
275
346
|
support.subprocess_runner(["claude", "a & b"])
|
|
@@ -280,13 +351,7 @@ def test_should_refuse_metacharacters_for_an_uppercase_bat_extension(monkeypatch
|
|
|
280
351
|
def test_should_launch_a_batch_file_with_flags_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
281
352
|
resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
|
|
282
353
|
monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
|
|
283
|
-
all_launched_argv
|
|
284
|
-
|
|
285
|
-
def fake_run(all_argv: list[str], **options: object) -> subprocess.CompletedProcess[bytes]:
|
|
286
|
-
all_launched_argv.append(list(all_argv))
|
|
287
|
-
return subprocess.CompletedProcess(all_argv, 0)
|
|
288
|
-
|
|
289
|
-
monkeypatch.setattr(support.subprocess, "run", fake_run)
|
|
354
|
+
all_launched_argv = _record_launches(monkeypatch)
|
|
290
355
|
|
|
291
356
|
support.subprocess_runner(["claude", "-p", "--output-format", "json", "--model", "opus"], input=b"a & b")
|
|
292
357
|
|
|
@@ -296,13 +361,7 @@ def test_should_launch_a_batch_file_with_flags_only(monkeypatch: pytest.MonkeyPa
|
|
|
296
361
|
def test_should_pass_metacharacters_to_an_executable_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
297
362
|
resolved_command = "/usr/local/bin/claude"
|
|
298
363
|
monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
|
|
299
|
-
all_launched_argv
|
|
300
|
-
|
|
301
|
-
def fake_run(all_argv: list[str], **options: object) -> subprocess.CompletedProcess[bytes]:
|
|
302
|
-
all_launched_argv.append(list(all_argv))
|
|
303
|
-
return subprocess.CompletedProcess(all_argv, 0)
|
|
304
|
-
|
|
305
|
-
monkeypatch.setattr(support.subprocess, "run", fake_run)
|
|
364
|
+
all_launched_argv = _record_launches(monkeypatch)
|
|
306
365
|
|
|
307
366
|
support.subprocess_runner(["claude", "-p", 'say "a & b" | 100%!'])
|
|
308
367
|
|