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.
@@ -6,9 +6,11 @@
6
6
 
7
7
  Run `codex-compat materialize --source-root <claude-root> --target-root <codex-root>`. The command defaults to a dry run; add `--apply` to publish files. Use `--python <command>` or `CODEX_COMPAT_PYTHON` to select Python. If no usable interpreter is found, the command reports that condition. The launcher passes an argv array, never a shell command.
8
8
 
9
- The Python materializer maps Claude `_shared/`, `agents/`, `hooks/`, `rules/`, and `scripts/` into the target according to the package's compatibility materialization rules. Claude agent frontmatter is converted to Codex TOML metadata. Unsupported Claude metadata is reported, rather than silently treated as equivalent.
9
+ The Python materializer maps Claude `_shared/`, `agents/`, `hooks/`, `rules/`, and `scripts/` into the target according to the package's compatibility materialization rules. Claude agent frontmatter is converted to Codex TOML metadata. The canonical failure blast-radius rule projects its repository-instruction excerpt into a managed `AGENTS.md` file. Claude metadata reports its supported-field shape.
10
10
 
11
- Rules, hooks, and scripts that have no safe Codex runtime equivalent remain inert or source-only. They are preserved for inspection and are not executed as translated target tools. The capability bridge likewise emits declarative records only; it never invokes the translated surface.
11
+ The Codex hook projection merges a managed `apply_patch` entry for `code_rules_enforcer.py` into the target `hooks.json`. Existing Codex hook entries keep their order, repeated enforcer entries collapse to one deterministic record, and the command resolves under the target root. The enforcer reads the patch command, reconstructs every file's pre-edit and projected post-edit content, and returns a blocking diagnostic for patch shapes requiring correction or code-rule violations. The existing Claude `Write`, `Edit`, and `MultiEdit` dispatcher keeps its current order and behavior.
12
+
13
+ The capability bridge emits declarative records and leaves translated surfaces for their owning runtime.
12
14
 
13
15
  Materialization uses a compatibility manifest to identify generated files. Dry runs report the plan without writing. Apply mode uses safe link/copy fallback where linking is unavailable, writes atomically, removes only stale managed files, and rolls back managed changes on failure. A failed rollback reports that reconciliation is required.
14
16
 
@@ -71,6 +71,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
71
71
  | `bot_mention_comment_blocker.py` | PreToolUse (Write/Edit) | PR review comments that @-mention a bot |
72
72
  | `claude_md_orphan_file_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | Per-directory `CLAUDE.md` table cells naming a bare filename absent from the directory subtree |
73
73
  | `conventional_pr_title_gate.py` | PreToolUse (Bash) | `gh pr create`/`gh pr edit` with a `--title` that is not a Conventional Commit, in a repo whose CI runs a semantic-pull-request title check |
74
+ | `cursor_cli_python_misfire_blocker.py` | PreToolUse (Bash/PowerShell) | `cursor` / `Cursor.exe` launched against a Python script with code-rules-gate flags, which raises Cursor's EPIPE unknown-option dialog |
74
75
  | `destructive_command_blocker.py` | PreToolUse (Bash/PowerShell) | Shell commands with destructive literals (`rm -rf`, `git reset --hard`, etc.) |
75
76
  | `docstring_rule_gate_count_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | A stale spelled-out gate-validator count in `docstring-prose-matches-implementation.md` — the "N more gate validators" / "M gated slices" count drifting from the `check_docstring_*` validators the prose names |
76
77
  | `duplicate_rmtree_helper_blocker.py` | PreToolUse (Write/Edit) | A local re-definition of the Windows-safe rmtree helper trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`) in place of importing a shared helper |
@@ -94,7 +95,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
94
95
  | `precommit_code_rules_gate.py` | PreToolUse (Bash) | Staged changes that fail the CODE_RULES gate at commit time |
95
96
  | `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
96
97
  | `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
97
- | `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to opening the file with its native Windows app |
98
+ | `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to `Invoke-Item -LiteralPath` for the native Windows app |
98
99
  | `sensitive_file_protector.py` | PreToolUse (Write/Edit/MultiEdit) | Writes to sensitive credential or config files |
99
100
  | `session_edit_stage_gate.py` | PreToolUse (Bash) | A `git commit` that would drop files edited this session because they are tracked but left unstaged |
100
101
  | `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: deny Cursor launches that mistreat a Python gate script.
3
+
4
+ Agents sometimes run ``cursor code_rules_gate.py --base origin/main`` (or the
5
+ same shape through ``Cursor.exe`` / ``cursor.cmd``) when the intent is either
6
+ to execute the gate or to open a file. Cursor's main process then hits
7
+ ``onUnknownOption`` for the gate flags, ``console.warn`` writes to a closed
8
+ pipe, and Windows shows an EPIPE error dialog — while still opening the
9
+ ``.py`` path in the editor.
10
+
11
+ ::
12
+
13
+ cursor code_rules_gate.py --base origin/main flag
14
+ Cursor.exe gate.py --staged flag
15
+ cursor.cmd path/to/x.py --repo-root . flag
16
+ cursor code_rules_gate.py ok: editor open, no gate flags
17
+ cursor -g file.py:10 ok: editor goto, no gate flags
18
+ cursor README.md ok: open without gate flags
19
+ python code_rules_gate.py --base origin/main ok: real gate run
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
29
+ if _hooks_dir not in sys.path:
30
+ sys.path.insert(0, _hooks_dir)
31
+
32
+ from hooks_constants.cursor_cli_python_misfire_blocker_constants import ( # noqa: E402
33
+ ALL_SUPPORTED_TOOL_NAMES,
34
+ CALLING_HOOK_NAME,
35
+ COMMAND_KEY,
36
+ CORRECTIVE_MESSAGE,
37
+ CURSOR_LAUNCH_PATTERN,
38
+ DENY_DECISION,
39
+ GATE_FLAG_PATTERN,
40
+ GATE_SCRIPT_PATTERN,
41
+ HOOK_EVENT_NAME,
42
+ HOOK_EVENT_NAME_KEY,
43
+ HOOK_SPECIFIC_OUTPUT_KEY,
44
+ PERMISSION_DECISION_KEY,
45
+ PERMISSION_DECISION_REASON_KEY,
46
+ PYTHON_PATH_PATTERN,
47
+ TOOL_INPUT_KEY,
48
+ TOOL_NAME_KEY,
49
+ )
50
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
51
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
52
+ read_hook_input_dictionary_from_stdin,
53
+ )
54
+
55
+
56
+ def is_cursor_python_gate_misfire(command: str) -> bool:
57
+ """Return True when *command* launches Cursor against a gate-shaped Python run.
58
+
59
+ Args:
60
+ command: Raw Bash or PowerShell command string from the tool input.
61
+
62
+ Returns:
63
+ True when Cursor is the launch target, a gate CLI flag is present, and
64
+ the operands name the gate script or another ``.py`` path.
65
+ """
66
+ if not command or not CURSOR_LAUNCH_PATTERN.search(command):
67
+ return False
68
+ if not GATE_FLAG_PATTERN.search(command):
69
+ return False
70
+ return bool(GATE_SCRIPT_PATTERN.search(command) or PYTHON_PATH_PATTERN.search(command))
71
+
72
+
73
+ def main() -> None:
74
+ hook_input = read_hook_input_dictionary_from_stdin()
75
+ if hook_input is None:
76
+ sys.exit(0)
77
+
78
+ tool_name = hook_input.get(TOOL_NAME_KEY, "")
79
+ if tool_name not in ALL_SUPPORTED_TOOL_NAMES:
80
+ sys.exit(0)
81
+
82
+ tool_input = hook_input.get(TOOL_INPUT_KEY) or {}
83
+ if not isinstance(tool_input, dict):
84
+ sys.exit(0)
85
+ command = tool_input.get(COMMAND_KEY, "")
86
+ if not isinstance(command, str) or not is_cursor_python_gate_misfire(command):
87
+ sys.exit(0)
88
+
89
+ deny_payload = {
90
+ HOOK_SPECIFIC_OUTPUT_KEY: {
91
+ HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
92
+ PERMISSION_DECISION_KEY: DENY_DECISION,
93
+ PERMISSION_DECISION_REASON_KEY: CORRECTIVE_MESSAGE,
94
+ }
95
+ }
96
+ log_hook_block(
97
+ calling_hook_name=CALLING_HOOK_NAME,
98
+ hook_event=HOOK_EVENT_NAME,
99
+ block_reason=CORRECTIVE_MESSAGE,
100
+ tool_name=str(tool_name),
101
+ )
102
+ print(json.dumps(deny_payload))
103
+ sys.stdout.flush()
104
+ sys.exit(0)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook that checks AskUserQuestion prose when enabled."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from collections.abc import Mapping, Sequence
9
+ from pathlib import Path
10
+
11
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
12
+ if _hooks_dir not in sys.path:
13
+ sys.path.insert(0, _hooks_dir)
14
+
15
+ from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
16
+ prose_style_enforcement_enabled_in_environment,
17
+ )
18
+ from hooks_constants.ask_user_question_shape_constants import ( # noqa: E402
19
+ ASK_USER_QUESTION_TOOL_NAME,
20
+ )
21
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
+ from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
23
+ ALL_PLAIN_LANGUAGE_TERM_PATTERNS,
24
+ FENCED_CODE_PATTERN,
25
+ FILE_PATH_PATTERN,
26
+ INLINE_CODE_PATTERN,
27
+ PLAIN_LANGUAGE_BLOCK_PREFIX,
28
+ PLAIN_LANGUAGE_NOTICE,
29
+ PLAIN_LANGUAGE_TERM_SEPARATOR,
30
+ URL_PATTERN,
31
+ )
32
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
33
+ read_hook_input_dictionary_from_stdin,
34
+ )
35
+
36
+
37
+ def strip_non_prose_regions(text: str) -> str:
38
+ """Remove exact code, URL, and path regions before scanning prose."""
39
+ without_fenced_code = FENCED_CODE_PATTERN.sub(" ", text)
40
+ without_inline_code = INLINE_CODE_PATTERN.sub(" ", without_fenced_code)
41
+ without_urls = URL_PATTERN.sub(" ", without_inline_code)
42
+ return FILE_PATH_PATTERN.sub(" ", without_urls)
43
+
44
+ def find_banned_terms(text: str) -> list[tuple[str, str]]:
45
+ """Return each detected formal term and its familiar replacement."""
46
+ prose_text = strip_non_prose_regions(text)
47
+ all_matches: list[tuple[str, str]] = []
48
+ seen_terms: set[str] = set()
49
+ for each_pattern, each_replacement in ALL_PLAIN_LANGUAGE_TERM_PATTERNS:
50
+ match = each_pattern.search(prose_text)
51
+ if match is None:
52
+ continue
53
+ matched_term = match.group(0).lower()
54
+ if matched_term in seen_terms:
55
+ continue
56
+ seen_terms.add(matched_term)
57
+ all_matches.append((matched_term, each_replacement))
58
+ return all_matches
59
+
60
+
61
+ def _question_prose(payload_by_key: Mapping[str, object]) -> list[str]:
62
+ """Return question and option-description prose from a tool payload."""
63
+ raw_tool_input = payload_by_key.get("tool_input", {})
64
+ if not isinstance(raw_tool_input, Mapping):
65
+ return []
66
+ raw_questions = raw_tool_input.get("questions", [])
67
+ if not isinstance(raw_questions, Sequence) or isinstance(
68
+ raw_questions, (str, bytes)
69
+ ):
70
+ return []
71
+ all_prose: list[str] = []
72
+ for each_raw_question in raw_questions:
73
+ if not isinstance(each_raw_question, Mapping):
74
+ continue
75
+ question = each_raw_question.get("question")
76
+ if isinstance(question, str):
77
+ all_prose.append(question)
78
+ raw_options = each_raw_question.get("options", [])
79
+ if not isinstance(raw_options, Sequence) or isinstance(
80
+ raw_options, (str, bytes)
81
+ ):
82
+ continue
83
+ for each_raw_option in raw_options:
84
+ if not isinstance(each_raw_option, Mapping):
85
+ continue
86
+ description = each_raw_option.get("description")
87
+ if isinstance(description, str):
88
+ all_prose.append(description)
89
+ return all_prose
90
+
91
+
92
+ def evaluate(payload_by_key: Mapping[str, object]) -> str | None:
93
+ """Return a deny reason for formal AskUserQuestion prose."""
94
+ if not prose_style_enforcement_enabled_in_environment():
95
+ return None
96
+ if payload_by_key.get("tool_name") != ASK_USER_QUESTION_TOOL_NAME:
97
+ return None
98
+ all_matches: list[tuple[str, str]] = []
99
+ for each_prose in _question_prose(payload_by_key):
100
+ for each_match in find_banned_terms(each_prose):
101
+ if each_match not in all_matches:
102
+ all_matches.append(each_match)
103
+ if not all_matches:
104
+ return None
105
+ return build_block_reason(all_matches)
106
+
107
+
108
+ def build_block_reason(all_matches: Sequence[tuple[str, str]]) -> str:
109
+ """Build a concise denial reason with one replacement per detected term."""
110
+ swaps = PLAIN_LANGUAGE_TERM_SEPARATOR.join(
111
+ f"{term} -> {replacement}" for term, replacement in all_matches
112
+ )
113
+ return f"{PLAIN_LANGUAGE_BLOCK_PREFIX}{swaps}."
114
+
115
+
116
+ def build_deny_payload(deny_reason: str) -> dict[str, object]:
117
+ """Build the standard PreToolUse deny response."""
118
+ log_hook_block(
119
+ calling_hook_name="plain_language_blocker.py",
120
+ hook_event="PreToolUse",
121
+ block_reason=deny_reason,
122
+ tool_name=ASK_USER_QUESTION_TOOL_NAME,
123
+ )
124
+ return {
125
+ "hookSpecificOutput": {
126
+ "hookEventName": "PreToolUse",
127
+ "permissionDecision": "deny",
128
+ "permissionDecisionReason": deny_reason,
129
+ },
130
+ "systemMessage": PLAIN_LANGUAGE_NOTICE,
131
+ "suppressOutput": True,
132
+ }
133
+
134
+
135
+ def main() -> None:
136
+ """Read one hook payload and emit a deny response when needed."""
137
+ payload_by_key = read_hook_input_dictionary_from_stdin()
138
+ if payload_by_key is None:
139
+ return
140
+ deny_reason = evaluate(payload_by_key)
141
+ if deny_reason is None:
142
+ return
143
+ sys.stdout.write(json.dumps(build_deny_payload(deny_reason)) + "\n")
144
+ sys.stdout.flush()
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
@@ -496,13 +496,15 @@ def _emit_allow_decision(decision: DispatcherDecision) -> None:
496
496
  allow_payload: dict[str, object] = {
497
497
  "hookSpecificOutput": allow_hook_specific,
498
498
  }
499
- if decision.all_additional_context:
499
+ all_unique_additional_context = unique_first_seen_strings(decision.all_additional_context)
500
+ all_unique_system_messages = unique_first_seen_strings(decision.all_system_messages)
501
+ if all_unique_additional_context:
500
502
  allow_hook_specific["additionalContext"] = CONTEXT_JOIN_SEPARATOR.join(
501
- decision.all_additional_context
503
+ all_unique_additional_context
502
504
  )
503
- if decision.all_system_messages:
505
+ if all_unique_system_messages:
504
506
  allow_payload["systemMessage"] = SYSTEM_MESSAGE_JOIN_SEPARATOR.join(
505
- decision.all_system_messages
507
+ all_unique_system_messages
506
508
  )
507
509
  sys.stdout.write(json.dumps(allow_payload) + "\n")
508
510
  sys.stdout.flush()
@@ -3,8 +3,9 @@
3
3
 
4
4
  SendUserFile attaches a file to the session. While the user is at the terminal
5
5
  (status "normal" or unset) an attach does not let them see the file — it must
6
- open on screen in its native Windows app. The one attach allowed
7
- through is an away-from-desk phone push (status "proactive").
6
+ open on screen in its native Windows app. The corrective message names the
7
+ ``Invoke-Item -LiteralPath`` form. The one attach allowed through is an
8
+ away-from-desk phone push (status "proactive").
8
9
  """
9
10
 
10
11
  import json
@@ -219,6 +219,7 @@ def test_powershell_selects_the_shared_hooks_in_registration_order() -> None:
219
219
  for each_entry in select_applicable_entries(POWERSHELL_TOOL_NAME)
220
220
  ]
221
221
  assert powershell_paths == [
222
+ "blocking/cursor_cli_python_misfire_blocker.py",
222
223
  "blocking/unscoped_search_blocker.py",
223
224
  "blocking/pii_prevention_blocker.py",
224
225
  ]
@@ -143,6 +143,31 @@ def test_codex_payload_blocks_malformed_patch(
143
143
 
144
144
  stdout = _run_codex_payload(payload, monkeypatch, capsys)
145
145
 
146
+ deny_payload = json.loads(stdout)
147
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
148
+ assert "payload requires accepted patch markers" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
149
+ def test_codex_payload_blocks_a_nul_containing_patch_path(
150
+ tmp_path: Path,
151
+ monkeypatch: pytest.MonkeyPatch,
152
+ capsys: pytest.CaptureFixture[str],
153
+ ) -> None:
154
+ """A path containing NUL is converted into the standard JSON deny response."""
155
+ production_directory = _production_directory(tmp_path)
156
+ payload = {
157
+ "tool_name": "apply_patch",
158
+ "cwd": str(production_directory),
159
+ "tool_input": {
160
+ "command": (
161
+ "*** Begin Patch\n"
162
+ "*** Add File: unsafe\x00.py\n"
163
+ "+raise RuntimeError()\n"
164
+ "*** End Patch"
165
+ )
166
+ },
167
+ }
168
+
169
+ stdout = _run_codex_payload(payload, monkeypatch, capsys)
170
+
146
171
  deny_payload = json.loads(stdout)
147
172
  assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
148
173
  assert "payload requires accepted patch markers" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
@@ -0,0 +1,208 @@
1
+ """Behavior tests for the Cursor-vs-Python gate misfire PreToolUse hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import io
7
+ import json
8
+ import pathlib
9
+ import sys
10
+ from unittest import mock
11
+
12
+ _HOOK_DIR = pathlib.Path(__file__).parent
13
+ if str(_HOOK_DIR) not in sys.path:
14
+ sys.path.insert(0, str(_HOOK_DIR))
15
+
16
+ _HOOKS_DIR = str(_HOOK_DIR.parent)
17
+ if _HOOKS_DIR not in sys.path:
18
+ sys.path.insert(0, _HOOKS_DIR)
19
+
20
+ hook_spec = importlib.util.spec_from_file_location(
21
+ "cursor_cli_python_misfire_blocker",
22
+ _HOOK_DIR / "cursor_cli_python_misfire_blocker.py",
23
+ )
24
+ assert hook_spec is not None
25
+ assert hook_spec.loader is not None
26
+ hook_module = importlib.util.module_from_spec(hook_spec)
27
+ hook_spec.loader.exec_module(hook_module)
28
+
29
+ is_cursor_python_gate_misfire = hook_module.is_cursor_python_gate_misfire
30
+
31
+ from hooks_constants.cursor_cli_python_misfire_blocker_constants import ( # noqa: E402
32
+ CORRECTIVE_MESSAGE,
33
+ DENY_DECISION,
34
+ )
35
+
36
+
37
+ def test_blocks_cursor_with_code_rules_gate_and_base_flag() -> None:
38
+ assert (
39
+ is_cursor_python_gate_misfire(
40
+ "cursor code_rules_gate.py --base origin/main"
41
+ )
42
+ is True
43
+ )
44
+
45
+
46
+ def test_blocks_cursor_exe_with_staged_flag_on_python_path() -> None:
47
+ assert (
48
+ is_cursor_python_gate_misfire(r"Cursor.exe C:\temp\helper.py --staged")
49
+ is True
50
+ )
51
+
52
+
53
+ def test_blocks_cursor_cmd_with_repo_root_flag() -> None:
54
+ assert (
55
+ is_cursor_python_gate_misfire("cursor.cmd path/to/module.py --repo-root .")
56
+ is True
57
+ )
58
+
59
+
60
+ def test_allows_code_rules_gate_path_without_extra_flags() -> None:
61
+ assert is_cursor_python_gate_misfire("cursor code_rules_gate.py") is False
62
+
63
+
64
+ def test_allows_cursor_goto_on_python_file() -> None:
65
+ assert is_cursor_python_gate_misfire("cursor -g file.py:10") is False
66
+
67
+
68
+ def test_allows_cursor_goto_on_code_rules_gate_without_gate_flags() -> None:
69
+ assert (
70
+ is_cursor_python_gate_misfire("cursor -g code_rules_gate.py:40") is False
71
+ )
72
+
73
+
74
+ def test_blocks_start_process_cursor_with_quoted_gate_flag() -> None:
75
+ command = (
76
+ r'Start-Process Cursor.exe -ArgumentList '
77
+ r'"C:\temp\helper.py","--staged"'
78
+ )
79
+ assert is_cursor_python_gate_misfire(command) is True
80
+
81
+
82
+ def test_blocks_quoted_full_path_cursor_exe_with_gate_flags() -> None:
83
+ command = (
84
+ r"& 'C:\Program Files\Cursor\Cursor.exe' "
85
+ r"code_rules_gate.py --base origin/main"
86
+ )
87
+ assert is_cursor_python_gate_misfire(command) is True
88
+
89
+
90
+ def test_blocks_unquoted_absolute_cursor_exe_with_gate_flags() -> None:
91
+ command = (
92
+ r"C:\Apps\Cursor\Cursor.exe code_rules_gate.py --base origin/main"
93
+ )
94
+ assert is_cursor_python_gate_misfire(command) is True
95
+
96
+
97
+ def test_allows_relative_path_under_cursor_directory() -> None:
98
+ assert (
99
+ is_cursor_python_gate_misfire(
100
+ r"cursor\fix\x\code_rules_gate.py --base origin/main"
101
+ )
102
+ is False
103
+ )
104
+ assert (
105
+ is_cursor_python_gate_misfire(
106
+ "cursor/fix/x/code_rules_gate.py --base origin/main"
107
+ )
108
+ is False
109
+ )
110
+
111
+
112
+ def test_allows_cursor_open_of_non_python_file() -> None:
113
+ assert is_cursor_python_gate_misfire("cursor README.md") is False
114
+
115
+
116
+ def test_allows_python_running_the_gate() -> None:
117
+ assert (
118
+ is_cursor_python_gate_misfire(
119
+ "python code_rules_gate.py --base origin/main"
120
+ )
121
+ is False
122
+ )
123
+
124
+
125
+ def test_allows_invoke_item() -> None:
126
+ assert (
127
+ is_cursor_python_gate_misfire("Invoke-Item -LiteralPath 'C:\\tmp\\a.py'")
128
+ is False
129
+ )
130
+
131
+
132
+ def test_allows_empty_command() -> None:
133
+ assert is_cursor_python_gate_misfire("") is False
134
+
135
+
136
+ def test_corrective_message_names_python_and_invoke_item() -> None:
137
+ assert "python" in CORRECTIVE_MESSAGE
138
+ assert "Invoke-Item -LiteralPath" in CORRECTIVE_MESSAGE
139
+ assert "EPIPE" in CORRECTIVE_MESSAGE
140
+
141
+
142
+ def _run_main_with_io(input_text: str) -> str:
143
+ with mock.patch("sys.stdin", io.StringIO(input_text)):
144
+ with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
145
+ try:
146
+ hook_module.main()
147
+ except SystemExit:
148
+ pass
149
+ return mock_stdout.getvalue()
150
+
151
+
152
+ def test_main_denies_bash_misfire() -> None:
153
+ hook_input = {
154
+ "tool_name": "Bash",
155
+ "tool_input": {
156
+ "command": "cursor code_rules_gate.py --base origin/main",
157
+ },
158
+ }
159
+ output_text = _run_main_with_io(json.dumps(hook_input))
160
+ output = json.loads(output_text)
161
+ assert output["hookSpecificOutput"]["permissionDecision"] == DENY_DECISION
162
+ assert "cursor-python-misfire" in output["hookSpecificOutput"][
163
+ "permissionDecisionReason"
164
+ ]
165
+
166
+
167
+ def test_main_denies_powershell_misfire() -> None:
168
+ hook_input = {
169
+ "tool_name": "PowerShell",
170
+ "tool_input": {
171
+ "command": "cursor.cmd code_rules_gate.py --staged",
172
+ },
173
+ }
174
+ output_text = _run_main_with_io(json.dumps(hook_input))
175
+ output = json.loads(output_text)
176
+ assert output["hookSpecificOutput"]["permissionDecision"] == DENY_DECISION
177
+
178
+
179
+ def test_main_passes_python_gate_run() -> None:
180
+ hook_input = {
181
+ "tool_name": "Bash",
182
+ "tool_input": {
183
+ "command": "python code_rules_gate.py --base origin/main",
184
+ },
185
+ }
186
+ assert _run_main_with_io(json.dumps(hook_input)) == ""
187
+
188
+
189
+ def test_main_passes_wrong_tool_name() -> None:
190
+ hook_input = {
191
+ "tool_name": "Write",
192
+ "tool_input": {
193
+ "command": "cursor code_rules_gate.py --base origin/main",
194
+ },
195
+ }
196
+ assert _run_main_with_io(json.dumps(hook_input)) == ""
197
+
198
+
199
+ def test_main_passes_malformed_json() -> None:
200
+ assert _run_main_with_io("not valid json {{{") == ""
201
+
202
+
203
+ def test_main_passes_when_tool_input_is_null() -> None:
204
+ hook_input = {
205
+ "tool_name": "Bash",
206
+ "tool_input": None,
207
+ }
208
+ assert _run_main_with_io(json.dumps(hook_input)) == ""