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.
@@ -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. Unsupported Claude metadata is reported, rather than silently treated as equivalent.
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
- Rules, hooks, and scripts that have no safe Codex runtime equivalent remain inert or source-only. They are preserved for inspection and are not executed as translated target tools. The capability bridge likewise emits declarative records only; it never invokes the translated surface.
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()