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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import json
|
|
2
|
-
|
|
2
|
+
import subprocess
|
|
3
3
|
import sys
|
|
4
|
-
import
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
8
|
+
import tomllib
|
|
7
9
|
|
|
8
10
|
module_directory = str(Path(__file__).parents[1])
|
|
9
11
|
if module_directory not in sys.path:
|
|
@@ -17,8 +19,8 @@ from codex_compat_materializer import (
|
|
|
17
19
|
PlannedFile,
|
|
18
20
|
atomic_write,
|
|
19
21
|
build_plan,
|
|
20
|
-
convert_agent,
|
|
21
22
|
content_to_bytes,
|
|
23
|
+
convert_agent,
|
|
22
24
|
hash_content,
|
|
23
25
|
load_manifest,
|
|
24
26
|
parse_frontmatter,
|
|
@@ -144,6 +146,20 @@ def test_validation_rejects_overlap_and_unsafe_paths(tmp_path: Path) -> None:
|
|
|
144
146
|
validate_target_path(config.target_root, name)
|
|
145
147
|
|
|
146
148
|
|
|
149
|
+
def test_public_legacy_call_forms_validate_collection_entries(tmp_path: Path) -> None:
|
|
150
|
+
config = MaterializerConfig(tmp_path / "source", tmp_path / "target")
|
|
151
|
+
|
|
152
|
+
with pytest.raises(TypeError, match="ClaudeAgent entries"):
|
|
153
|
+
build_plan(config, all_agents=[object()])
|
|
154
|
+
with pytest.raises(TypeError, match="PlannedFile entries"):
|
|
155
|
+
publish_plan(config, all_planned_files=[object()])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_render_codex_failure_blast_radius_requires_the_excerpt_heading() -> None:
|
|
159
|
+
with pytest.raises(MaterializerError, match="requires a Codex excerpt"):
|
|
160
|
+
materializer.render_codex_failure_blast_radius("# No excerpt\n")
|
|
161
|
+
|
|
162
|
+
|
|
147
163
|
def test_validation_rejects_reparse_point_from_portable_attribute_seam(
|
|
148
164
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
149
165
|
) -> None:
|
|
@@ -428,6 +444,69 @@ def _apply_source_agent(source_root: Path, target_root: Path, description: str)
|
|
|
428
444
|
return target_root / "Luna.toml"
|
|
429
445
|
|
|
430
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
|
+
|
|
431
510
|
def test_apply_with_a_missing_source_root_keeps_managed_files_and_fails(
|
|
432
511
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
433
512
|
) -> None:
|
|
@@ -698,3 +777,249 @@ def test_frontmatter_rejects_non_string_name() -> None:
|
|
|
698
777
|
"---\nname: 1\ndescription: y\n---\n",
|
|
699
778
|
"bad.md",
|
|
700
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)
|