claude-dev-env 2.20.0 → 2.21.1
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/code_rules_enforcer.py +83 -0
- package/hooks/blocking/codex_apply_patch.py +238 -0
- package/hooks/blocking/plain_language_blocker.py +148 -0
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +173 -0
- package/hooks/blocking/test_plain_language_blocker.py +119 -0
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +1 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +26 -0
- package/package.json +1 -1
- package/scripts/codex_compat_materializer.py +455 -17
- package/scripts/tests/test_codex_compat_materializer.py +328 -3
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Tests for the Codex apply_patch adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_HOOK_DIRECTORY = Path(__file__).resolve().parent
|
|
14
|
+
_HOOKS_PARENT = _HOOK_DIRECTORY.parent
|
|
15
|
+
if str(_HOOK_DIRECTORY) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(_HOOK_DIRECTORY))
|
|
17
|
+
if str(_HOOKS_PARENT) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_HOOKS_PARENT))
|
|
19
|
+
|
|
20
|
+
import code_rules_enforcer
|
|
21
|
+
import codex_apply_patch
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run_codex_payload(
|
|
25
|
+
payload: dict[str, object],
|
|
26
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
27
|
+
capsys: pytest.CaptureFixture[str],
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Run the real enforcer entry point and return its stdout."""
|
|
30
|
+
monkeypatch.setattr(code_rules_enforcer.sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
31
|
+
with contextlib.suppress(SystemExit):
|
|
32
|
+
code_rules_enforcer.main([])
|
|
33
|
+
return capsys.readouterr().out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _production_directory(tmp_path: Path) -> Path:
|
|
37
|
+
"""Return a temporary directory whose path carries production semantics."""
|
|
38
|
+
production_directory = tmp_path.parent / "codex-prod"
|
|
39
|
+
production_directory.mkdir(exist_ok=True)
|
|
40
|
+
return production_directory
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_parse_codex_apply_patch_projects_every_multi_file_operation(
|
|
44
|
+
tmp_path: Path,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""The parser returns pre-edit and post-edit content for update, add, and delete."""
|
|
47
|
+
updated_path = tmp_path / "updated.py"
|
|
48
|
+
deleted_path = tmp_path / "deleted.py"
|
|
49
|
+
updated_path.write_text("before\nkeep\n", encoding="utf-8")
|
|
50
|
+
deleted_path.write_text("remove\n", encoding="utf-8")
|
|
51
|
+
patch = (
|
|
52
|
+
"*** Begin Patch\n"
|
|
53
|
+
"*** Update File: updated.py\n"
|
|
54
|
+
"@@\n"
|
|
55
|
+
"-before\n"
|
|
56
|
+
"+after\n"
|
|
57
|
+
" keep\n"
|
|
58
|
+
"*** Add File: added.py\n"
|
|
59
|
+
"+new\n"
|
|
60
|
+
"*** Delete File: deleted.py\n"
|
|
61
|
+
"*** End Patch"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
all_patch_files = codex_apply_patch.parse_codex_apply_patch(patch, str(tmp_path))
|
|
65
|
+
|
|
66
|
+
views_by_name = {
|
|
67
|
+
Path(each_patch.file_path).name: each_patch for each_patch in all_patch_files
|
|
68
|
+
}
|
|
69
|
+
assert views_by_name["updated.py"].prior_content == "before\nkeep\n"
|
|
70
|
+
assert views_by_name["updated.py"].post_content == "after\nkeep\n"
|
|
71
|
+
assert views_by_name["added.py"].prior_content == ""
|
|
72
|
+
assert views_by_name["added.py"].post_content == "new\n"
|
|
73
|
+
assert views_by_name["deleted.py"].prior_content == "remove\n"
|
|
74
|
+
assert views_by_name["deleted.py"].post_content == ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_codex_payload_allows_declared_blast_radius(
|
|
78
|
+
tmp_path: Path,
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
80
|
+
capsys: pytest.CaptureFixture[str],
|
|
81
|
+
) -> None:
|
|
82
|
+
"""A loop raise with a declared stopping scope passes the Codex hook."""
|
|
83
|
+
production_directory = _production_directory(tmp_path)
|
|
84
|
+
payload = {
|
|
85
|
+
"tool_name": "apply_patch",
|
|
86
|
+
"cwd": str(production_directory),
|
|
87
|
+
"tool_input": {
|
|
88
|
+
"command": (
|
|
89
|
+
"*** Begin Patch\n"
|
|
90
|
+
"*** Add File: module.py\n"
|
|
91
|
+
"+for each_member in all_members:\n"
|
|
92
|
+
"+ raise AssetItemBlocked()\n"
|
|
93
|
+
"*** End Patch"
|
|
94
|
+
)
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stdout = _run_codex_payload(payload, monkeypatch, capsys)
|
|
99
|
+
|
|
100
|
+
assert stdout == ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_codex_payload_blocks_undeclared_blast_radius(
|
|
104
|
+
tmp_path: Path,
|
|
105
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
106
|
+
capsys: pytest.CaptureFixture[str],
|
|
107
|
+
) -> None:
|
|
108
|
+
"""A loop raise requires a stopping-scope declaration for acceptance."""
|
|
109
|
+
production_directory = _production_directory(tmp_path)
|
|
110
|
+
payload = {
|
|
111
|
+
"tool_name": "apply_patch",
|
|
112
|
+
"cwd": str(production_directory),
|
|
113
|
+
"tool_input": {
|
|
114
|
+
"command": (
|
|
115
|
+
"*** Begin Patch\n"
|
|
116
|
+
"*** Add File: module.py\n"
|
|
117
|
+
"+for each_member in all_members:\n"
|
|
118
|
+
"+ raise RuntimeError()\n"
|
|
119
|
+
"*** End Patch"
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
stdout = _run_codex_payload(payload, monkeypatch, capsys)
|
|
125
|
+
|
|
126
|
+
deny_payload = json.loads(stdout)
|
|
127
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
128
|
+
assert "blast radius" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_codex_payload_blocks_malformed_patch(
|
|
132
|
+
tmp_path: Path,
|
|
133
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
134
|
+
capsys: pytest.CaptureFixture[str],
|
|
135
|
+
) -> None:
|
|
136
|
+
"""A malformed Codex patch returns a blocking diagnostic."""
|
|
137
|
+
production_directory = _production_directory(tmp_path)
|
|
138
|
+
payload = {
|
|
139
|
+
"tool_name": "apply_patch",
|
|
140
|
+
"cwd": str(production_directory),
|
|
141
|
+
"tool_input": {"command": "*** Begin Patch\n*** End Patch"},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
stdout = _run_codex_payload(payload, monkeypatch, capsys)
|
|
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
|
+
|
|
171
|
+
deny_payload = json.loads(stdout)
|
|
172
|
+
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
173
|
+
assert "payload requires accepted patch markers" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
@@ -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 == ""
|
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",
|
|
@@ -36,6 +36,7 @@ ADVISORY_LINE_THRESHOLD_SOFT = 400
|
|
|
36
36
|
ADVISORY_LINE_THRESHOLD_HARD = 1000
|
|
37
37
|
|
|
38
38
|
DENY_REASON_ISSUE_PREVIEW_COUNT = 10
|
|
39
|
+
VIOLATION_SEPARATOR = "; "
|
|
39
40
|
|
|
40
41
|
ALL_BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_", "was_", "did_")
|
|
41
42
|
UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
@@ -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
|
+
)
|