claude-dev-env 2.21.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
 
@@ -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()
@@ -143,6 +143,31 @@ def test_codex_payload_blocks_malformed_patch(
143
143
 
144
144
  stdout = _run_codex_payload(payload, monkeypatch, capsys)
145
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
+
146
171
  deny_payload = json.loads(stdout)
147
172
  assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
148
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",
@@ -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
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.21.0",
3
+ "version": "2.21.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.is_file():
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
- return next((each_path for each_path in all_candidates if each_path.is_file()), None)
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 = config.source_root / each_relative_path
714
- if not source_path.is_file():
715
- raise MaterializerError(
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
@@ -1,6 +1,8 @@
1
1
  import json
2
+ import subprocess
2
3
  import sys
3
4
  from pathlib import Path
5
+ from typing import Any
4
6
 
5
7
  import pytest
6
8
  import tomllib
@@ -442,6 +444,69 @@ def _apply_source_agent(source_root: Path, target_root: Path, description: str)
442
444
  return target_root / "Luna.toml"
443
445
 
444
446
 
447
+ def _write_codex_hook_source(source_root: Path) -> None:
448
+ package_root = Path(__file__).parents[2]
449
+ source_hooks = source_root / "hooks"
450
+ source_hooks.mkdir(parents=True, exist_ok=True)
451
+ (source_hooks / "hooks.json").write_bytes(
452
+ (package_root / "hooks" / "hooks.json").read_bytes()
453
+ )
454
+ for each_relative_path in materializer.codex_hook_dependency_manifest:
455
+ source_path = source_root / each_relative_path
456
+ source_path.parent.mkdir(parents=True, exist_ok=True)
457
+ source_path.write_bytes((package_root / each_relative_path).read_bytes())
458
+
459
+
460
+ def test_build_plan_rejects_reparse_hook_dependencies(
461
+ tmp_path: Path,
462
+ ) -> None:
463
+ source = tmp_path / "source"
464
+ _write_codex_hook_source(source)
465
+ dependency_path = source / materializer.codex_enforcer_script_relative_path
466
+ outside_path = tmp_path / "outside.py"
467
+ outside_path.write_text("secret", encoding="utf-8")
468
+ try:
469
+ dependency_path.unlink()
470
+ dependency_path.symlink_to(outside_path)
471
+ except OSError as error:
472
+ pytest.skip(f"symlinks unavailable: {error}")
473
+
474
+ with pytest.raises(MaterializerError, match="reparse point"):
475
+ build_plan(MaterializerConfig(source, tmp_path / "target"))
476
+
477
+
478
+ def test_publish_plan_preserves_modified_enforcer_hook_as_conflict(
479
+ tmp_path: Path,
480
+ ) -> None:
481
+ source = tmp_path / "source"
482
+ target = tmp_path / "target"
483
+ _write_codex_hook_source(source)
484
+ target.mkdir()
485
+ modified_command = f'python3 "{target / materializer.codex_enforcer_script_relative_path}" --custom'
486
+ existing_manifest = {
487
+ "hooks": {
488
+ "PreToolUse": [
489
+ {
490
+ "matcher": materializer.codex_hook_matcher,
491
+ "hooks": [
492
+ {"type": "command", "command": modified_command, "timeout": 5}
493
+ ],
494
+ }
495
+ ]
496
+ }
497
+ }
498
+ hooks_path = target / materializer.codex_hook_manifest_target_path
499
+ hooks_path.write_text(json.dumps(existing_manifest), encoding="utf-8")
500
+ config = MaterializerConfig(source, target, should_apply=True)
501
+
502
+ planned, report = build_plan(config)
503
+ publication = publish_plan(config, planned, report)
504
+
505
+ assert publication.conflicted == 1
506
+ assert publication.details["conflicted"] == ["hooks.json"]
507
+ assert json.loads(hooks_path.read_text(encoding="utf-8")) == existing_manifest
508
+
509
+
445
510
  def test_apply_with_a_missing_source_root_keeps_managed_files_and_fails(
446
511
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
447
512
  ) -> None:
@@ -712,3 +777,249 @@ def test_frontmatter_rejects_non_string_name() -> None:
712
777
  "---\nname: 1\ndescription: y\n---\n",
713
778
  "bad.md",
714
779
  )
780
+
781
+
782
+ def test_failure_blast_radius_projection_uses_the_canonical_excerpt() -> None:
783
+ canonical_rule_path = Path(__file__).parents[2] / "rules" / "failure-blast-radius.md"
784
+ canonical_rule = canonical_rule_path.read_text(encoding="utf-8")
785
+
786
+ projected_instruction = materializer.render_codex_failure_blast_radius(canonical_rule)
787
+
788
+ assert projected_instruction.startswith("Failure handling for this run")
789
+ assert "Three real attempts, then park." in projected_instruction
790
+ assert "Report as: N of M complete" in projected_instruction
791
+
792
+
793
+ def test_build_plan_publishes_owned_agents_projection_and_tracks_drift(
794
+ tmp_path: Path,
795
+ ) -> None:
796
+ canonical_rule_path = Path(__file__).parents[2] / "rules" / "failure-blast-radius.md"
797
+ source = tmp_path / "source"
798
+ target = tmp_path / "target"
799
+ rule_path = source / "rules" / "failure-blast-radius.md"
800
+ rule_path.parent.mkdir(parents=True)
801
+ rule_path.write_text(canonical_rule_path.read_text(encoding="utf-8"), encoding="utf-8")
802
+ config = MaterializerConfig(source, target, should_apply=True)
803
+
804
+ planned, report = build_plan(config, all_agents=[])
805
+ publish_plan(config, planned, report)
806
+
807
+ projected_path = target / "AGENTS.md"
808
+ first_projection = projected_path.read_text(encoding="utf-8")
809
+ manifest_record = load_manifest(config.manifest_path)["files"]["AGENTS.md"]
810
+ assert manifest_record["ownership"] == "codex-compat"
811
+ assert manifest_record["source"] == "rules/failure-blast-radius.md"
812
+
813
+ rule_path.write_text(
814
+ rule_path.read_text(encoding="utf-8").replace("Three real attempts, then park.", "Three tested attempts, then park."),
815
+ encoding="utf-8",
816
+ )
817
+ drifted_plan, _ = build_plan(config, all_agents=[])
818
+
819
+ drifted_projection = next(
820
+ each_file.content
821
+ for each_file in drifted_plan
822
+ if each_file.target_relative_path == "AGENTS.md"
823
+ )
824
+ assert drifted_projection != first_projection
825
+
826
+
827
+ def test_codex_hook_manifest_keeps_dispatcher_order_and_adds_enforcer() -> None:
828
+ hooks_path = Path(__file__).parents[2] / "hooks" / "hooks.json"
829
+ hooks_configuration = json.loads(hooks_path.read_text(encoding="utf-8"))
830
+ pre_tool_use_entries = hooks_configuration["hooks"]["PreToolUse"]
831
+ matcher_names = [each_entry.get("matcher") for each_entry in pre_tool_use_entries]
832
+
833
+ dispatcher_index = matcher_names.index("Write|Edit|MultiEdit")
834
+ enforcer_entries = [
835
+ each_entry
836
+ for each_entry in pre_tool_use_entries
837
+ if each_entry.get("matcher") == "apply_patch"
838
+ ]
839
+ assert len(enforcer_entries) == 1
840
+ assert matcher_names.index("apply_patch") > dispatcher_index
841
+ assert enforcer_entries[0]["hooks"][0]["command"].endswith(
842
+ "/hooks/blocking/code_rules_enforcer.py"
843
+ )
844
+
845
+
846
+ def _prepare_projection_fixture(
847
+ tmp_path: Path,
848
+ ) -> tuple[Path, Path, dict[str, Any], Path]:
849
+ source = tmp_path / "source"
850
+ target = tmp_path.parent / "codex-prod-target"
851
+ source_rule = source / "rules" / "failure-blast-radius.md"
852
+ source_hooks = source / "hooks" / "hooks.json"
853
+ source_rule.parent.mkdir(parents=True)
854
+ source_hooks.parent.mkdir(parents=True)
855
+ source_rule.write_text(
856
+ (Path(__file__).parents[2] / "rules" / "failure-blast-radius.md").read_text(
857
+ encoding="utf-8"
858
+ ),
859
+ encoding="utf-8",
860
+ )
861
+ source_hooks.write_text(
862
+ (Path(__file__).parents[2] / "hooks" / "hooks.json").read_text(encoding="utf-8"),
863
+ encoding="utf-8",
864
+ )
865
+ package_root = Path(__file__).parents[2]
866
+ for each_relative_path in materializer.codex_hook_dependency_manifest:
867
+ target_dependency = source / each_relative_path
868
+ target_dependency.parent.mkdir(parents=True, exist_ok=True)
869
+ target_dependency.write_bytes((package_root / each_relative_path).read_bytes())
870
+ target.mkdir()
871
+ existing_hooks = {
872
+ "hooks": {
873
+ "PreToolUse": [
874
+ {
875
+ "matcher": "Write|Edit",
876
+ "hooks": [{"type": "command", "command": "python existing.py"}],
877
+ },
878
+ {
879
+ "matcher": "apply_patch",
880
+ "hooks": [
881
+ {
882
+ "type": "command",
883
+ "command": (
884
+ f'python3 "{target / "hooks" / "blocking" / "code_rules_enforcer.py"}"'
885
+ ),
886
+ "timeout": 60,
887
+ }
888
+ ],
889
+ },
890
+ {
891
+ "matcher": "apply_patch",
892
+ "hooks": [
893
+ {"type": "command", "command": "python not_code_rules_enforcer.py"}
894
+ ],
895
+ },
896
+ ],
897
+ "Bash": [{"matcher": "", "hooks": []}],
898
+ }
899
+ }
900
+ (target / "hooks.json").write_text(json.dumps(existing_hooks), encoding="utf-8")
901
+ retired_owned_path = target / "hooks" / "obsolete.py"
902
+ retired_owned_path.parent.mkdir(parents=True)
903
+ retired_owned_path.write_text("retired owned hook\n", encoding="utf-8")
904
+ retired_manifest = {
905
+ "version": 1,
906
+ "files": {
907
+ "hooks/obsolete.py": {
908
+ "source": "hooks/obsolete.py",
909
+ "hash": materializer.hash_content(retired_owned_path.read_bytes()),
910
+ "ownership": "codex-compat",
911
+ }
912
+ },
913
+ }
914
+ (target / ".codex-compat-manifest.json").write_text(
915
+ json.dumps(retired_manifest), encoding="utf-8"
916
+ )
917
+ return source, target, existing_hooks, retired_owned_path
918
+
919
+
920
+ def _run_materializer_and_read_report(
921
+ source: Path,
922
+ target: Path,
923
+ capsys: pytest.CaptureFixture[str],
924
+ ) -> dict[str, Any]:
925
+ exit_code = materializer.main([str(source), str(target), "--apply"])
926
+ report = json.loads(capsys.readouterr().out)
927
+ assert exit_code == 0, report
928
+ assert report["errors"] == 0, report
929
+ return report
930
+
931
+
932
+ def _assert_projection_and_manifest(
933
+ target: Path,
934
+ existing_hooks: dict[str, Any],
935
+ retired_owned_path: Path,
936
+ ) -> None:
937
+ assert not retired_owned_path.exists()
938
+ projected_instruction = (target / "AGENTS.md").read_text(encoding="utf-8")
939
+ assert "Three real attempts, then park." in projected_instruction
940
+ manifest = json.loads((target / "hooks.json").read_text(encoding="utf-8"))
941
+ assert manifest["hooks"]["PreToolUse"][0] == existing_hooks["hooks"]["PreToolUse"][0]
942
+ apply_patch_entries = [
943
+ each_entry
944
+ for each_entry in manifest["hooks"]["PreToolUse"]
945
+ if each_entry["matcher"] == "apply_patch"
946
+ ]
947
+ assert len(apply_patch_entries) == 1
948
+ apply_patch_commands = [
949
+ each_hook["command"] for each_hook in apply_patch_entries[0]["hooks"]
950
+ ]
951
+ assert apply_patch_commands[0] == "python not_code_rules_enforcer.py"
952
+ managed_command = (
953
+ f'python3 "{target / "hooks" / "blocking" / "code_rules_enforcer.py"}"'
954
+ )
955
+ assert managed_command in apply_patch_commands
956
+ manifest_record = load_manifest(target / ".codex-compat-manifest.json")["files"]
957
+ assert manifest_record["AGENTS.md"]["ownership"] == "codex-compat"
958
+ assert manifest_record["hooks.json"]["source"] == "hooks/hooks.json"
959
+
960
+
961
+ def _assert_detached_enforcer_behavior(target: Path) -> None:
962
+ target_entrypoint = target / materializer.codex_enforcer_script_relative_path
963
+ allow_payload = {
964
+ "tool_name": "apply_patch",
965
+ "cwd": str(target),
966
+ "tool_input": {
967
+ "command": (
968
+ "*** Begin Patch\n"
969
+ "*** Add File: module.py\n"
970
+ "+for each_member in all_members:\n"
971
+ "+ raise AssetItemBlocked()\n"
972
+ "*** End Patch"
973
+ )
974
+ },
975
+ }
976
+ allow_run = subprocess.run(
977
+ [sys.executable, str(target_entrypoint)],
978
+ cwd=target,
979
+ input=json.dumps(allow_payload),
980
+ capture_output=True,
981
+ text=True,
982
+ check=False,
983
+ )
984
+ assert allow_run.returncode == 0
985
+ assert allow_run.stdout == ""
986
+
987
+ deny_payload = {
988
+ **allow_payload,
989
+ "tool_input": {
990
+ "command": allow_payload["tool_input"]["command"].replace(
991
+ "AssetItemBlocked", "RuntimeError"
992
+ )
993
+ },
994
+ }
995
+ deny_run = subprocess.run(
996
+ [sys.executable, str(target_entrypoint)],
997
+ cwd=target,
998
+ input=json.dumps(deny_payload),
999
+ capture_output=True,
1000
+ text=True,
1001
+ check=False,
1002
+ )
1003
+ assert deny_run.returncode == 0
1004
+ assert json.loads(deny_run.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
1005
+ assert target_entrypoint.is_file()
1006
+
1007
+
1008
+ def test_exact_cli_publishes_instruction_and_additive_hook_projection(
1009
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
1010
+ ) -> None:
1011
+ source, target, existing_hooks, retired_owned_path = _prepare_projection_fixture(
1012
+ tmp_path
1013
+ )
1014
+
1015
+ first_report = _run_materializer_and_read_report(source, target, capsys)
1016
+ assert first_report["written"] > 0
1017
+ _assert_projection_and_manifest(target, existing_hooks, retired_owned_path)
1018
+
1019
+ hooks_before_second_run = (target / "hooks.json").read_bytes()
1020
+ second_report = _run_materializer_and_read_report(source, target, capsys)
1021
+ assert second_report["written"] == 0
1022
+ assert (target / "hooks.json").read_bytes() == hooks_before_second_run
1023
+
1024
+ source.rename(tmp_path / "source-detached")
1025
+ _assert_detached_enforcer_behavior(target)