claude-dev-env 8.54.5 → 8.54.7

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.
@@ -2986,6 +2986,10 @@ test('the help output states that the installer reads only flags', () => {
2986
2986
  assert.equal(helpRun.status, 0, helpRun.stderr);
2987
2987
  assert.match(helpRun.stdout, /reads only flags/i);
2988
2988
  assert.match(helpRun.stdout, /bare path argument carries no meaning/i);
2989
+ assert.match(
2990
+ helpRun.stdout,
2991
+ /--update\s+Full install: copy the package over the prior install, then prune files it no longer ships/,
2992
+ );
2989
2993
  });
2990
2994
 
2991
2995
 
@@ -11,7 +11,8 @@ The hook stays silent when:
11
11
  - the stop is already a retry after a block (stop_hook_active is true);
12
12
  - the session shows no title tool: no title tool named anywhere in the
13
13
  transcript, and either no remote session id in the environment or a
14
- deferred tool listing that leaves the title tool out;
14
+ deferred tool listing that leaves the title tool out, or an earlier
15
+ Stop block the session never met;
15
16
  - the transcript cannot be read.
16
17
  """
17
18
 
@@ -32,6 +33,7 @@ from hooks_constants.session_title_constants import (
32
33
  ALLOW_EXIT_CODE,
33
34
  BLOCK_DECISION,
34
35
  DEFERRED_TOOL_ATTACHMENT_TYPES,
36
+ HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE,
35
37
  REMOTE_SESSION_ENVIRONMENT_VARIABLE,
36
38
  REMOTE_TITLE_TOOL_NAME,
37
39
  STOP_BLOCK_REASON,
@@ -85,6 +87,15 @@ def _lists_deferred_tools(all_entry_fields: dict[str, object]) -> bool:
85
87
  return isinstance(attachment, dict) and attachment.get("type") in DEFERRED_TOOL_ATTACHMENT_TYPES
86
88
 
87
89
 
90
+ def _is_stop_block(all_entry_fields: dict[str, object]) -> bool:
91
+ attachment = all_entry_fields.get("attachment")
92
+ return (
93
+ isinstance(attachment, dict)
94
+ and attachment.get("type") == HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE
95
+ and attachment.get("hookEvent") == STOP_GATE_EVENT_NAME
96
+ )
97
+
98
+
88
99
  def _is_title_call(entry_type: object, all_block_fields: dict[str, object]) -> bool:
89
100
  return (
90
101
  entry_type == "assistant"
@@ -132,6 +143,7 @@ def stop_block_reason(all_entries: list[dict[str, object]], is_remote_session: b
132
143
  prompt -> work, no title call => block reason
133
144
  no title tool anywhere, not remote => None
134
145
  remote, tool list lacks the title tool => None
146
+ remote, earlier Stop block never met => None
135
147
 
136
148
  Args:
137
149
  all_entries: The transcript entries, oldest first.
@@ -144,7 +156,8 @@ def stop_block_reason(all_entries: list[dict[str, object]], is_remote_session: b
144
156
  each_name for each_entry in all_entries for each_name in _title_tool_names_in(each_entry)
145
157
  ]
146
158
  if not all_seen_tool_names and (
147
- not is_remote_session or any(_lists_deferred_tools(each) for each in all_entries)
159
+ not is_remote_session
160
+ or any(_lists_deferred_tools(each) or _is_stop_block(each) for each in all_entries)
148
161
  ):
149
162
  return None
150
163
  if _title_set_since(all_entries[_turn_start_index(all_entries) :]):
@@ -199,6 +199,43 @@ def test_should_block_a_remote_session_whose_tool_list_names_the_title_tool(
199
199
  assert json.loads(stdout_text)["decision"] == "block"
200
200
 
201
201
 
202
+ def earlier_stop_block() -> dict[str, object]:
203
+ return {
204
+ "type": "attachment",
205
+ "attachment": {"type": "hook_blocking_error", "hookEvent": "Stop"},
206
+ }
207
+
208
+
209
+ def test_should_stay_silent_in_a_remote_session_after_an_unmet_earlier_block(
210
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
211
+ ) -> None:
212
+ monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
213
+ all_entries = [
214
+ prompt("Fix the runner"),
215
+ earlier_stop_block(),
216
+ prompt("Next task"),
217
+ tool_call("t1", "Bash"),
218
+ tool_result("t1"),
219
+ ]
220
+ assert run_gate(monkeypatch, capsys, tmp_path, all_entries) == (0, "")
221
+
222
+
223
+ def test_should_block_after_an_earlier_block_once_the_title_tool_is_listed(
224
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
225
+ ) -> None:
226
+ monkeypatch.setenv(REMOTE_SESSION_VARIABLE, "cse_1")
227
+ all_entries = [
228
+ deferred_tools(REMOTE_TITLE_TOOL),
229
+ prompt("Fix the runner"),
230
+ earlier_stop_block(),
231
+ prompt("Next task"),
232
+ tool_call("t1", "Bash"),
233
+ tool_result("t1"),
234
+ ]
235
+ _, stdout_text = run_gate(monkeypatch, capsys, tmp_path, all_entries)
236
+ assert json.loads(stdout_text)["decision"] == "block"
237
+
238
+
202
239
  def test_should_stay_silent_on_the_retry_after_a_block(
203
240
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
204
241
  ) -> None:
@@ -15,6 +15,7 @@ ALLOW_EXIT_CODE = 0
15
15
  BLOCK_EXIT_CODE = 2
16
16
  BLOCK_DECISION = "block"
17
17
  DEFERRED_TOOL_ATTACHMENT_TYPES = frozenset({"deferred_tools_delta", "deferred_tools_record"})
18
+ HOOK_BLOCKING_ERROR_ATTACHMENT_TYPE = "hook_blocking_error"
18
19
 
19
20
  STATUS_PREFIX_PATTERN = re.compile("^(\U0001f6a9|\u2705|\u23f3)\ufe0f? (?=\\S)")
20
21
  MAXIMUM_NAME_LENGTH = 25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.54.5",
3
+ "version": "8.54.7",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,8 @@ test('the driver reads an envelope from every stage of a fresh install, and repo
30
30
  assert.match(greenRun.stdout, /\[PASS\] playtest install .* sha256=[0-9a-f]{64}/);
31
31
  assert.match(greenRun.stdout, /\[PASS\] playtest session_start_hook .* sha256=[0-9a-f]{64}/);
32
32
  assert.match(greenRun.stdout, /\[PASS\] playtest blocking_hook .* sha256=[0-9a-f]{64}/);
33
+ const blockingEnvelope = JSON.parse(readFileSync(join(evidenceDirectory, 'blocking_hook.json'), 'utf8'));
34
+ assert.equal(Object.hasOwn(blockingEnvelope, 'permissionDecision'), false);
33
35
  assert.ok(
34
36
  readFileSync(join(scratchHome, SANDBOX_GIT_CONFIG_NAME), 'utf8').startsWith(SANDBOX_GIT_CONFIG_BODY),
35
37
  );
@@ -16,9 +16,11 @@ import pytest
16
16
 
17
17
  from dev_env_scripts_constants import account_broker_constants as broker_constants
18
18
  from dev_env_scripts_constants.account_broker_constants import (
19
+ ALL_BATCH_FILE_EXTENSIONS,
19
20
  ALL_CLAUDE_FLOORS,
20
- ALL_PARENT_CLAUDE_SESSION_VARIABLES,
21
21
  ALL_CODEX_FLOORS,
22
+ ALL_PARENT_CLAUDE_SESSION_VARIABLES,
23
+ CMD_SHELL_METACHARACTERS,
22
24
  JobOutcome,
23
25
  )
24
26
  from dev_env_scripts_constants.claude_account_constants import (
@@ -53,6 +55,11 @@ def test_should_keep_codex_floors_tied_to_the_tier_limits() -> None:
53
55
  }
54
56
 
55
57
 
58
+ def test_batch_command_boundaries_include_shell_metacharacters() -> None:
59
+ assert ALL_BATCH_FILE_EXTENSIONS == frozenset({".bat", ".cmd"})
60
+ assert set(CMD_SHELL_METACHARACTERS) == set('&|<>^%!"\r\n')
61
+
62
+
56
63
  def test_should_freeze_job_outcome() -> None:
57
64
  outcome = JobOutcome(0, "", "", None, (), "served", None, None)
58
65
  with pytest.raises(dataclasses.FrozenInstanceError):