claude-dev-env 2.21.1 → 2.22.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.
- package/.agents/skills/orchestrator/SKILL.md +1 -0
- package/.agents/skills/pr-cleanup/SKILL.md +12 -2
- package/.agents/skills/pr-cleanup/reference/process-inventory.md +2 -0
- package/.agents/skills/pr-cleanup/reference/task-seeds.md +9 -7
- package/.agents/skills/team-advisor/SKILL.md +1 -0
- package/AGENTS.md +1 -1
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +108 -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_cursor_cli_python_misfire_blocker.py +208 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +3 -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/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
|
@@ -6,6 +6,7 @@ description: >-
|
|
|
6
6
|
correction, plan, or stop. Triggers: '/orchestrator', 'orchestrator
|
|
7
7
|
strategy', 'run with an orchestrator', 'executor-advisor mode',
|
|
8
8
|
'orchestrator enforcement', 'agent routing', 'orchestrate'.
|
|
9
|
+
disable-model-invocation: true
|
|
9
10
|
---
|
|
10
11
|
|
|
11
12
|
# Orchestrator Strategy
|
|
@@ -48,6 +48,7 @@ with the user.
|
|
|
48
48
|
| `pr-name-by-capability` | Find driver or motive words on reusable capability surfaces | Naming findings and rename directions |
|
|
49
49
|
| `pr-small-cl` | Choose one coherent pull request or an ordered replacement stack | Focused boundary and dependencies |
|
|
50
50
|
| `source-command-sr-loop` | Run `e-simplify`, then `e-code-review low --fix` until clean | Review passes, fixes, and validation |
|
|
51
|
+
| `pr-summarizer` (repo-local) | When the repository under cleanup ships `.claude/skills/pr-summarizer/`, run it after Ready and post the secret-gist preview comment | Preview URL and comment confirmation |
|
|
51
52
|
|
|
52
53
|
## Task seeding
|
|
53
54
|
|
|
@@ -89,7 +90,12 @@ review passes, fixes, skips, tests, and commit SHAs.
|
|
|
89
90
|
|
|
90
91
|
### 6. Promote and report
|
|
91
92
|
|
|
92
|
-
After the applicable gate passes,
|
|
93
|
+
After the applicable gate passes, when the repository under cleanup includes
|
|
94
|
+
`.claude/skills/pr-summarizer/SKILL.md`, load and run that skill for each pull
|
|
95
|
+
request promoted to Ready in this cleanup run. Post the secret gist preview as a
|
|
96
|
+
pull request comment before completing the [Finish report](#finish-report).
|
|
97
|
+
|
|
98
|
+
When the skill is absent, record `N/A` for the summary step and continue.
|
|
93
99
|
|
|
94
100
|
## Promotion gates
|
|
95
101
|
|
|
@@ -110,6 +116,9 @@ Reapply every relevant fix to the child. Rerun child tests, `e-simplify`, and
|
|
|
110
116
|
`e-code-review` after the merge. Record the new child head and every validation
|
|
111
117
|
result before promoting the child to Ready.
|
|
112
118
|
|
|
119
|
+
When the repository ships `pr-summarizer`, run it on the child after child Ready
|
|
120
|
+
promotion if the child is in scope for this cleanup run.
|
|
121
|
+
|
|
113
122
|
Use `reference/task-seeds.md` and `reference/process-inventory.md` to record promotion evidence.
|
|
114
123
|
|
|
115
124
|
## Finish report
|
|
@@ -118,6 +127,7 @@ Use `reference/task-seeds.md` and `reference/process-inventory.md` to record pro
|
|
|
118
127
|
- `pr-refinement` outcome.
|
|
119
128
|
- `source-command-sr-loop` passes, commits, and validation results.
|
|
120
129
|
- Parent-ready and child-ready SHAs when a child is promoted.
|
|
130
|
+
- `pr-summarizer` preview URLs and comment confirmation for each Ready pull request, or `N/A` when the repo does not ship the skill.
|
|
121
131
|
- Remaining hard block, or `null`.
|
|
122
132
|
|
|
123
133
|
## File index
|
|
@@ -125,5 +135,5 @@ Use `reference/task-seeds.md` and `reference/process-inventory.md` to record pro
|
|
|
125
135
|
| Path | Purpose |
|
|
126
136
|
|---|---|
|
|
127
137
|
| `SKILL.md` | Hub for refinement, cleanup convergence, promotion gates, and reporting |
|
|
128
|
-
| `reference/task-seeds.md` | Ordered session tasks for audits, delivery, validation, and
|
|
138
|
+
| `reference/task-seeds.md` | Ordered session tasks for audits, delivery, validation, promotion, and summary |
|
|
129
139
|
| `reference/process-inventory.md` | Process classes, evidence homes, and paired task checks |
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
| Apply or disposition findings | judgment | `SKILL.md` and `task-seed:reference/task-seeds.md` | Changed diff or exact disposition | task-tool |
|
|
9
9
|
| Validate parent head | deterministic | `task-seed:reference/task-seeds.md` | Scoped test and confirmation results | task-tool |
|
|
10
10
|
| Promote parent and record exact Ready SHA | deterministic | `task-seed:reference/task-seeds.md` | Remote parent Ready state and SHA | task-tool |
|
|
11
|
+
| Post parent PR summary gist comment | deterministic | `SKILL.md` and `task-seed:reference/task-seeds.md` | Preview URL, comment confirmation, or `N/A` | task-tool |
|
|
11
12
|
| Merge exact parent SHA into child | deterministic | `task-seed:reference/task-seeds.md` | Child merge commit and parent SHA | task-tool |
|
|
12
13
|
| Prove parent SHA ancestry | deterministic | `task-seed:reference/task-seeds.md` | `git merge-base --is-ancestor` exit code `0` | task-tool |
|
|
13
14
|
| Reapply relevant fixes to child | judgment | `SKILL.md` and `task-seed:reference/task-seeds.md` | Child diff and reapplication record | task-tool |
|
|
14
15
|
| Validate child and rerun confirmations | deterministic | `task-seed:reference/task-seeds.md` | New child-head tests, simplify, and review results | task-tool |
|
|
15
16
|
| Promote child and report | deterministic | `task-seed:reference/task-seeds.md` | Child Ready state and finish report | task-tool |
|
|
17
|
+
| Post child PR summary gist comment | deterministic | `SKILL.md` and `task-seed:reference/task-seeds.md` | Preview URL, comment confirmation, or `N/A` | task-tool |
|
|
@@ -11,10 +11,12 @@ each task with evidence from the stated gate.
|
|
|
11
11
|
6. Select, apply, validate, or exactly disposition every actionable preflight finding in the one-agent parent cleanup worktree.
|
|
12
12
|
7. Run parent scoped tests and cleanup/review confirmation checks.
|
|
13
13
|
8. Promote the parent to Ready and record the exact remote `parent_ready_sha`.
|
|
14
|
-
9.
|
|
15
|
-
10.
|
|
16
|
-
11.
|
|
17
|
-
12.
|
|
18
|
-
13.
|
|
19
|
-
14.
|
|
20
|
-
15.
|
|
14
|
+
9. When `.claude/skills/pr-summarizer/SKILL.md` exists in the repository under cleanup, run that skill on the parent and post the gist preview comment; otherwise record `N/A`.
|
|
15
|
+
10. Create the child from its intended pre-parent base and merge the exact `parent_ready_sha`.
|
|
16
|
+
11. Prove `parent_ready_sha` is an ancestor of the child head and record the exit code.
|
|
17
|
+
12. Reapply every relevant preflight fix to the child.
|
|
18
|
+
13. Run child scoped tests on the new child head.
|
|
19
|
+
14. Rerun `e-simplify` and `e-code-review` on the new child head.
|
|
20
|
+
15. Promote the child to Ready after all child evidence passes.
|
|
21
|
+
16. When the repository ships `pr-summarizer` and the child is in scope, run it on the child and post the gist preview comment; otherwise record `N/A`.
|
|
22
|
+
17. Write the finish report with SHAs, evidence, summary preview URLs, states, and hard blocks.
|
package/AGENTS.md
CHANGED
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()
|
|
@@ -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
|
]
|
|
@@ -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)) == ""
|
|
@@ -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:
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
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
|
)
|