claude-dev-env 8.52.1 → 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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.52.1",
3
+ "version": "8.53.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {