claude-dev-env 8.48.0 → 8.48.2

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.
@@ -28,6 +28,8 @@ _ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
28
28
 
29
29
  _VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
30
30
 
31
+ _DIRECTORY_OUTSIDE_TEMP_ROOTS = Path("/home/user")
32
+
31
33
 
32
34
  def _run_enforcer_cli(
33
35
  all_cli_arguments: list[str],
@@ -74,6 +76,7 @@ def test_should_return_false_for_os_tempfile_gettempdir_root(
74
76
  """
75
77
  monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
76
78
  monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
79
+ monkeypatch.setattr(tempfile, "tempdir", str(_DIRECTORY_OUTSIDE_TEMP_ROOTS / "os_temp_root"))
77
80
  system_temp_root = tempfile.gettempdir()
78
81
  scratch_path = str(Path(system_temp_root) / "scratch_work.py")
79
82
  assert is_ephemeral_script_path(scratch_path) is False
@@ -82,7 +85,6 @@ def test_should_return_false_for_os_tempfile_gettempdir_root(
82
85
  @pytest.mark.parametrize("env_name", ["TMPDIR", "TEMP", "TMP"])
83
86
  def test_should_return_false_for_tmpdir_temp_tmp_env_roots(
84
87
  env_name: str,
85
- tmp_path: Path,
86
88
  monkeypatch: pytest.MonkeyPatch,
87
89
  ) -> None:
88
90
  """B3: $TMPDIR / $TEMP / $TMP are not ephemeral sources.
@@ -92,8 +94,9 @@ def test_should_return_false_for_tmpdir_temp_tmp_env_roots(
92
94
  """
93
95
  monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
94
96
  monkeypatch.delenv("CLAUDE_JOB_DIR", raising=False)
95
- monkeypatch.setenv(env_name, str(tmp_path / "env_root"))
96
- scratch_path = str(tmp_path / "env_root" / "scratch.py")
97
+ env_root = _DIRECTORY_OUTSIDE_TEMP_ROOTS / "env_root"
98
+ monkeypatch.setenv(env_name, str(env_root))
99
+ scratch_path = str(env_root / "scratch.py")
97
100
  assert is_ephemeral_script_path(scratch_path) is False
98
101
 
99
102
 
@@ -111,7 +114,8 @@ def test_should_return_false_for_pytest_tmp_path_when_job_dir_elsewhere(
111
114
  """
112
115
  monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
113
116
  monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path / "elsewhere"))
114
- pytest_sandbox_target = str(tmp_path / "candidate.py")
117
+ monkeypatch.setattr(tempfile, "tempdir", str(_DIRECTORY_OUTSIDE_TEMP_ROOTS / "os_temp_root"))
118
+ pytest_sandbox_target = str(Path(tempfile.gettempdir()) / "pytest-of-user" / "candidate.py")
115
119
  assert is_ephemeral_script_path(pytest_sandbox_target) is False
116
120
 
117
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.48.0",
3
+ "version": "8.48.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,12 +25,14 @@ import codex_account_meters
25
25
  from claude_account_profile import default_profile_home, validate_profile_name
26
26
  from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
27
27
  from dev_env_scripts_constants.account_broker_constants import (
28
+ ALL_BATCH_FILE_EXTENSIONS,
28
29
  BROKER_STATE_DIRECTORY_NAME,
29
30
  BROKER_STATE_FILE_NAME,
30
31
  BROKER_STATE_LOCK_SUFFIX,
31
32
  BROKER_STATE_TEMP_SUFFIX,
32
33
  Account,
33
34
  BrokerConfigurationError,
35
+ CMD_SHELL_METACHARACTERS,
34
36
  Decision,
35
37
  JobOutcome,
36
38
  Meters,
@@ -434,7 +436,29 @@ def _resolve_command(all_argv: Sequence[str]) -> list[str]:
434
436
  resolved_command = shutil.which(command_name)
435
437
  if resolved_command is None and not os.path.dirname(command_name):
436
438
  raise FileNotFoundError(errno.ENOENT, "command not found on PATH", command_name)
437
- return [resolved_command or command_name, *all_arguments]
439
+ launched_command = resolved_command or command_name
440
+ _refuse_batch_file_shell_metacharacters(launched_command, all_arguments)
441
+ return [launched_command, *all_arguments]
442
+
443
+
444
+ def _refuse_batch_file_shell_metacharacters(launched_command: str, all_arguments: Sequence[str]) -> None:
445
+ if os.path.splitext(launched_command)[1].casefold() not in ALL_BATCH_FILE_EXTENSIONS:
446
+ return
447
+ shell_parsed_argument = next(
448
+ (
449
+ each_argument
450
+ for each_argument in all_arguments
451
+ if any(each_character in CMD_SHELL_METACHARACTERS for each_character in each_argument)
452
+ ),
453
+ None,
454
+ )
455
+ if shell_parsed_argument is None:
456
+ return
457
+ raise OSError(
458
+ errno.EINVAL,
459
+ f"cmd.exe would parse the batch file argument {shell_parsed_argument!r}; send that text on stdin",
460
+ launched_command,
461
+ )
438
462
 
439
463
 
440
464
  def _run_captured_subprocess(all_argv: Sequence[str], **options: object) -> subprocess.CompletedProcess[str]:
@@ -58,6 +58,8 @@ BROKER_STATE_FILE_NAME = "state.json"
58
58
  BROKER_STATE_TEMP_SUFFIX = ".json"
59
59
  BROKER_STATE_LOCK_SUFFIX = ".lock"
60
60
  SECONDS_PER_HOUR = 3600
61
+ ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
62
+ CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
61
63
  ALL_PARENT_CLAUDE_SESSION_VARIABLES: frozenset[str] = frozenset({
62
64
  "CLAUDE_CODE_SESSION_ID",
63
65
  "CLAUDE_CODE_REMOTE",
@@ -190,3 +190,62 @@ def test_should_refuse_to_launch_a_bare_command_missing_from_path(monkeypatch: p
190
190
 
191
191
  assert raised.value.errno == errno.ENOENT
192
192
  assert raised.value.filename == "claude"
193
+
194
+
195
+ @pytest.mark.parametrize("metacharacter", ["&", "|", "<", ">", "^", "%", "!", '"', "\n", "\r"])
196
+ def test_should_refuse_a_batch_file_argument_that_cmd_would_parse(
197
+ monkeypatch: pytest.MonkeyPatch, metacharacter: str
198
+ ) -> None:
199
+ resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
200
+ monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
201
+ monkeypatch.setattr(support.subprocess, "run", lambda *arguments, **options: pytest.fail("a batch file ran"))
202
+
203
+ with pytest.raises(OSError) as raised:
204
+ support.subprocess_runner(["claude", "-p", f"fix{metacharacter}test"])
205
+
206
+ assert raised.value.errno == errno.EINVAL
207
+ assert raised.value.filename == resolved_command
208
+ assert "stdin" in raised.value.strerror
209
+
210
+
211
+ def test_should_refuse_metacharacters_for_an_uppercase_bat_extension(monkeypatch: pytest.MonkeyPatch) -> None:
212
+ resolved_command = r"C:\tools\CLAUDE.BAT"
213
+ monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
214
+ monkeypatch.setattr(support.subprocess, "run", lambda *arguments, **options: pytest.fail("a batch file ran"))
215
+
216
+ with pytest.raises(OSError) as raised:
217
+ support.subprocess_runner(["claude", "a & b"])
218
+
219
+ assert raised.value.errno == errno.EINVAL
220
+
221
+
222
+ def test_should_launch_a_batch_file_with_flags_only(monkeypatch: pytest.MonkeyPatch) -> None:
223
+ resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
224
+ monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
225
+ all_launched_argv: list[list[str]] = []
226
+
227
+ def fake_run(all_argv: list[str], **options: object) -> subprocess.CompletedProcess[bytes]:
228
+ all_launched_argv.append(list(all_argv))
229
+ return subprocess.CompletedProcess(all_argv, 0)
230
+
231
+ monkeypatch.setattr(support.subprocess, "run", fake_run)
232
+
233
+ support.subprocess_runner(["claude", "-p", "--output-format", "json", "--model", "opus"], input=b"a & b")
234
+
235
+ assert all_launched_argv == [[resolved_command, "-p", "--output-format", "json", "--model", "opus"]]
236
+
237
+
238
+ def test_should_pass_metacharacters_to_an_executable_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
239
+ resolved_command = "/usr/local/bin/claude"
240
+ monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
241
+ all_launched_argv: list[list[str]] = []
242
+
243
+ def fake_run(all_argv: list[str], **options: object) -> subprocess.CompletedProcess[bytes]:
244
+ all_launched_argv.append(list(all_argv))
245
+ return subprocess.CompletedProcess(all_argv, 0)
246
+
247
+ monkeypatch.setattr(support.subprocess, "run", fake_run)
248
+
249
+ support.subprocess_runner(["claude", "-p", 'say "a & b" | 100%!'])
250
+
251
+ assert all_launched_argv == [[resolved_command, "-p", 'say "a & b" | 100%!']]