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.
- package/.agents/skills/build-eval/SKILL.md +3 -0
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +1 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +9 -1
- package/hooks/blocking/headless_claude_broker_gate.py +14 -1
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +2 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +46 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -3
- package/hooks/conftest.py +18 -0
- package/hooks/features/session-context.md +1 -3
- package/hooks/features/write-edit-blocking.md +3 -6
- package/hooks/hooks.json +0 -5
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +7 -1
- package/hooks/hooks_constants/mod_handoff.py +91 -0
- package/hooks/hooks_constants/mod_handoff_constants.py +38 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -7
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -1
- package/hooks/hooks_constants/test_mod_handoff.py +100 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +2 -23
- package/hooks/session/advisor_rules_prompt.py +5 -1
- package/hooks/session/orchestrator_auto_starter.py +5 -1
- package/hooks/session/task_tool_prompt.py +5 -1
- package/hooks/session/test_advisor_rules_prompt.py +11 -0
- package/hooks/session/test_orchestrator_auto_starter.py +22 -0
- package/hooks/session/test_task_tool_prompt.py +15 -0
- package/hooks/session/test_working_style_prompt.py +15 -0
- package/hooks/session/working_style_prompt.py +5 -1
- package/package.json +1 -1
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +24 -0
- package/scripts/review_closure_model.py +48 -12
- package/scripts/test_files/review_closure/codex_comments.json +1266 -0
- package/scripts/tests/test_review_closure_model.py +130 -0
- package/hooks/advisory/migration_safety_advisor.py +0 -74
- package/hooks/advisory/test_migration_safety_advisor.py +0 -204
- package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +0 -22
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +0 -7
- package/hooks/session/issue_tracker_session_starter.py +0 -136
- 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([
|
package/bin/install.test.mjs
CHANGED
|
@@ -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`,
|
|
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.
|
|
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`
|
|
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`
|
|
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
|
|
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
|