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
|
@@ -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
|
|
|
@@ -16,6 +16,7 @@ concern focused. The separate ``tdd_enforcer.py`` hook accepts any
|
|
|
16
16
|
``code_rules_*`` module family, so the suffix files satisfy its gate.
|
|
17
17
|
"""
|
|
18
18
|
import json
|
|
19
|
+
import os
|
|
19
20
|
import sys
|
|
20
21
|
from collections import Counter
|
|
21
22
|
from collections.abc import Callable
|
|
@@ -29,6 +30,8 @@ if _BLOCKING_DIRECTORY not in sys.path:
|
|
|
29
30
|
if _HOOKS_DIRECTORY not in sys.path:
|
|
30
31
|
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
31
32
|
|
|
33
|
+
_codex_apply_patch_tool_name = "apply_patch"
|
|
34
|
+
|
|
32
35
|
from code_rules_annotations_length import ( # noqa: E402
|
|
33
36
|
check_function_length,
|
|
34
37
|
check_known_pytest_fixture_annotations,
|
|
@@ -203,6 +206,11 @@ from code_rules_typeddict_stub import ( # noqa: E402
|
|
|
203
206
|
from code_rules_unused_imports import ( # noqa: E402
|
|
204
207
|
check_unused_module_level_imports,
|
|
205
208
|
)
|
|
209
|
+
from codex_apply_patch import ( # noqa: E402
|
|
210
|
+
CodexPatchError,
|
|
211
|
+
CodexPatchFile,
|
|
212
|
+
parse_codex_apply_patch,
|
|
213
|
+
)
|
|
206
214
|
|
|
207
215
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
208
216
|
ALL_CODE_EXTENSIONS,
|
|
@@ -211,6 +219,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
211
219
|
DENY_REASON_ISSUE_PREVIEW_COUNT,
|
|
212
220
|
PRECHECK_USAGE_EXIT_CODE,
|
|
213
221
|
PRECHECK_USAGE_MESSAGE,
|
|
222
|
+
VIOLATION_SEPARATOR,
|
|
214
223
|
)
|
|
215
224
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
216
225
|
from hooks_constants.setup_project_paths_constants import ( # noqa: E402
|
|
@@ -218,6 +227,76 @@ from hooks_constants.setup_project_paths_constants import ( # noqa: E402
|
|
|
218
227
|
)
|
|
219
228
|
|
|
220
229
|
|
|
230
|
+
def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
|
|
231
|
+
"""Run the existing code-rules verdict over one Codex patch view."""
|
|
232
|
+
if not each_patch_file.post_content and each_patch_file.operation == "delete":
|
|
233
|
+
return []
|
|
234
|
+
if _is_hook_infrastructure_python_target(each_patch_file.file_path):
|
|
235
|
+
all_issues = _hook_infrastructure_blocking_issues(
|
|
236
|
+
each_patch_file.post_content,
|
|
237
|
+
each_patch_file.file_path,
|
|
238
|
+
each_patch_file.post_content,
|
|
239
|
+
each_patch_file.prior_content,
|
|
240
|
+
)
|
|
241
|
+
elif _is_validated_target(each_patch_file.file_path):
|
|
242
|
+
all_issues = validate_content(
|
|
243
|
+
each_patch_file.post_content,
|
|
244
|
+
each_patch_file.file_path,
|
|
245
|
+
each_patch_file.prior_content,
|
|
246
|
+
each_patch_file.post_content,
|
|
247
|
+
each_patch_file.prior_content,
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
return []
|
|
251
|
+
return [
|
|
252
|
+
f"{each_patch_file.file_path}: {each_issue}"
|
|
253
|
+
for each_issue in all_issues
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _report_codex_patch_payload(
|
|
258
|
+
all_pretooluse_payload: dict[str, object], deny_stream: TextIO
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Validate every file view carried by a Codex apply_patch payload."""
|
|
261
|
+
tool_input = all_pretooluse_payload.get("tool_input")
|
|
262
|
+
if not isinstance(tool_input, dict):
|
|
263
|
+
_write_deny_payload(
|
|
264
|
+
"BLOCKED: [CODE_RULES] apply_patch payload requires tool input",
|
|
265
|
+
deny_stream,
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
patch_command = tool_input.get("command")
|
|
269
|
+
if not isinstance(patch_command, str):
|
|
270
|
+
_write_deny_payload(
|
|
271
|
+
"BLOCKED: [CODE_RULES] apply_patch payload requires a string command",
|
|
272
|
+
deny_stream,
|
|
273
|
+
)
|
|
274
|
+
return
|
|
275
|
+
working_directory = all_pretooluse_payload.get("cwd")
|
|
276
|
+
if not isinstance(working_directory, str):
|
|
277
|
+
working_directory = os.getcwd()
|
|
278
|
+
try:
|
|
279
|
+
all_patch_files = parse_codex_apply_patch(patch_command, working_directory)
|
|
280
|
+
except CodexPatchError as error:
|
|
281
|
+
_write_deny_payload(
|
|
282
|
+
f"BLOCKED: [CODE_RULES] apply_patch payload requires accepted patch markers: {error}",
|
|
283
|
+
deny_stream,
|
|
284
|
+
)
|
|
285
|
+
return
|
|
286
|
+
all_issues = [
|
|
287
|
+
each_issue
|
|
288
|
+
for each_patch_file in all_patch_files
|
|
289
|
+
for each_issue in _codex_patch_issues(each_patch_file)
|
|
290
|
+
]
|
|
291
|
+
if all_issues:
|
|
292
|
+
_write_deny_payload(
|
|
293
|
+
f"BLOCKED: [CODE_RULES] {len(all_issues)} violation(s): "
|
|
294
|
+
+ VIOLATION_SEPARATOR.join(all_issues[:DENY_REASON_ISSUE_PREVIEW_COUNT])
|
|
295
|
+
+ _precheck_hint(),
|
|
296
|
+
deny_stream,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
221
300
|
def validate_content(
|
|
222
301
|
content: str,
|
|
223
302
|
file_path: str,
|
|
@@ -1259,6 +1338,10 @@ def main(all_arguments: list[str]) -> None:
|
|
|
1259
1338
|
sys.exit(0)
|
|
1260
1339
|
|
|
1261
1340
|
tool_name = pretooluse_payload.get("tool_name", "")
|
|
1341
|
+
if tool_name == _codex_apply_patch_tool_name:
|
|
1342
|
+
_report_codex_patch_payload(pretooluse_payload, sys.stdout)
|
|
1343
|
+
sys.exit(0)
|
|
1344
|
+
|
|
1262
1345
|
tool_input = pretooluse_payload.get("tool_input", {})
|
|
1263
1346
|
file_path = tool_input.get("file_path", "")
|
|
1264
1347
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Parse Codex apply_patch commands into safe pre-edit and post-edit views."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_codex_patch_begin_marker = "*** Begin Patch"
|
|
9
|
+
_codex_patch_end_marker = "*** End Patch"
|
|
10
|
+
_codex_update_marker = "*** Update File:"
|
|
11
|
+
_codex_add_marker = "*** Add File:"
|
|
12
|
+
_codex_delete_marker = "*** Delete File:"
|
|
13
|
+
_codex_hunk_marker = "@@"
|
|
14
|
+
_codex_end_of_file_marker = "*** End of File"
|
|
15
|
+
_codex_no_newline_marker = "\"
|
|
16
|
+
_codex_minimum_patch_line_count = 2
|
|
17
|
+
_codex_update_operation = "update"
|
|
18
|
+
_codex_add_operation = "add"
|
|
19
|
+
_codex_delete_operation = "delete"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CodexPatchError(ValueError):
|
|
23
|
+
"""Describe the accepted file views required by a Codex patch."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class CodexPatchFile:
|
|
28
|
+
"""Represent one Codex path with pre-edit and projected post-edit content."""
|
|
29
|
+
|
|
30
|
+
file_path: str
|
|
31
|
+
prior_content: str
|
|
32
|
+
post_content: str
|
|
33
|
+
operation: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _codex_marker_text(patch_line: str) -> str:
|
|
37
|
+
"""Return one patch control line minus its line ending."""
|
|
38
|
+
return patch_line.rstrip("\r\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _codex_resolve_patch_path(relative_path: str, working_directory: Path) -> str:
|
|
42
|
+
"""Resolve one relative patch path under the Codex working directory."""
|
|
43
|
+
normalized_path = relative_path.replace("\\", "/")
|
|
44
|
+
all_path_parts = tuple(
|
|
45
|
+
each_part
|
|
46
|
+
for each_part in normalized_path.split("/")
|
|
47
|
+
if each_part not in ("", ".")
|
|
48
|
+
)
|
|
49
|
+
if (
|
|
50
|
+
not all_path_parts
|
|
51
|
+
or normalized_path.startswith("/")
|
|
52
|
+
or re.match(r"^[A-Za-z]:", normalized_path)
|
|
53
|
+
):
|
|
54
|
+
raise CodexPatchError("patch path requires a relative location")
|
|
55
|
+
if any(each_part == ".." for each_part in all_path_parts):
|
|
56
|
+
raise CodexPatchError("patch path requires a traversal-free location")
|
|
57
|
+
target_path = (working_directory / Path(*all_path_parts)).resolve()
|
|
58
|
+
resolved_working_directory = working_directory.resolve()
|
|
59
|
+
if target_path != resolved_working_directory and resolved_working_directory not in target_path.parents:
|
|
60
|
+
raise CodexPatchError("patch path requires a location under the working directory")
|
|
61
|
+
return str(target_path)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _codex_patch_sections(command: str) -> list[tuple[str, str, list[str]]]:
|
|
65
|
+
"""Parse operation sections from a Codex apply_patch command."""
|
|
66
|
+
all_lines = command.splitlines(keepends=True)
|
|
67
|
+
if len(all_lines) < _codex_minimum_patch_line_count:
|
|
68
|
+
raise CodexPatchError("patch requires begin and end markers")
|
|
69
|
+
if _codex_marker_text(all_lines[0]) != _codex_patch_begin_marker:
|
|
70
|
+
raise CodexPatchError("patch requires a begin marker")
|
|
71
|
+
if _codex_marker_text(all_lines[-1]) != _codex_patch_end_marker:
|
|
72
|
+
raise CodexPatchError("patch requires an end marker")
|
|
73
|
+
all_sections: list[tuple[str, str, list[str]]] = []
|
|
74
|
+
current_section: tuple[str, str, list[str]] | None = None
|
|
75
|
+
for each_line in all_lines[1:-1]:
|
|
76
|
+
marker_text = _codex_marker_text(each_line)
|
|
77
|
+
operation = next(
|
|
78
|
+
(
|
|
79
|
+
each_operation
|
|
80
|
+
for each_operation, each_marker in (
|
|
81
|
+
(_codex_update_operation, _codex_update_marker),
|
|
82
|
+
(_codex_add_operation, _codex_add_marker),
|
|
83
|
+
(_codex_delete_operation, _codex_delete_marker),
|
|
84
|
+
)
|
|
85
|
+
if marker_text.startswith(each_marker)
|
|
86
|
+
),
|
|
87
|
+
None,
|
|
88
|
+
)
|
|
89
|
+
if operation is not None:
|
|
90
|
+
if current_section is not None:
|
|
91
|
+
all_sections.append(current_section)
|
|
92
|
+
marker_by_operation = {
|
|
93
|
+
_codex_update_operation: _codex_update_marker,
|
|
94
|
+
_codex_add_operation: _codex_add_marker,
|
|
95
|
+
_codex_delete_operation: _codex_delete_marker,
|
|
96
|
+
}
|
|
97
|
+
path_text = marker_text[len(marker_by_operation[operation]) :].strip()
|
|
98
|
+
if not path_text:
|
|
99
|
+
raise CodexPatchError("patch operation requires a path")
|
|
100
|
+
current_section = (operation, path_text, [])
|
|
101
|
+
continue
|
|
102
|
+
if current_section is None:
|
|
103
|
+
raise CodexPatchError("patch content requires a file operation")
|
|
104
|
+
current_section[2].append(each_line)
|
|
105
|
+
if current_section is not None:
|
|
106
|
+
all_sections.append(current_section)
|
|
107
|
+
if not all_sections:
|
|
108
|
+
raise CodexPatchError("patch requires a file operation")
|
|
109
|
+
return all_sections
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _codex_find_patch_block(
|
|
113
|
+
all_current_lines: list[str], all_old_lines: list[str], search_start: int
|
|
114
|
+
) -> int:
|
|
115
|
+
"""Find one hunk's old lines at or after the prior hunk position."""
|
|
116
|
+
if not all_old_lines:
|
|
117
|
+
return search_start
|
|
118
|
+
last_start = len(all_current_lines) - len(all_old_lines)
|
|
119
|
+
for each_start in range(search_start, last_start + 1):
|
|
120
|
+
if all_current_lines[each_start : each_start + len(all_old_lines)] == all_old_lines:
|
|
121
|
+
return each_start
|
|
122
|
+
return -1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _codex_apply_hunk(
|
|
126
|
+
all_current_lines: list[str], all_hunk_lines: list[str], search_start: int
|
|
127
|
+
) -> tuple[list[str], int]:
|
|
128
|
+
"""Apply one context, deletion, and addition hunk to file lines."""
|
|
129
|
+
all_old_lines: list[str] = []
|
|
130
|
+
all_new_lines: list[str] = []
|
|
131
|
+
for each_line in all_hunk_lines:
|
|
132
|
+
marker_text = _codex_marker_text(each_line)
|
|
133
|
+
if marker_text in (_codex_end_of_file_marker, _codex_no_newline_marker):
|
|
134
|
+
continue
|
|
135
|
+
if not each_line or each_line[0] not in " +-":
|
|
136
|
+
raise CodexPatchError("patch hunk requires context, deletion, or addition lines")
|
|
137
|
+
line_content = each_line[1:]
|
|
138
|
+
if each_line[0] in " -":
|
|
139
|
+
all_old_lines.append(line_content)
|
|
140
|
+
if each_line[0] in " +":
|
|
141
|
+
all_new_lines.append(line_content)
|
|
142
|
+
block_start = _codex_find_patch_block(all_current_lines, all_old_lines, search_start)
|
|
143
|
+
if block_start < 0:
|
|
144
|
+
raise CodexPatchError("patch hunk requires matching file content")
|
|
145
|
+
all_current_lines[block_start : block_start + len(all_old_lines)] = all_new_lines
|
|
146
|
+
return all_current_lines, block_start + len(all_new_lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _codex_apply_update(prior_content: str, all_section_lines: list[str]) -> str:
|
|
150
|
+
"""Apply every hunk in one Codex update section."""
|
|
151
|
+
all_current_lines = prior_content.splitlines(keepends=True)
|
|
152
|
+
all_hunk_lines: list[str] = []
|
|
153
|
+
search_start = 0
|
|
154
|
+
has_hunk = False
|
|
155
|
+
for each_line in all_section_lines:
|
|
156
|
+
marker_text = _codex_marker_text(each_line)
|
|
157
|
+
if marker_text.startswith(_codex_hunk_marker):
|
|
158
|
+
if all_hunk_lines:
|
|
159
|
+
all_current_lines, search_start = _codex_apply_hunk(
|
|
160
|
+
all_current_lines, all_hunk_lines, search_start
|
|
161
|
+
)
|
|
162
|
+
all_hunk_lines = []
|
|
163
|
+
has_hunk = True
|
|
164
|
+
continue
|
|
165
|
+
all_hunk_lines.append(each_line)
|
|
166
|
+
if all_hunk_lines:
|
|
167
|
+
all_current_lines, _ = _codex_apply_hunk(all_current_lines, all_hunk_lines, search_start)
|
|
168
|
+
if not has_hunk:
|
|
169
|
+
raise CodexPatchError("update section requires a hunk marker")
|
|
170
|
+
return "".join(all_current_lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _codex_add_content(all_section_lines: list[str]) -> str:
|
|
174
|
+
"""Build new content from an Add File section."""
|
|
175
|
+
all_content_lines: list[str] = []
|
|
176
|
+
for each_line in all_section_lines:
|
|
177
|
+
marker_text = _codex_marker_text(each_line)
|
|
178
|
+
if marker_text == _codex_end_of_file_marker:
|
|
179
|
+
continue
|
|
180
|
+
if not each_line or each_line[0] != "+":
|
|
181
|
+
raise CodexPatchError("add section requires added lines")
|
|
182
|
+
all_content_lines.append(each_line[1:])
|
|
183
|
+
return "".join(all_content_lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _codex_read_patch_file(
|
|
187
|
+
operation: str, target_path: Path, all_section_lines: list[str]
|
|
188
|
+
) -> CodexPatchFile:
|
|
189
|
+
"""Read one pre-edit file and project its post-edit content."""
|
|
190
|
+
try:
|
|
191
|
+
prior_content = target_path.read_text(encoding="utf-8")
|
|
192
|
+
except (FileNotFoundError, IsADirectoryError, OSError, UnicodeDecodeError, ValueError) as error:
|
|
193
|
+
if operation == _codex_add_operation and isinstance(error, FileNotFoundError):
|
|
194
|
+
prior_content = ""
|
|
195
|
+
else:
|
|
196
|
+
raise CodexPatchError("patch target requires readable UTF-8 content") from error
|
|
197
|
+
if operation == _codex_add_operation:
|
|
198
|
+
if target_path.exists():
|
|
199
|
+
raise CodexPatchError("add target requires a new path")
|
|
200
|
+
post_content = _codex_add_content(all_section_lines)
|
|
201
|
+
elif operation == _codex_update_operation:
|
|
202
|
+
post_content = _codex_apply_update(prior_content, all_section_lines)
|
|
203
|
+
else:
|
|
204
|
+
if any(
|
|
205
|
+
_codex_marker_text(each_line) != _codex_end_of_file_marker
|
|
206
|
+
for each_line in all_section_lines
|
|
207
|
+
):
|
|
208
|
+
raise CodexPatchError("delete section requires end-of-file content")
|
|
209
|
+
post_content = ""
|
|
210
|
+
return CodexPatchFile(str(target_path), prior_content, post_content, operation)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_codex_apply_patch(
|
|
214
|
+
command: str, working_directory: str | None = None
|
|
215
|
+
) -> tuple[CodexPatchFile, ...]:
|
|
216
|
+
"""Return pre-edit and post-edit views for every Codex patch path."""
|
|
217
|
+
if not isinstance(command, str) or not command.strip():
|
|
218
|
+
raise CodexPatchError("patch command requires text")
|
|
219
|
+
resolved_working_directory = Path(working_directory or os.getcwd()).expanduser().resolve()
|
|
220
|
+
if not resolved_working_directory.is_dir():
|
|
221
|
+
raise CodexPatchError("patch working directory requires an existing directory")
|
|
222
|
+
all_patch_files: list[CodexPatchFile] = []
|
|
223
|
+
seen_paths: set[str] = set()
|
|
224
|
+
for each_operation, each_relative_path, each_section_lines in _codex_patch_sections(command):
|
|
225
|
+
try:
|
|
226
|
+
resolved_path = _codex_resolve_patch_path(
|
|
227
|
+
each_relative_path, resolved_working_directory
|
|
228
|
+
)
|
|
229
|
+
except (OSError, ValueError) as error:
|
|
230
|
+
raise CodexPatchError("patch path requires a resolvable location") from error
|
|
231
|
+
path_key = resolved_path.casefold()
|
|
232
|
+
if path_key in seen_paths:
|
|
233
|
+
raise CodexPatchError("patch paths require unique entries")
|
|
234
|
+
seen_paths.add(path_key)
|
|
235
|
+
all_patch_files.append(
|
|
236
|
+
_codex_read_patch_file(each_operation, Path(resolved_path), each_section_lines)
|
|
237
|
+
)
|
|
238
|
+
return tuple(all_patch_files)
|
|
@@ -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()
|