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.
- package/docs/codex-compatibility.md +4 -2
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +108 -0
- package/hooks/blocking/plain_language_blocker.py +148 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +6 -4
- package/hooks/blocking/send_user_file_open_locally_blocker.py +3 -2
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +25 -0
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +208 -0
- package/hooks/blocking/test_plain_language_blocker.py +119 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +3 -0
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/AGENTS.md +2 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +79 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +26 -0
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +5 -1
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/package.json +1 -1
- package/scripts/codex_compat_materializer.py +67 -7
- package/scripts/tests/test_codex_compat_materializer.py +311 -0
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
package/hooks/blocking/AGENTS.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
503
|
+
all_unique_additional_context
|
|
502
504
|
)
|
|
503
|
-
if
|
|
505
|
+
if all_unique_system_messages:
|
|
504
506
|
allow_payload["systemMessage"] = SYSTEM_MESSAGE_JOIN_SEPARATOR.join(
|
|
505
|
-
|
|
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
|
|
7
|
-
|
|
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)) == ""
|