claude-dev-env 2.21.0 → 2.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ """Production-path tests for the AskUserQuestion plain-language blocker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ HOOK_PATH = Path(__file__).with_name("plain_language_blocker.py")
14
+
15
+
16
+ def _run_hook(
17
+ payload: dict[str, object], *, enabled: bool
18
+ ) -> subprocess.CompletedProcess[str]:
19
+ environment = os.environ.copy()
20
+ if enabled:
21
+ environment["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
22
+ else:
23
+ environment.pop("CLAUDE_PROSE_STYLE_ENFORCEMENT", None)
24
+ return subprocess.run(
25
+ [sys.executable, str(HOOK_PATH)],
26
+ input=json.dumps(payload),
27
+ capture_output=True,
28
+ check=False,
29
+ env=environment,
30
+ text=True,
31
+ )
32
+
33
+
34
+ def _decision(result: subprocess.CompletedProcess[str]) -> str | None:
35
+ if not result.stdout:
36
+ return None
37
+ parsed_payload = json.loads(result.stdout)
38
+ return parsed_payload.get("hookSpecificOutput", {}).get("permissionDecision")
39
+
40
+
41
+ def test_default_off_allows_formal_question_word() -> None:
42
+ result = _run_hook(
43
+ {
44
+ "tool_name": "AskUserQuestion",
45
+ "tool_input": {
46
+ "questions": [{"question": "Should we utilize this path?"}]
47
+ },
48
+ },
49
+ enabled=False,
50
+ )
51
+ assert result.returncode == 0
52
+ assert result.stdout == ""
53
+
54
+
55
+ def test_enabled_blocks_formal_question_word() -> None:
56
+ result = _run_hook(
57
+ {
58
+ "tool_name": "AskUserQuestion",
59
+ "tool_input": {
60
+ "questions": [
61
+ {
62
+ "question": "Should we utilize this path?",
63
+ "options": [{"description": "Initiate the migration."}],
64
+ }
65
+ ]
66
+ },
67
+ },
68
+ enabled=True,
69
+ )
70
+ assert result.returncode == 0
71
+ assert _decision(result) == "deny"
72
+ assert "utilize -> use" in result.stdout
73
+ assert "initiate -> start" in result.stdout
74
+
75
+
76
+ def test_exact_code_url_and_path_text_is_exempt() -> None:
77
+ result = _run_hook(
78
+ {
79
+ "tool_name": "AskUserQuestion",
80
+ "tool_input": {
81
+ "questions": [
82
+ {
83
+ "question": (
84
+ "Use `utilize` in src/initiate.py or "
85
+ "https://example.test/utilize."
86
+ )
87
+ }
88
+ ]
89
+ },
90
+ },
91
+ enabled=True,
92
+ )
93
+ assert result.returncode == 0
94
+ assert result.stdout == ""
95
+
96
+
97
+ def test_other_tools_are_ignored() -> None:
98
+ result = _run_hook(
99
+ {
100
+ "tool_name": "Write",
101
+ "tool_input": {"content": "Utilize the existing helper."},
102
+ },
103
+ enabled=True,
104
+ )
105
+ assert result.returncode == 0
106
+ assert result.stdout == ""
107
+
108
+
109
+ @pytest.mark.parametrize("raw_input", ["", "[]", "not json"])
110
+ def test_invalid_input_is_fail_open(raw_input: str) -> None:
111
+ result = subprocess.run(
112
+ [sys.executable, str(HOOK_PATH)],
113
+ input=raw_input,
114
+ capture_output=True,
115
+ check=False,
116
+ text=True,
117
+ )
118
+ assert result.returncode == 0
119
+ assert result.stdout == ""
@@ -47,7 +47,10 @@ def test_allows_proactive_status() -> None:
47
47
 
48
48
  def test_corrective_message_points_to_native_windows_app() -> None:
49
49
  assert "native Windows app" in CORRECTIVE_MESSAGE
50
+ assert "Invoke-Item -LiteralPath" in CORRECTIVE_MESSAGE
50
51
  assert "Show-Asset.ps1" not in CORRECTIVE_MESSAGE
52
+ assert "Cursor.exe" in CORRECTIVE_MESSAGE
53
+ assert "EPIPE" in CORRECTIVE_MESSAGE
51
54
 
52
55
 
53
56
  def test_corrective_message_names_proactive_escape_hatch() -> None:
package/hooks/hooks.json CHANGED
@@ -22,6 +22,16 @@
22
22
  }
23
23
  ]
24
24
  },
25
+ {
26
+ "matcher": "apply_patch",
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py",
31
+ "timeout": 60
32
+ }
33
+ ]
34
+ },
25
35
  {
26
36
  "matcher": "Bash|PowerShell",
27
37
  "hooks": [
@@ -80,6 +90,11 @@
80
90
  {
81
91
  "matcher": "AskUserQuestion",
82
92
  "hooks": [
93
+ {
94
+ "type": "command",
95
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/plain_language_blocker.py",
96
+ "timeout": 10
97
+ },
83
98
  {
84
99
  "type": "command",
85
100
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/ask_user_question_shape_blocker.py",
@@ -21,6 +21,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
21
21
  | `code_rules_path_utils_constants.py` | Path-matching helpers used by the code-rules check modules |
22
22
  | `command_dispatch_constants.py` | Command-word regex, command-key access pattern, tokenization pattern, and anchors for the unanchored command-dispatch meta-gate |
23
23
  | `conventional_pr_title_gate_constants.py` | Bash tool name, gh executable basenames and pr create/edit subcommand tokens, title/repo flag names, semantic-title-CI workflow markers, the Conventional Commits type list and title pattern, the semantic-pull-request action `types:` input pattern, and block-message text for the conventional-PR-title gate |
24
+ | `cursor_cli_python_misfire_blocker_constants.py` | Detection patterns and deny message for Cursor launches that mistreat a Python code-rules-gate script |
24
25
  | `dead_argparse_argument_constants.py` | Patterns for detecting unused argparse arguments |
25
26
  | `dead_config_field_constants.py` | Patterns for detecting unused `*Config` / `*Selectors` dataclass fields |
26
27
  | `dead_dataclass_field_constants.py` | Patterns for detecting unused dataclass fields |
@@ -63,7 +64,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
63
64
  | `pytest_testpaths_orphan_blocker_constants.py` | Marker filename, section and key names, test-file pattern, search budget, and block-message text for the pytest unregistered-test-directory blocker |
64
65
  | `python_style_checks_constants.py` | Command-line argument count and blank-line count between top-level functions for the style validator |
65
66
  | `ruff_integration_constants.py` | ``NO_COLOR`` / ``FORCE_COLOR`` environment variable names for plain ruff diagnostics |
66
- | `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
67
+ | `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the `Invoke-Item` block message for the open-locally attach blocker |
67
68
  | `sensitive_file_protector_constants.py` | Sensitive filename patterns, committed-template suffixes that earn an exemption, and the deny decision and message template for `sensitive_file_protector` |
68
69
  | `session_edit_stage_gate_constants.py` | Tracker filename prefix/suffix, JSON payload key, edit tool name set, session-id sanitize pattern, lock filename suffix and lock-acquire timing, git diff command, commit flag escapes, and deny-message template shared by the session edit stage gate trio |
69
70
  | `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
@@ -58,6 +58,10 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
58
58
  BashHostedHookEntry("blocking/gh_body_arg_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
59
59
  BashHostedHookEntry("blocking/shell_substitution_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
60
60
  BashHostedHookEntry("blocking/piped_pytest_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
61
+ BashHostedHookEntry(
62
+ "blocking/cursor_cli_python_misfire_blocker.py",
63
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
64
+ ),
61
65
  BashHostedHookEntry(
62
66
  "blocking/unscoped_search_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
63
67
  ),
@@ -0,0 +1,79 @@
1
+ """Constants for the Cursor-vs-Python gate misfire PreToolUse blocker.
2
+
3
+ Blocks Shell commands that launch Cursor's CLI or GUI binary against a Python
4
+ script with code-rules-gate flags. That path feeds unknown options into
5
+ Cursor's main process ``resolveArgs``, where ``console.warn`` on a closed pipe
6
+ raises an EPIPE error dialog and still opens the script as an editor path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ __all__ = [
14
+ "ALL_SUPPORTED_TOOL_NAMES",
15
+ "BASH_TOOL_NAME",
16
+ "POWERSHELL_TOOL_NAME",
17
+ "TOOL_NAME_KEY",
18
+ "TOOL_INPUT_KEY",
19
+ "COMMAND_KEY",
20
+ "HOOK_SPECIFIC_OUTPUT_KEY",
21
+ "HOOK_EVENT_NAME_KEY",
22
+ "HOOK_EVENT_NAME",
23
+ "PERMISSION_DECISION_KEY",
24
+ "DENY_DECISION",
25
+ "PERMISSION_DECISION_REASON_KEY",
26
+ "CALLING_HOOK_NAME",
27
+ "CURSOR_LAUNCH_PATTERN",
28
+ "GATE_SCRIPT_PATTERN",
29
+ "GATE_FLAG_PATTERN",
30
+ "PYTHON_PATH_PATTERN",
31
+ "CORRECTIVE_MESSAGE",
32
+ ]
33
+
34
+ BASH_TOOL_NAME = "Bash"
35
+ POWERSHELL_TOOL_NAME = "PowerShell"
36
+ ALL_SUPPORTED_TOOL_NAMES: frozenset[str] = frozenset(
37
+ {BASH_TOOL_NAME, POWERSHELL_TOOL_NAME}
38
+ )
39
+
40
+ TOOL_NAME_KEY = "tool_name"
41
+ TOOL_INPUT_KEY = "tool_input"
42
+ COMMAND_KEY = "command"
43
+
44
+ HOOK_SPECIFIC_OUTPUT_KEY = "hookSpecificOutput"
45
+ HOOK_EVENT_NAME_KEY = "hookEventName"
46
+ HOOK_EVENT_NAME = "PreToolUse"
47
+ PERMISSION_DECISION_KEY = "permissionDecision"
48
+ DENY_DECISION = "deny"
49
+ PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
50
+
51
+ CALLING_HOOK_NAME = "cursor_cli_python_misfire_blocker.py"
52
+
53
+ CURSOR_LAUNCH_PATTERN = re.compile(
54
+ r"(?i)"
55
+ r"(?:"
56
+ r"(?:^|[\s;&|])(?:cursor(?:\.cmd|\.exe)?)(?=\s|[\"']|$)"
57
+ r"|"
58
+ r"[\\/](?:cursor(?:\.cmd|\.exe)?)(?=\s|[\"']|$)"
59
+ r")"
60
+ )
61
+ GATE_SCRIPT_PATTERN = re.compile(r"(?i)code_rules_gate\.py\b")
62
+ GATE_FLAG_PATTERN = re.compile(
63
+ r"(?i)(?:^|[\s,\"'])(?:--base|--staged|--repo-root|--only-under)"
64
+ r"(?:\s|=|$|[\"'])"
65
+ )
66
+ PYTHON_PATH_PATTERN = re.compile(r"(?i)\.py(?:\s|$|['\":])")
67
+
68
+ CORRECTIVE_MESSAGE = (
69
+ "BLOCKED [cursor-python-misfire]: Cursor's CLI/GUI was invoked against a "
70
+ "Python script with code-rules-gate flags. Cursor treats those flags as "
71
+ "unknown options, warns on a closed pipe, and raises an EPIPE error dialog "
72
+ "while still opening the script as an editor path.\n\n"
73
+ "To run the gate:\n"
74
+ " python <path-to>/code_rules_gate.py --base origin/main\n\n"
75
+ "To open a file on screen in its native Windows app:\n"
76
+ " Invoke-Item -LiteralPath '<path>'\n\n"
77
+ "Do not pass --base, --staged, --repo-root, or --only-under to cursor / "
78
+ "Cursor.exe."
79
+ )
@@ -0,0 +1,26 @@
1
+ """Configuration for the AskUserQuestion plain-language blocker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ ALL_PLAIN_LANGUAGE_TERM_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
8
+ (re.compile(r"\butilize\b", re.IGNORECASE), "use"),
9
+ (re.compile(r"\binitiate\b", re.IGNORECASE), "start"),
10
+ (re.compile(r"\bsufficient\b", re.IGNORECASE), "enough"),
11
+ (re.compile(r"\bprior to\b", re.IGNORECASE), "before"),
12
+ (re.compile(r"\bin order to\b", re.IGNORECASE), "to"),
13
+ )
14
+
15
+ FENCED_CODE_PATTERN = re.compile(r"```[\s\S]*?```")
16
+ INLINE_CODE_PATTERN = re.compile(r"`[^`\n]+`")
17
+ URL_PATTERN = re.compile(r"https?://\S+", re.IGNORECASE)
18
+ FILE_PATH_PATTERN = re.compile(
19
+ r"(?<!\w)(?:[A-Za-z]:[\\/]|\.\.?[\\/])?[\w.-]+(?:[\\/][\w.-]+)+"
20
+ )
21
+
22
+ PLAIN_LANGUAGE_BLOCK_PREFIX = "BLOCKED: [PLAIN_LANGUAGE] Use familiar words: "
23
+ PLAIN_LANGUAGE_TERM_SEPARATOR = "; "
24
+ PLAIN_LANGUAGE_NOTICE = (
25
+ "Plain-language check: use familiar words in the question and its options."
26
+ )
@@ -7,7 +7,11 @@ PROACTIVE_STATUS: str = "proactive"
7
7
  CORRECTIVE_MESSAGE: str = (
8
8
  "BLOCKED [open-locally]: SendUserFile attaches a file to the session, which "
9
9
  "does not let the user see it while they are at the terminal. Open the file on "
10
- "screen with its native Windows app. Open every path the user named.\n"
10
+ "screen with its native Windows app:\n"
11
+ " Invoke-Item -LiteralPath '<path>'\n"
12
+ "Open every path the user named. Do not launch Cursor.exe or cursor.cmd to open "
13
+ "a file — unknown flags on that path raise an EPIPE error dialog in Cursor's "
14
+ "main process.\n"
11
15
  "The one allowed attach is a phone push: when the user has stepped away and you "
12
16
  'want the file to reach their phone, call SendUserFile with status "proactive".'
13
17
  )
@@ -27,6 +27,7 @@ _EXPECTED_BASH_ORDER = (
27
27
  "blocking/gh_body_arg_blocker.py",
28
28
  "blocking/shell_substitution_blocker.py",
29
29
  "blocking/piped_pytest_blocker.py",
30
+ "blocking/cursor_cli_python_misfire_blocker.py",
30
31
  "blocking/unscoped_search_blocker.py",
31
32
  "blocking/nas_ssh_binary_enforcer.py",
32
33
  "blocking/volatile_path_in_post_blocker.py",
@@ -41,6 +42,7 @@ _EXPECTED_BASH_ORDER = (
41
42
  )
42
43
 
43
44
  _POWERSHELL_APPLICABLE = (
45
+ "blocking/cursor_cli_python_misfire_blocker.py",
44
46
  "blocking/unscoped_search_blocker.py",
45
47
  "blocking/pii_prevention_blocker.py",
46
48
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.21.0",
3
+ "version": "2.21.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -520,8 +520,9 @@ def render_codex_failure_blast_radius(rule_content: str) -> str:
520
520
  def _build_codex_instruction_projection(config: MaterializerConfig) -> PlannedFile | None:
521
521
  """Build the managed AGENTS.md projection when the canonical rule is present."""
522
522
  source_path = config.source_root / failure_blast_radius_rule_relative_path
523
- if not source_path.is_file():
523
+ if not source_path.exists() and not _is_reparse_point(source_path):
524
524
  return None
525
+ source_path = _validated_source_file(config, failure_blast_radius_rule_relative_path, "failure blast-radius rule")
525
526
  rule_content = source_path.read_text(encoding="utf-8")
526
527
  projected_content = render_codex_failure_blast_radius(rule_content)
527
528
  return PlannedFile(
@@ -545,6 +546,24 @@ def _read_json_object(file_path: Path, description: str) -> dict[str, object]:
545
546
  return {each_key: each_record for each_key, each_record in parsed_json.items()}
546
547
 
547
548
 
549
+ def _validated_source_file(
550
+ config: MaterializerConfig, relative_path: str, description: str
551
+ ) -> Path:
552
+ """Resolve one source file while rejecting reparse points and escapes."""
553
+ source_path = config.source_root.joinpath(*relative_path.split(path_separator))
554
+ current_path = config.source_root
555
+ for each_part in relative_path.split(path_separator):
556
+ current_path /= each_part
557
+ if current_path.exists() and _is_reparse_point(current_path):
558
+ raise MaterializerRunFatal(
559
+ f"{description} source reparse point is not allowed: {relative_path}"
560
+ )
561
+ resolved_path = _validate_containment(config.source_root, source_path)
562
+ if not resolved_path.is_file():
563
+ raise MaterializerError(f"{description} source file is missing: {relative_path}")
564
+ return resolved_path
565
+
566
+
548
567
  def _validated_agent_iterable(candidate: object) -> tuple[ClaudeAgent, ...]:
549
568
  """Validate the optional agent iterable used by the legacy call form."""
550
569
  if not isinstance(candidate, Iterable):
@@ -571,7 +590,11 @@ def _find_codex_hook_source(config: MaterializerConfig) -> Path | None:
571
590
  config.source_root / codex_hook_manifest_source_path,
572
591
  config.source_root / Path(codex_hook_manifest_source_path).name,
573
592
  )
574
- return next((each_path for each_path in all_candidates if each_path.is_file()), None)
593
+ for each_path in all_candidates:
594
+ if each_path.exists() or _is_reparse_point(each_path):
595
+ relative_path = each_path.relative_to(config.source_root).as_posix()
596
+ return _validated_source_file(config, relative_path, "Codex hook manifest")
597
+ return None
575
598
 
576
599
 
577
600
  def _resolved_codex_enforcer_command(config: MaterializerConfig) -> str:
@@ -635,6 +658,41 @@ def _is_code_rules_enforcer_hook(all_hook_record: dict[str, object]) -> bool:
635
658
  )
636
659
 
637
660
 
661
+ def _codex_enforcer_hooks(all_manifest: object) -> tuple[dict[str, object], ...]:
662
+ """Return enforcer records from the target apply-patch matcher."""
663
+ if not isinstance(all_manifest, dict):
664
+ return ()
665
+ all_events = all_manifest.get("hooks")
666
+ if not isinstance(all_events, dict):
667
+ return ()
668
+ all_pre_tool_use = all_events.get(codex_hook_event_name)
669
+ if not isinstance(all_pre_tool_use, list):
670
+ return ()
671
+ all_enforcer_hooks: list[dict[str, object]] = []
672
+ for each_entry in all_pre_tool_use:
673
+ if not isinstance(each_entry, dict) or each_entry.get("matcher") != codex_hook_matcher:
674
+ continue
675
+ for each_hook in _hook_records(each_entry.get("hooks", [])):
676
+ if _is_code_rules_enforcer_hook(each_hook):
677
+ all_enforcer_hooks.append(each_hook)
678
+ return tuple(all_enforcer_hooks)
679
+
680
+
681
+ def _has_modified_codex_enforcer_hook(
682
+ current_bytes: bytes, planned_content: ManagedContent
683
+ ) -> bool:
684
+ """Report whether an existing enforcer record differs from the plan."""
685
+ try:
686
+ current_manifest = json.loads(current_bytes.decode("utf-8"))
687
+ planned_manifest = json.loads(content_to_bytes(planned_content).decode("utf-8"))
688
+ except (UnicodeDecodeError, json.JSONDecodeError):
689
+ return True
690
+ current_hooks = _codex_enforcer_hooks(current_manifest)
691
+ if not current_hooks:
692
+ return False
693
+ return current_hooks != _codex_enforcer_hooks(planned_manifest)
694
+
695
+
638
696
  def _merge_codex_hook_manifest(
639
697
  all_target_manifest: dict[str, object], all_focused_hook: dict[str, object]
640
698
  ) -> dict[str, object]:
@@ -710,11 +768,9 @@ def _build_codex_hook_dependency_projection(
710
768
  """Build the reviewed source files required by the target enforcer."""
711
769
  all_dependencies: list[PlannedFile] = []
712
770
  for each_relative_path in codex_hook_dependency_manifest:
713
- source_path = config.source_root / each_relative_path
714
- if not source_path.is_file():
715
- raise MaterializerError(
716
- f"source file is missing from the reviewed hook source list: {each_relative_path}"
717
- )
771
+ source_path = _validated_source_file(
772
+ config, each_relative_path, "reviewed Codex hook dependency"
773
+ )
718
774
  try:
719
775
  dependency_content = source_path.read_bytes()
720
776
  except OSError as error:
@@ -1119,6 +1175,7 @@ def _record_target_state(config: MaterializerConfig, planned_file: PlannedFile,
1119
1175
 
1120
1176
  target absent -> ok: publish
1121
1177
  on-disk bytes == planned bytes -> ok: unchanged, no write
1178
+ _has_modified_codex_enforcer_hook -> flag: conflicted, preserved untouched
1122
1179
  on-disk hash == manifest hash -> ok: publish, the tool owns these bytes
1123
1180
  on-disk hash != manifest hash -> flag: conflicted, preserved untouched
1124
1181
 
@@ -1140,6 +1197,9 @@ def _record_target_state(config: MaterializerConfig, planned_file: PlannedFile,
1140
1197
  _record_matching_target(report, planned_file.target_relative_path, previous_record)
1141
1198
  return target_path, current_bytes, False
1142
1199
  if planned_file.action == codex_hook_merge_action:
1200
+ if _has_modified_codex_enforcer_hook(current_bytes, planned_file.content):
1201
+ _record_target_conflict(report, planned_file.target_relative_path, previous_record)
1202
+ return target_path, current_bytes, False
1143
1203
  return target_path, current_bytes, True
1144
1204
  if _is_pristine_managed(previous_record, current_bytes):
1145
1205
  return target_path, current_bytes, True