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
|
@@ -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
|
|
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
|
@@ -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.
|
|
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
|
-
|
|
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 =
|
|
714
|
-
|
|
715
|
-
|
|
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
|