claude-dev-env 8.71.1 → 8.72.1

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.
Files changed (40) hide show
  1. package/.agents/skills/build-eval/SKILL.md +3 -0
  2. package/bin/install.mjs +1 -0
  3. package/bin/install.test.mjs +1 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +9 -1
  5. package/hooks/blocking/headless_claude_broker_gate.py +14 -1
  6. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +2 -0
  7. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +46 -0
  8. package/hooks/blocking/test_headless_claude_broker_gate.py +12 -0
  9. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -3
  10. package/hooks/conftest.py +18 -0
  11. package/hooks/features/session-context.md +1 -3
  12. package/hooks/features/write-edit-blocking.md +3 -6
  13. package/hooks/hooks.json +0 -5
  14. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  15. package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +7 -1
  16. package/hooks/hooks_constants/mod_handoff.py +91 -0
  17. package/hooks/hooks_constants/mod_handoff_constants.py +38 -0
  18. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -7
  19. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -1
  20. package/hooks/hooks_constants/test_mod_handoff.py +100 -0
  21. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +2 -23
  22. package/hooks/session/advisor_rules_prompt.py +5 -1
  23. package/hooks/session/orchestrator_auto_starter.py +5 -1
  24. package/hooks/session/task_tool_prompt.py +5 -1
  25. package/hooks/session/test_advisor_rules_prompt.py +11 -0
  26. package/hooks/session/test_orchestrator_auto_starter.py +22 -0
  27. package/hooks/session/test_task_tool_prompt.py +15 -0
  28. package/hooks/session/test_working_style_prompt.py +15 -0
  29. package/hooks/session/working_style_prompt.py +5 -1
  30. package/package.json +1 -1
  31. package/scripts/dev_env_scripts_constants/review_closure_constants.py +24 -0
  32. package/scripts/review_closure_model.py +48 -12
  33. package/scripts/test_files/review_closure/codex_comments.json +1266 -0
  34. package/scripts/tests/test_review_closure_model.py +130 -0
  35. package/hooks/advisory/migration_safety_advisor.py +0 -74
  36. package/hooks/advisory/test_migration_safety_advisor.py +0 -204
  37. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +0 -22
  38. package/hooks/hooks_constants/migration_safety_advisor_constants.py +0 -7
  39. package/hooks/session/issue_tracker_session_starter.py +0 -136
  40. package/hooks/session/test_issue_tracker_session_starter.py +0 -106
@@ -57,6 +57,9 @@ the arm with the plugin and the arm without it.
57
57
  such a feature with the `claude -p` recipe in `reference/graders-and-commands.md`.
58
58
  - `--keep-temp` on Windows warns that it cannot seal the folder. Delete that folder when done with
59
59
  PowerShell `Remove-Item -Recurse -Force -LiteralPath <path>`.
60
+ - A loop the user asks to run until clean runs with no round cap and no retry cap. Use only the
61
+ limits the user set. Name any other stop condition to the user before the first launch, and
62
+ report each run it ended as stopped by that condition.
60
63
 
61
64
  ## When this applies
62
65
 
package/bin/install.mjs CHANGED
@@ -1247,6 +1247,7 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1247
1247
  'blocking/send_user_file_open_locally_blocker.py',
1248
1248
  'blocking/question_to_user_enforcer.py',
1249
1249
  'blocking/session_handoff_blocker.py',
1250
+ 'session/issue_tracker_session_starter.py',
1250
1251
  ]);
1251
1252
 
1252
1253
  export const MOVED_HOOK_RELATIVE_PATHS = new Set([
@@ -1230,6 +1230,7 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
1230
1230
  'blocking/send_user_file_open_locally_blocker.py',
1231
1231
  'blocking/question_to_user_enforcer.py',
1232
1232
  'blocking/session_handoff_blocker.py',
1233
+ 'session/issue_tracker_session_starter.py',
1233
1234
  ]);
1234
1235
  const shippedHooks = JSON.parse(
1235
1236
  readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
@@ -41,6 +41,7 @@ from hooks_constants.hosted_hook_runner import (
41
41
  HostedHookRun,
42
42
  resolved_hook_script_path,
43
43
  )
44
+ from hooks_constants.mod_handoff import is_mod_plugin_enabled
44
45
 
45
46
  _PERMISSION_DECISION_KEY = "permissionDecision"
46
47
  _PERMISSION_REASON_KEY = "permissionDecisionReason"
@@ -87,11 +88,18 @@ class _ParsedHookDecision:
87
88
 
88
89
 
89
90
  def select_applicable_entries(tool_name: str) -> list[BashHostedHookEntry]:
90
- """Return the ordered hosted-hook entries that apply to tool_name."""
91
+ """Return the ordered hosted-hook entries that apply to tool_name.
92
+
93
+ An entry whose mod plugin is on drops out, because the mod does its work.
94
+ """
91
95
  return [
92
96
  each_entry
93
97
  for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
94
98
  if tool_name in each_entry.applicable_tool_names
99
+ and not (
100
+ each_entry.replaced_by_plugin_name is not None
101
+ and is_mod_plugin_enabled(each_entry.replaced_by_plugin_name)
102
+ )
95
103
  ]
96
104
 
97
105
 
@@ -9,6 +9,9 @@ The broker form runs ``python``, so its ``-- claude -p ...`` tail passes.
9
9
  Interactive ``claude`` and its subcommands, such as ``claude plugin eval`` or
10
10
  ``claude --version``, pass.
11
11
 
12
+ A Claude Code cloud session, where ``CLAUDE_CODE_REMOTE`` is ``true``, has one
13
+ account, so the gate passes every command there.
14
+
12
15
  Hosted by ``blocking/bash_pre_tool_use_dispatcher.py`` for the Bash and
13
16
  PowerShell tools.
14
17
  """
@@ -16,6 +19,7 @@ PowerShell tools.
16
19
  from __future__ import annotations
17
20
 
18
21
  import json
22
+ import os
19
23
  import sys
20
24
  from pathlib import Path
21
25
 
@@ -34,6 +38,8 @@ try:
34
38
  BROKER_COMMAND_DENY_REASON,
35
39
  CLAUDE_PROFILE_LAUNCHER_PREFIX,
36
40
  CLAUDE_PROGRAM_NAME,
41
+ CLOUD_SESSION_ENV_TRUE_VALUE,
42
+ CLOUD_SESSION_ENV_VAR,
37
43
  GATE_HOOK_NAME,
38
44
  PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX,
39
45
  )
@@ -113,8 +119,15 @@ def starts_unbrokered_headless_claude(command: str) -> bool:
113
119
  )
114
120
 
115
121
 
122
+ def is_cloud_session() -> bool:
123
+ """Return True when this hook runs inside a Claude Code cloud session."""
124
+ return os.environ.get(CLOUD_SESSION_ENV_VAR, "").strip().lower() == CLOUD_SESSION_ENV_TRUE_VALUE
125
+
126
+
116
127
  def main() -> None:
117
- """Deny a headless Claude start that skips the broker, or stay quiet."""
128
+ """Deny a headless Claude start that skips the broker outside a cloud session, or stay quiet."""
129
+ if is_cloud_session():
130
+ return
118
131
  hook_payload = read_hook_input_dictionary_from_stdin()
119
132
  if hook_payload is None:
120
133
  return
@@ -31,9 +31,11 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_two_gates() -> None:
31
31
  BashHostedHookEntry(
32
32
  script_relative_path="blocking/headless_claude_broker_gate.py",
33
33
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
34
+ replaced_by_plugin_name="shell-guards",
34
35
  ),
35
36
  BashHostedHookEntry(
36
37
  script_relative_path="blocking/gh_global_account_switch_gate.py",
37
38
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
39
+ replaced_by_plugin_name="shell-guards",
38
40
  ),
39
41
  )
@@ -251,3 +251,49 @@ def test_emit_decision_sends_a_rewrite_through_the_permission_prompt(
251
251
  assert hook_specific["updatedInput"] == {
252
252
  "command": "MSYS2_ARG_CONV_EXCL='*' git show HEAD:a.py"
253
253
  }
254
+
255
+
256
+ def _enable_shell_guards(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
257
+ """Turn the shell-guards plugin on in a temp user settings file."""
258
+ user_directory = tmp_path / "user-config"
259
+ user_directory.mkdir()
260
+ (user_directory / "settings.json").write_text(
261
+ json.dumps({"enabledPlugins": {"shell-guards@mods-marketplace": True}}), encoding="utf-8"
262
+ )
263
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(user_directory))
264
+ monkeypatch.delenv("CLAUDE_CODE_REMOTE", raising=False)
265
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path))
266
+
267
+
268
+ def test_should_drop_the_gates_the_shell_guards_mod_holds_while_it_is_on(
269
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
270
+ ) -> None:
271
+ _enable_shell_guards(tmp_path, monkeypatch)
272
+ all_script_paths = [each_entry.script_relative_path for each_entry in select_applicable_entries(BASH_TOOL_NAME)]
273
+ assert all_script_paths == ["blocking/msys_rev_path_rewriter.py"]
274
+
275
+
276
+ def test_should_pass_a_headless_claude_command_while_the_shell_guards_mod_is_on(
277
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
278
+ ) -> None:
279
+ _enable_shell_guards(tmp_path, monkeypatch)
280
+ completed = subprocess.run(
281
+ [sys.executable, _DISPATCHER_SCRIPT],
282
+ check=False,
283
+ input=_bash_payload("claude -p 'say hello'"),
284
+ capture_output=True,
285
+ text=True,
286
+ encoding="utf-8",
287
+ env={**os.environ},
288
+ )
289
+ assert _decision_from_stdout(completed.stdout) == ("", "")
290
+
291
+
292
+ def test_should_deny_a_headless_claude_command_while_no_mod_is_on(
293
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
294
+ ) -> None:
295
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
296
+ monkeypatch.delenv("CLAUDE_CODE_REMOTE", raising=False)
297
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path))
298
+ completed = _run_process(_DISPATCHER_SCRIPT, _bash_payload("claude -p 'say hello'"))
299
+ assert _decision_from_stdout(completed.stdout)[0] == "deny"
@@ -86,3 +86,15 @@ def test_broker_command_payload_emits_nothing() -> None:
86
86
 
87
87
  def test_other_tool_payload_emits_nothing() -> None:
88
88
  assert _stdout_from_main({"tool_name": "Write", "tool_input": {"command": 'claude -p "task"'}}) == ""
89
+
90
+
91
+ def test_should_pass_bare_headless_claude_in_a_cloud_session(monkeypatch: pytest.MonkeyPatch) -> None:
92
+ monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
93
+ assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": 'claude -p "task"'}}) == ""
94
+
95
+
96
+ def test_should_report_a_cloud_session_only_when_the_marker_is_true(monkeypatch: pytest.MonkeyPatch) -> None:
97
+ monkeypatch.setenv("CLAUDE_CODE_REMOTE", "false")
98
+ assert gate.is_cloud_session() is False
99
+ monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
100
+ assert gate.is_cloud_session() is True
@@ -804,9 +804,7 @@ def test_dispatcher_allows_clean_apply_patch_add(tmp_path: Path) -> None:
804
804
  assert not is_deny, reason_text
805
805
 
806
806
 
807
- ALL_RETAINED_PATHS = {
808
- "advisory/migration_safety_advisor.py",
809
- }
807
+ ALL_RETAINED_PATHS: set[str] = set()
810
808
  ALL_RETIRED_PATHS = {
811
809
  "validation/hook_format_validator.py",
812
810
  "blocking/code_rules_enforcer.py",
package/hooks/conftest.py CHANGED
@@ -22,6 +22,10 @@ _CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
22
22
  "hooks_constants.harness_scratchpad_constants"
23
23
  ).CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME
24
24
 
25
+ _CLOUD_SESSION_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
26
+ "hooks_constants.headless_claude_broker_gate_constants"
27
+ ).CLOUD_SESSION_ENV_VAR
28
+
25
29
  _ROOT_ANCHORED_PROBE_PATH = "/tmp/scratch.py"
26
30
 
27
31
 
@@ -40,6 +44,20 @@ def isolate_from_live_session_scratchpad(monkeypatch: pytest.MonkeyPatch) -> Non
40
44
  monkeypatch.delenv(_CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME, raising=False)
41
45
 
42
46
 
47
+ @pytest.fixture(autouse=True)
48
+ def isolate_from_live_cloud_session(monkeypatch: pytest.MonkeyPatch) -> None:
49
+ """Remove the cloud session marker before each hooks test.
50
+
51
+ A Claude Code cloud session exports CLAUDE_CODE_REMOTE=true, and gates
52
+ that stand aside in cloud would then pass every case. Tests that exercise the
53
+ cloud path set the variable themselves.
54
+
55
+ Args:
56
+ monkeypatch: Pytest's environment-variable patcher, torn down after the test.
57
+ """
58
+ monkeypatch.delenv(_CLOUD_SESSION_ENVIRONMENT_VARIABLE_NAME, raising=False)
59
+
60
+
43
61
  @pytest.fixture
44
62
  def ephemeral_exempt_off(monkeypatch: pytest.MonkeyPatch) -> None:
45
63
  """Turn off the ephemeral scratch-path exemption for the current test.
@@ -9,14 +9,13 @@ This family injects reminders and task guidance when an agent starts, resumes, s
9
9
  - `session/working_style_prompt.py` injects the session's working style at session start.
10
10
  - `session/advisor_rules_prompt.py` injects advisor guidance when the built-in advisor is enabled.
11
11
  - `session/orchestrator_auto_starter.py` injects an orchestrator directive when its environment flag is enabled.
12
- - `session/issue_tracker_session_starter.py` injects issue tracker guidance when enabled for a registered repository.
13
12
  - `session/artifact_template_pointer.py` points an agent that starts an artifact page at `docs/templates/html-plan/README.md`. When the request names the older artifact page template, it points at `docs/templates/artifact-page/README.md`, which names the tracker template and the diagram template and which ask each fits.
14
13
  - `advisory/auto_mode_denial_quick_fix.py` proposes one `autoMode.allow` entry and a PowerShell block that writes it after an auto mode denial. It never retries the denied call.
15
14
 
16
15
  ## When it fires
17
16
 
18
17
  - `session/skill_loaded_reminder.py` runs on `PreToolUse` matcher `Agent|Task` at `10` seconds, `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
19
- - `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, `session/orchestrator_auto_starter.py`, and `session/issue_tracker_session_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
18
+ - `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, and `session/orchestrator_auto_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
20
19
  - `session/artifact_template_pointer.py` runs on `PreToolUse` matcher `Artifact|Skill` at `10` seconds in `hooks.json`. It speaks only on an `Artifact` call with action `quickstart` or a `Skill` call that loads `artifact-design`.
21
20
  - `advisory/auto_mode_denial_quick_fix.py` runs on `PermissionDenied`, matcher `*`, timeout `10` seconds in `hooks.json`.
22
21
 
@@ -31,7 +30,6 @@ Preconditions:
31
30
  - **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
32
31
  - **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
33
32
  - **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
34
- - **Issue tracker start.** Input is SessionStart with its opt-in flag and a registered checkout. Run `python -m pytest packages/claude-dev-env/hooks/session/test_issue_tracker_session_starter.py -q`. The adjacent test observes issue tracker context.
35
33
  - **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
36
34
  - **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
37
35
 
@@ -1,17 +1,15 @@
1
1
  # Write and Edit blocking
2
2
 
3
- This family decides whether an agent's file mutation can run. It also warns about unsafe migration edits before the agent changes a file.
3
+ This family decides whether an agent's file mutation can run.
4
4
 
5
5
  ## Checks
6
6
 
7
- - `blocking/pre_tool_use_dispatcher.py` combines hosted advisory output with its native description check and returns one permission decision.
8
- - `advisory/migration_safety_advisor.py` warns when an Edit or MultiEdit adds an unsafe Django migration operation.
7
+ - `blocking/pre_tool_use_dispatcher.py` runs its native description check and returns one permission decision.
9
8
  - `blocking/state_description_blocker.py` rejects historical or comparative prose in supported comments, docstrings, and Markdown.
10
9
 
11
10
  ## When it fires
12
11
 
13
12
  - `blocking/pre_tool_use_dispatcher.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds in `hooks.json`.
14
- - `advisory/migration_safety_advisor.py` runs inside that dispatcher on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. `ALL_HOSTED_HOOK_ENTRIES` selects `Edit` and `MultiEdit` for it.
15
13
  - `blocking/state_description_blocker.py` runs through the dispatcher's native evaluator on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. Its evaluator checks `Write`, `Edit`, and `MultiEdit` payloads and skips `apply_patch`.
16
14
 
17
15
  ## Proving it
@@ -21,12 +19,11 @@ Preconditions:
21
19
  - Run each command from the repository root with Python and pytest available. The tests use disposable paths where they need files.
22
20
 
23
21
  - **Dispatcher decision.** Input is a clean Write payload. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pre_tool_use_dispatcher.py -q`. The adjacent test checks that the dispatcher allows clean writes and carries denials from its checks.
24
- - **Migration warning.** Input is an Edit containing `migrations.RemoveField`. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_migration_safety_advisor.py -q`. The adjacent test observes an allow decision with a migration warning.
25
22
  - **Description gate.** Input is a Write containing historical prose in a Python comment. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_state_description_blocker.py -q`. The adjacent test observes a denial reason.
26
23
 
27
24
  ## Gotchas
28
25
 
29
- - `ALL_HOSTED_HOOK_ENTRIES` lists only the nonblocking migration advisor. The dispatcher calls the description evaluator through its native hook table.
26
+ - `ALL_HOSTED_HOOK_ENTRIES` is empty. The dispatcher calls the description evaluator through its native hook table.
30
27
  - The dispatcher runs hosted entries in roster order. A hosted advisory crash stays silent; a native blocking check can deny the write.
31
28
  - `scripts/policy_lint/adapter_detectors.py` also loads `blocking/state_description_blocker.py`. Check that adapter when changing its evaluator.
32
29
  - `bin/install.mjs` names folded and retired script paths so reinstall can remove stale registrations.
package/hooks/hooks.json CHANGED
@@ -279,11 +279,6 @@
279
279
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/orchestrator_auto_starter.py",
280
280
  "timeout": 10
281
281
  },
282
- {
283
- "type": "command",
284
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/issue_tracker_session_starter.py",
285
- "timeout": 10
286
- },
287
282
  {
288
283
  "type": "command",
289
284
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  from dataclasses import dataclass
11
11
 
12
+ from hooks_constants.mod_handoff_constants import SHELL_GUARDS_PLUGIN_NAME
13
+
12
14
  __all__ = [
13
15
  "DENY_DECISION",
14
16
  "ASK_DECISION",
@@ -48,10 +50,14 @@ class BashHostedHookEntry:
48
50
  script_relative_path: Hook path relative to the hooks/ directory.
49
51
  applicable_tool_names: Tool names this hook runs for. The dispatcher
50
52
  skips the hook when the payload's tool is not in this set.
53
+ replaced_by_plugin_name: The plugin whose mod holds this hook. The
54
+ dispatcher skips the hook while that plugin is on, so a session
55
+ runs the hook or the mod and never both.
51
56
  """
52
57
 
53
58
  script_relative_path: str
54
59
  applicable_tool_names: frozenset[str]
60
+ replaced_by_plugin_name: str | None = None
55
61
 
56
62
 
57
63
  ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
@@ -62,9 +68,11 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
62
68
  BashHostedHookEntry(
63
69
  script_relative_path="blocking/headless_claude_broker_gate.py",
64
70
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
71
+ replaced_by_plugin_name=SHELL_GUARDS_PLUGIN_NAME,
65
72
  ),
66
73
  BashHostedHookEntry(
67
74
  script_relative_path="blocking/gh_global_account_switch_gate.py",
68
75
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
76
+ replaced_by_plugin_name=SHELL_GUARDS_PLUGIN_NAME,
69
77
  ),
70
78
  )
@@ -3,7 +3,9 @@
3
3
  Holds the Claude program name and its profile-launcher prefix, the Windows
4
4
  launcher suffixes stripped before the name check, the two print-mode flags, the
5
5
  hook name the block log records, and the deny message that names the broker
6
- command to run instead.
6
+ command to run instead. It also names the environment variable a Claude Code
7
+ cloud session sets, where the gate stands aside because the session has one
8
+ account.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
@@ -16,6 +18,8 @@ __all__ = [
16
18
  "PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX",
17
19
  "GATE_HOOK_NAME",
18
20
  "BROKER_COMMAND_DENY_REASON",
21
+ "CLOUD_SESSION_ENV_VAR",
22
+ "CLOUD_SESSION_ENV_TRUE_VALUE",
19
23
  ]
20
24
 
21
25
  CLAUDE_PROGRAM_NAME: str = "claude"
@@ -24,6 +28,8 @@ ALL_WINDOWS_LAUNCHER_SUFFIXES: tuple[str, ...] = (".cmd", ".exe", ".ps1", ".bat"
24
28
  ALL_PRINT_MODE_FLAGS: frozenset[str] = frozenset({"-p", "--print"})
25
29
  PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX: str = "--print="
26
30
  GATE_HOOK_NAME: str = "headless_claude_broker_gate.py"
31
+ CLOUD_SESSION_ENV_VAR: str = "CLAUDE_CODE_REMOTE"
32
+ CLOUD_SESSION_ENV_TRUE_VALUE: str = "true"
27
33
  BROKER_COMMAND_DENY_REASON: str = (
28
34
  "BLOCKED: Start a headless Claude session through the account broker. "
29
35
  "Run: python \"$HOME/.claude/scripts/account_broker.py\" run --product claude "
@@ -0,0 +1,91 @@
1
+ """Tell a settings hook whether the mod plugin that holds its work is on.
2
+
3
+ Claude Code reads ``enabledPlugins`` from the user settings file, then the
4
+ project's ``.claude/settings.json``, then its ``.claude/settings.local.json``,
5
+ and a later file overrides an earlier one. A key names a plugin as
6
+ ``<plugin>@<marketplace>``, so a match on the plugin name holds wherever the
7
+ plugin ships.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+
16
+ from hooks_constants.mod_handoff_constants import (
17
+ ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER,
18
+ CLOUD_SESSION_ENV_TRUE_VALUE,
19
+ CLOUD_SESSION_ENV_VAR,
20
+ CLAUDE_CONFIG_DIR_ENV_VAR,
21
+ CLAUDE_PROJECT_DIR_ENV_VAR,
22
+ DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME,
23
+ ENABLED_PLUGINS_SETTINGS_KEY,
24
+ PLUGIN_MARKETPLACE_SEPARATOR,
25
+ PROJECT_SETTINGS_DIRECTORY_NAME,
26
+ USER_SETTINGS_FILE_NAME,
27
+ )
28
+
29
+
30
+ def all_settings_paths_in_precedence_order() -> list[Path]:
31
+ """Return the user, project and local settings files, lowest precedence first."""
32
+ configured_directory = os.environ.get(CLAUDE_CONFIG_DIR_ENV_VAR, "").strip()
33
+ user_directory = (
34
+ Path(configured_directory)
35
+ if configured_directory
36
+ else Path.home() / DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME
37
+ )
38
+ all_paths = [user_directory / USER_SETTINGS_FILE_NAME]
39
+ project_directory = os.environ.get(CLAUDE_PROJECT_DIR_ENV_VAR, "").strip() or os.getcwd()
40
+ project_settings_directory = Path(project_directory) / PROJECT_SETTINGS_DIRECTORY_NAME
41
+ all_paths.extend(
42
+ project_settings_directory / each_file_name
43
+ for each_file_name in ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER
44
+ )
45
+ return all_paths
46
+
47
+
48
+ def plugin_state_in_settings_file(settings_path: Path, plugin_name: str) -> bool | None:
49
+ """Return the file's on or off setting for plugin_name, or None when it names none.
50
+
51
+ Args:
52
+ settings_path: A Claude Code settings file.
53
+ plugin_name: The plugin name before the ``@marketplace`` part of the key.
54
+ """
55
+ try:
56
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
57
+ except (OSError, ValueError):
58
+ return None
59
+ if not isinstance(settings, dict):
60
+ return None
61
+ enabled_plugins = settings.get(ENABLED_PLUGINS_SETTINGS_KEY)
62
+ if not isinstance(enabled_plugins, dict):
63
+ return None
64
+ all_states = [
65
+ each_state
66
+ for each_key, each_state in enabled_plugins.items()
67
+ if isinstance(each_key, str)
68
+ and isinstance(each_state, bool)
69
+ and each_key.split(PLUGIN_MARKETPLACE_SEPARATOR, 1)[0] == plugin_name
70
+ ]
71
+ if not all_states:
72
+ return None
73
+ return any(all_states)
74
+
75
+
76
+ def is_mod_plugin_enabled(plugin_name: str) -> bool:
77
+ """Return True when the settings files leave plugin_name on outside a cloud session.
78
+
79
+ A cloud session runs no mods, so it keeps every hook whatever the settings say.
80
+
81
+ Args:
82
+ plugin_name: The plugin that holds the mod replacing the calling hook.
83
+ """
84
+ if os.environ.get(CLOUD_SESSION_ENV_VAR, "").strip().lower() == CLOUD_SESSION_ENV_TRUE_VALUE:
85
+ return False
86
+ is_enabled = False
87
+ for each_path in all_settings_paths_in_precedence_order():
88
+ state = plugin_state_in_settings_file(each_path, plugin_name)
89
+ if state is not None:
90
+ is_enabled = state
91
+ return is_enabled
@@ -0,0 +1,38 @@
1
+ """Names a settings hook reads to tell whether a mod plugin holds its work.
2
+
3
+ A converted hook stays silent when the plugin that holds its mod is on in
4
+ ``enabledPlugins``, so a session runs the hook or the mod and never both.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = [
10
+ "ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER",
11
+ "CLOUD_SESSION_ENV_TRUE_VALUE",
12
+ "CLOUD_SESSION_ENV_VAR",
13
+ "CLAUDE_CONFIG_DIR_ENV_VAR",
14
+ "CLAUDE_PROJECT_DIR_ENV_VAR",
15
+ "DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME",
16
+ "ENABLED_PLUGINS_SETTINGS_KEY",
17
+ "PLUGIN_MARKETPLACE_SEPARATOR",
18
+ "PROJECT_SETTINGS_DIRECTORY_NAME",
19
+ "SESSION_PROMPTS_PLUGIN_NAME",
20
+ "SHELL_GUARDS_PLUGIN_NAME",
21
+ "USER_SETTINGS_FILE_NAME",
22
+ ]
23
+
24
+ ENABLED_PLUGINS_SETTINGS_KEY = "enabledPlugins"
25
+ CLOUD_SESSION_ENV_VAR = "CLAUDE_CODE_REMOTE"
26
+ CLOUD_SESSION_ENV_TRUE_VALUE = "true"
27
+ PLUGIN_MARKETPLACE_SEPARATOR = "@"
28
+ SHELL_GUARDS_PLUGIN_NAME = "shell-guards"
29
+ SESSION_PROMPTS_PLUGIN_NAME = "session-prompts"
30
+ CLAUDE_CONFIG_DIR_ENV_VAR = "CLAUDE_CONFIG_DIR"
31
+ CLAUDE_PROJECT_DIR_ENV_VAR = "CLAUDE_PROJECT_DIR"
32
+ DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME = ".claude"
33
+ PROJECT_SETTINGS_DIRECTORY_NAME = ".claude"
34
+ USER_SETTINGS_FILE_NAME = "settings.json"
35
+ ALL_SETTINGS_FILE_NAMES_IN_PRECEDENCE_ORDER: tuple[str, ...] = (
36
+ "settings.json",
37
+ "settings.local.json",
38
+ )
@@ -82,10 +82,4 @@ class HostedHookEntry:
82
82
  native_module_name: str | None = field(default=None)
83
83
 
84
84
 
85
- ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
86
- HostedHookEntry(
87
- script_relative_path="advisory/migration_safety_advisor.py",
88
- applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
89
- is_blocking=False,
90
- ),
91
- )
85
+ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = ()
@@ -12,7 +12,7 @@ ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSH
12
12
  BashHostedHookEntry = ALL_CONSTANT_BINDINGS["BashHostedHookEntry"]
13
13
 
14
14
 
15
- def test_roster_hosts_the_msys_rewriter_the_broker_gate_and_the_gh_account_gate() -> None:
15
+ def test_roster_hosts_the_three_gates_and_hands_two_to_the_shell_guards_mod() -> None:
16
16
  assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
17
17
  BashHostedHookEntry(
18
18
  script_relative_path="blocking/msys_rev_path_rewriter.py",
@@ -21,9 +21,11 @@ def test_roster_hosts_the_msys_rewriter_the_broker_gate_and_the_gh_account_gate(
21
21
  BashHostedHookEntry(
22
22
  script_relative_path="blocking/headless_claude_broker_gate.py",
23
23
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
24
+ replaced_by_plugin_name="shell-guards",
24
25
  ),
25
26
  BashHostedHookEntry(
26
27
  script_relative_path="blocking/gh_global_account_switch_gate.py",
27
28
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
29
+ replaced_by_plugin_name="shell-guards",
28
30
  ),
29
31
  )
@@ -0,0 +1,100 @@
1
+ """Behavioral tests for the mod handoff check that silences a converted hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
12
+ if str(_HOOKS_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(_HOOKS_ROOT))
14
+
15
+ from hooks_constants.mod_handoff import (
16
+ all_settings_paths_in_precedence_order,
17
+ is_mod_plugin_enabled,
18
+ plugin_state_in_settings_file,
19
+ )
20
+
21
+
22
+ @pytest.fixture
23
+ def settings_homes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
24
+ """Point the user config and project directories at empty temp folders."""
25
+ user_directory = tmp_path / "user-config"
26
+ project_directory = tmp_path / "project"
27
+ (project_directory / ".claude").mkdir(parents=True)
28
+ user_directory.mkdir()
29
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(user_directory))
30
+ monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(project_directory))
31
+ monkeypatch.delenv("CLAUDE_CODE_REMOTE", raising=False)
32
+ return user_directory, project_directory
33
+
34
+
35
+ def _write_enabled_plugins(settings_path: Path, enabled_plugins: object) -> None:
36
+ settings_path.write_text(json.dumps({"enabledPlugins": enabled_plugins}), encoding="utf-8")
37
+
38
+
39
+ def test_should_list_user_then_project_then_local_settings(
40
+ settings_homes: tuple[Path, Path],
41
+ ) -> None:
42
+ user_directory, project_directory = settings_homes
43
+ assert all_settings_paths_in_precedence_order() == [
44
+ user_directory / "settings.json",
45
+ project_directory / ".claude" / "settings.json",
46
+ project_directory / ".claude" / "settings.local.json",
47
+ ]
48
+
49
+
50
+ def test_should_match_the_plugin_under_any_marketplace(tmp_path: Path) -> None:
51
+ settings_path = tmp_path / "settings.json"
52
+ _write_enabled_plugins(settings_path, {"shell-guards@any-marketplace": True})
53
+ assert plugin_state_in_settings_file(settings_path, "shell-guards") is True
54
+
55
+
56
+ def test_should_report_no_state_for_a_plugin_the_file_never_names(tmp_path: Path) -> None:
57
+ settings_path = tmp_path / "settings.json"
58
+ _write_enabled_plugins(settings_path, {"shell-guards-extra@market": True})
59
+ assert plugin_state_in_settings_file(settings_path, "shell-guards") is None
60
+
61
+
62
+ def test_should_report_no_state_for_a_missing_or_broken_file(tmp_path: Path) -> None:
63
+ broken_path = tmp_path / "broken.json"
64
+ broken_path.write_text("{not json", encoding="utf-8")
65
+ assert plugin_state_in_settings_file(tmp_path / "missing.json", "shell-guards") is None
66
+ assert plugin_state_in_settings_file(broken_path, "shell-guards") is None
67
+
68
+
69
+ def test_should_stay_off_when_no_settings_file_names_the_plugin(
70
+ settings_homes: tuple[Path, Path],
71
+ ) -> None:
72
+ assert is_mod_plugin_enabled("session-prompts") is False
73
+
74
+
75
+ def test_should_turn_on_from_the_user_settings(settings_homes: tuple[Path, Path]) -> None:
76
+ user_directory, _ = settings_homes
77
+ _write_enabled_plugins(user_directory / "settings.json", {"session-prompts@market": True})
78
+ assert is_mod_plugin_enabled("session-prompts") is True
79
+
80
+
81
+ def test_should_let_local_project_settings_turn_a_user_plugin_off(
82
+ settings_homes: tuple[Path, Path],
83
+ ) -> None:
84
+ user_directory, project_directory = settings_homes
85
+ _write_enabled_plugins(user_directory / "settings.json", {"session-prompts@market": True})
86
+ _write_enabled_plugins(
87
+ project_directory / ".claude" / "settings.local.json", {"session-prompts@market": False}
88
+ )
89
+ assert is_mod_plugin_enabled("session-prompts") is False
90
+
91
+
92
+ def test_should_keep_the_hook_in_a_cloud_session_with_the_plugin_on(
93
+ settings_homes: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch,
94
+ ) -> None:
95
+ _, project_directory = settings_homes
96
+ _write_enabled_plugins(
97
+ project_directory / ".claude" / "settings.json", {"session-prompts@market": True}
98
+ )
99
+ monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
100
+ assert is_mod_plugin_enabled("session-prompts") is False