agent-bios 0.16.0 → 0.17.0

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.
@@ -40,23 +40,59 @@ CLAUDE_HOOK_EVENTS = frozenset((
40
40
  "UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "SubagentStart",
41
41
  "SubagentStop", "PreCompact", "PermissionRequest", "TeammateIdle",
42
42
  "TaskCompleted", "ConfigChange", "WorktreeCreate", "WorktreeRemove",
43
+ "Setup", "InstructionsLoaded", "UserPromptExpansion", "MessageDisplay",
44
+ "PermissionDenied", "PostToolBatch", "TaskCreated", "StopFailure",
45
+ "CwdChanged", "DirectoryAdded", "FileChanged", "PostCompact",
46
+ "PreModelSwitch", "PostModelSwitch", "Elicitation", "ElicitationResult",
43
47
  ))
48
+ # Command-event vocabulary from the two hosts' hook references (2026-09-11).
49
+ # Authoring accepts the union; compilation checks the selected host. A host-only
50
+ # event stays authored and becomes unavailable on the other host, never renamed.
51
+ CODEX_HOOK_EVENTS = frozenset((
52
+ "PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact",
53
+ "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart",
54
+ "SubagentStop", "Stop", "Interrupt",
55
+ ))
56
+ HOOK_EVENTS_BY_HOST = {"claude": CLAUDE_HOOK_EVENTS, "codex": CODEX_HOOK_EVENTS}
57
+ HOOK_EVENTS = CLAUDE_HOOK_EVENTS | CODEX_HOOK_EVENTS
44
58
 
45
59
 
46
60
  class CatalogError(ValueError):
47
61
  """A catalog cannot be compiled safely."""
48
62
 
49
63
 
50
- def validate_hook_binding(hook: Any) -> None:
64
+ def validate_hook_binding(hook: Any, host: str | None = None) -> None:
51
65
  if not isinstance(hook, dict) or set(hook) != {"event", "matcher"}:
52
66
  raise CatalogError("hook binding must contain only event and matcher")
53
67
  event, matcher = hook.get("event"), hook.get("matcher")
54
- if not isinstance(event, str) or event not in CLAUDE_HOOK_EVENTS:
55
- raise CatalogError(f"unsupported Claude hook event {event!r}")
68
+ events = HOOK_EVENTS if host is None else HOOK_EVENTS_BY_HOST[host]
69
+ if not isinstance(event, str) or event not in events:
70
+ raise CatalogError(f"unsupported {host + ' ' if host else ''}hook event {event!r}")
56
71
  if not isinstance(matcher, str) or not matcher or any(char in matcher for char in "\n\r\x00"):
57
72
  raise CatalogError("hook matcher must be one non-empty line")
58
73
 
59
74
 
75
+ def validate_native_hook_config(value: Any, host: str) -> None:
76
+ """Validate compiler-owned command registrations, not arbitrary host config."""
77
+ if not isinstance(value, dict):
78
+ raise CatalogError("native hooks must be an event mapping")
79
+ for event, groups in value.items():
80
+ if not isinstance(groups, list) or not groups:
81
+ raise CatalogError("native hook event must contain matcher groups")
82
+ for group in groups:
83
+ if not isinstance(group, dict) or set(group) != {"matcher", "hooks"}:
84
+ raise CatalogError("invalid native hook matcher group")
85
+ validate_hook_binding({"event": event, "matcher": group["matcher"]}, host)
86
+ handlers = group["hooks"]
87
+ if not isinstance(handlers, list) or not handlers:
88
+ raise CatalogError("native hook group must contain commands")
89
+ for handler in handlers:
90
+ if (not isinstance(handler, dict) or set(handler) != {"type", "command"}
91
+ or handler["type"] != "command" or not isinstance(handler["command"], str)
92
+ or not handler["command"] or "\x00" in handler["command"]):
93
+ raise CatalogError("invalid native hook command")
94
+
95
+
60
96
  def _read_utf8(path: pathlib.Path) -> str:
61
97
  if path.is_symlink():
62
98
  raise CatalogError(f"symlink input is not a corpus member: {path}")
@@ -667,10 +703,10 @@ def _plugin_namespace(ref: str) -> str:
667
703
  def _native_hook_carrier(item: dict[str, Any]) -> tuple[str, dict[str, str]]:
668
704
  source = item.get("origin", {}).get("source_path")
669
705
  if item.get("kind") != "hook" or not isinstance(source, str):
670
- raise CatalogError("event item has no installed Claude hook carrier provenance")
706
+ raise CatalogError("event item has no installed hook carrier provenance")
671
707
  matched = re.fullmatch(r"claude/hooks/([A-Za-z0-9][A-Za-z0-9._-]*\.py)", source)
672
708
  if not matched:
673
- raise CatalogError("event item origin is not an installed Claude hooks/*.py carrier")
709
+ raise CatalogError("event item origin is not an installed canonical hooks/*.py carrier")
674
710
  member = f"hooks/{matched.group(1)}"
675
711
  if member not in item["members"]:
676
712
  raise CatalogError("event item no longer retains its installed hook entrypoint")
@@ -680,7 +716,7 @@ def _native_hook_carrier(item: dict[str, Any]) -> tuple[str, dict[str, str]]:
680
716
  raise CatalogError(f"native hook entrypoint is not valid Python: {exc.msg}") from exc
681
717
  binding = item.get("hook")
682
718
  if not isinstance(binding, dict):
683
- raise CatalogError("event item has no registered Claude hook binding")
719
+ raise CatalogError("event item has no registered hook binding")
684
720
  return member, binding
685
721
 
686
722
 
@@ -716,9 +752,9 @@ def _assert_native_member_safety(item: dict[str, Any], carrier: str, surface: st
716
752
  raise CatalogError("delegated item must retain exactly its one agents/*.md carrier")
717
753
  for member in item["members"]:
718
754
  first = pathlib.PurePosixPath(member).parts[0]
719
- if first in {"skills", "commands", ".claude-plugin"}:
755
+ if first in {"skills", "commands", ".claude-plugin", ".codex-plugin", ".mcp.json"}:
720
756
  raise CatalogError(f"native plugin member would auto-discover {member!r}")
721
- if member == "hooks/hooks.json" or member == "settings.json":
757
+ if member in {"hooks/hooks.json", "hooks.json", "settings.json", "config.toml"}:
722
758
  raise CatalogError(f"native plugin member would override generated registration {member!r}")
723
759
  if first == "agents" and member != carrier:
724
760
  raise CatalogError(f"native plugin member adds an unselected agent carrier {member!r}")
@@ -736,15 +772,19 @@ def _write_plugin_manifest(root: pathlib.Path, namespace: str, item: dict[str, A
736
772
  return relative.as_posix()
737
773
 
738
774
 
739
- def _emit_native_claude_item(
775
+ def _emit_native_item(
740
776
  item: dict[str, Any], destination: pathlib.Path, namespace: str, base_instruction_text: str,
777
+ host: str, reference_root: pathlib.Path,
741
778
  ) -> tuple[list[str], dict[str, Any] | None]:
742
779
  root = destination / "items" / _safe_ref_path(item["ref"])
743
780
  if item["surface"] == "event":
744
781
  member, binding = _native_hook_carrier(item)
782
+ validate_hook_binding(binding, host)
745
783
  _assert_native_member_safety(item, member, "event")
746
- emitted = [_write_plugin_manifest(root, namespace, item)]
747
- command = f'{shlex.quote(sys.executable)} "${{CLAUDE_PLUGIN_ROOT}}/{member}"'
784
+ emitted = [_write_plugin_manifest(root, namespace, item)] if host == "claude" else []
785
+ reference = reference_root / "items" / _safe_ref_path(item["ref"]) / member
786
+ target = f'"${{CLAUDE_PLUGIN_ROOT}}/{member}"' if host == "claude" else shlex.quote(str(reference))
787
+ command = f'{shlex.quote(sys.executable)} {target}'
748
788
  hooks = {"hooks": {binding["event"]: [{
749
789
  "matcher": binding["matcher"],
750
790
  "hooks": [{"type": "command", "command": command}],
@@ -752,8 +792,11 @@ def _emit_native_claude_item(
752
792
  relative = pathlib.PurePosixPath("hooks") / "hooks.json"
753
793
  _write_private(root / relative, json.dumps(hooks, ensure_ascii=False, separators=(",", ":")) + "\n")
754
794
  emitted.append(relative.as_posix())
755
- return emitted, {"ref": item["ref"], "plugin": namespace, "hook": binding, "entrypoint": member}
795
+ return emitted, {"ref": item["ref"], "plugin": namespace, "hook": binding,
796
+ "entrypoint": member, "hooks": hooks["hooks"]}
756
797
  if item["surface"] == "delegated":
798
+ if host != "claude":
799
+ raise CatalogError("native delegated adapter is unsupported for codex: authored Claude agent frontmatter requires a Codex agent projection")
757
800
  member, name = _native_agent_carrier(item)
758
801
  _assert_native_member_safety(item, member, "delegated")
759
802
  # Carrier validation happens before any plugin write. The child receives
@@ -764,11 +807,11 @@ def _emit_native_claude_item(
764
807
  emitted = [_write_plugin_manifest(root, namespace, item)]
765
808
  return emitted, {"ref": item["ref"], "plugin": namespace, "agent_name": name,
766
809
  "route": f"{namespace}:{name}"}
767
- raise CatalogError(f"unsupported native Claude surface {item['surface']!r}")
810
+ raise CatalogError(f"unsupported native surface {item['surface']!r}")
768
811
 
769
812
 
770
813
  def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host: str,
771
- native: bool = False) -> dict[str, Any]:
814
+ native: bool = False, reference_root: pathlib.Path | None = None) -> dict[str, Any]:
772
815
  """Emit selected items into one private destination.
773
816
 
774
817
  ``host`` is currently a validation seam for adapters. Claude and Codex use
@@ -782,6 +825,9 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
782
825
  if not isinstance(items, list):
783
826
  raise CatalogError("compiler requires an item list")
784
827
  destination = pathlib.Path(destination)
828
+ # Emit bytes into staging while quoting paths for their final location. A
829
+ # string replacement after shell/Python quoting cannot relocate all paths.
830
+ reference_root = pathlib.Path(reference_root) if reference_root is not None else destination
785
831
  if destination.exists() and destination.is_symlink():
786
832
  raise CatalogError(f"compiler destination must not be a symlink: {destination}")
787
833
  destination.mkdir(parents=True, exist_ok=True)
@@ -790,7 +836,7 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
790
836
  unresolved = [item["ref"] for item in normalized if item.get("content_conflict")]
791
837
  if unresolved:
792
838
  raise CatalogError(f"content_conflict: selected item needs reconciliation: {', '.join(unresolved)}")
793
- rewrites = _resource_rewrites(normalized, destination)
839
+ rewrites = _resource_rewrites(normalized, reference_root)
794
840
  files: list[str] = []
795
841
  for item in normalized:
796
842
  emitted: dict[str, str] = {}
@@ -799,10 +845,10 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
799
845
  relative = pathlib.PurePosixPath("items") / _safe_ref_path(item["ref"]) / member
800
846
  target = _destination_path(destination, relative.as_posix())
801
847
  rewritten = _rewrite_resources(content, rewrites)
802
- if native and host == "claude" and item["surface"] == "event":
848
+ if native and item["surface"] == "event":
803
849
  rewritten = _rewrite_native_hook_guide(item, rewritten, rewrites)
804
850
  _write_private(target, rewritten)
805
- emitted[member] = str(target)
851
+ emitted[member] = str(reference_root / relative)
806
852
  files.append(relative.as_posix())
807
853
  item["_emitted_members"] = emitted
808
854
 
@@ -822,7 +868,7 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
822
868
  instruction_parts.extend(_rewrite_resources(item["body"], rewrites).rstrip("\n") for item in always)
823
869
  instruction_parts.append("")
824
870
  if router_path is not None:
825
- instruction_parts.append(f"Relevant procedures: {router_path}")
871
+ instruction_parts.append(f"Relevant procedures: {reference_root / 'router/relevant.md'}")
826
872
  if requested:
827
873
  instruction_parts.append("Requested procedures:")
828
874
  for item in requested:
@@ -834,6 +880,7 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
834
880
  assets: dict[str, Any] = {}
835
881
  plugin_names: dict[str, str] = {}
836
882
  plugin_roots: list[str] = []
883
+ hook_config: dict[str, list[dict[str, Any]]] = {}
837
884
  agent_routes: list[dict[str, str]] = []
838
885
  for item in normalized:
839
886
  if item["surface"] not in {"event", "delegated"}:
@@ -842,27 +889,29 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
842
889
  unavailable.append({"ref": item["ref"], "surface": item["surface"],
843
890
  "reason": f"native {item['surface']} consumption is disabled; opt in with --corpus-native"})
844
891
  continue
845
- if host != "claude":
846
- unavailable.append({"ref": item["ref"], "surface": item["surface"],
847
- "reason": f"native {item['surface']} adapter is unsupported for {host}"})
848
- continue
849
892
  namespace = _plugin_namespace(item["ref"])
850
893
  prior = plugin_names.get(namespace)
851
894
  if prior is not None and prior != item["ref"]:
852
- raise CatalogError(f"Claude plugin namespace collision: {prior} and {item['ref']}")
895
+ raise CatalogError(f"native namespace collision: {prior} and {item['ref']}")
853
896
  plugin_names[namespace] = item["ref"]
854
897
  try:
855
- emitted, route = _emit_native_claude_item(item, destination, namespace, base_instruction_text)
898
+ emitted, route = _emit_native_item(item, destination, namespace, base_instruction_text, host, reference_root)
856
899
  except CatalogError as exc:
857
900
  unavailable.append({"ref": item["ref"], "surface": item["surface"], "reason": str(exc)})
858
901
  continue
859
902
  root_relative = (pathlib.PurePosixPath("items") / _safe_ref_path(item["ref"])).as_posix()
860
- plugin_roots.append(root_relative)
903
+ if host == "claude":
904
+ plugin_roots.append(root_relative)
905
+ else:
906
+ for event, groups in route["hooks"].items():
907
+ hook_config.setdefault(event, []).extend(groups)
861
908
  files.extend((pathlib.PurePosixPath(root_relative) / path).as_posix() for path in emitted)
862
909
  if route and item["surface"] == "delegated":
863
910
  agent_routes.append(route)
864
911
  if native and host == "claude":
865
912
  assets["claude_plugins"] = sorted(plugin_roots)
913
+ if native and host == "codex":
914
+ assets["codex_hooks"] = hook_config
866
915
  instruction_text = base_instruction_text
867
916
  if agent_routes:
868
917
  instruction_text += "\nNative delegated agents:\n"
@@ -33,6 +33,7 @@ from corpus_transaction import (
33
33
  operation_scope,
34
34
  operation_scope_active,
35
35
  pending_status,
36
+ reject_symlink_ancestors,
36
37
  transaction_lock,
37
38
  )
38
39
 
@@ -395,11 +396,17 @@ class CorpusInstaller:
395
396
  "mode": "private-session-scoped",
396
397
  }) + b"\n"
397
398
 
399
+ @staticmethod
400
+ def _safe_transaction_target(path: Path) -> None:
401
+ try:
402
+ reject_symlink_ancestors(path)
403
+ except TransactionError as exc:
404
+ raise InstallError(str(exc)) from exc
405
+
398
406
  @staticmethod
399
407
  def _file_version(path: Path, *, allow_bytes: bool = True) -> dict[str, Any]:
400
408
  """A journaled exact-state predicate; secrets use hash-only predicates."""
401
- if path.is_symlink():
402
- raise InstallError(f"refusing symlink transaction target: {path}")
409
+ CorpusInstaller._safe_transaction_target(path)
403
410
  if not path.exists():
404
411
  return {"exists": False}
405
412
  if not path.is_file():
@@ -419,7 +426,9 @@ class CorpusInstaller:
419
426
 
420
427
  @staticmethod
421
428
  def _matches_version(path: Path, version: dict[str, Any]) -> bool:
422
- if path.is_symlink():
429
+ try:
430
+ CorpusInstaller._safe_transaction_target(path)
431
+ except InstallError:
423
432
  return False
424
433
  if not version.get("exists"):
425
434
  return not path.exists()
@@ -436,6 +445,7 @@ class CorpusInstaller:
436
445
  _atomic_json(journal, value)
437
446
 
438
447
  def _write_planned(self, path: Path, version: dict[str, Any], mode: int) -> None:
448
+ self._safe_transaction_target(path)
439
449
  encoded = version.get("bytes_b64")
440
450
  if not isinstance(encoded, str):
441
451
  if not version.get("exists"):
@@ -445,12 +455,19 @@ class CorpusInstaller:
445
455
  _atomic_bytes(path, base64.b64decode(encoded.encode("ascii"), validate=True), mode)
446
456
 
447
457
  def _assert_preflight_paths(self, paths: list[dict[str, Any]]) -> None:
458
+ for entry in paths:
459
+ self._safe_transaction_target(Path(entry["path"]))
448
460
  conflicts = [entry["path"] for entry in paths
449
461
  if not self._matches_version(Path(entry["path"]), entry["before"])
450
462
  and not self._matches_version(Path(entry["path"]), entry["after"])]
451
463
  if conflicts:
452
464
  raise InstallError("owned path changed during transaction preflight: " + ", ".join(conflicts))
453
465
 
466
+ def _projection_matches(self, entry: dict[str, Any]) -> bool:
467
+ path, after = Path(entry["path"]), entry["after"]
468
+ return self._matches_version(path, after) and (
469
+ not after.get("exists") or path.stat().st_mode & 0o777 == int(entry["mode"]))
470
+
454
471
  def _old_record(self) -> dict[str, Any] | None:
455
472
  return _read_json(self.record_path) if self.record_path.exists() else None
456
473
 
@@ -518,6 +535,31 @@ class CorpusInstaller:
518
535
  f"export AGENT_BIOS_PACKAGE_ROOT={quoted}\n"
519
536
  "exec python3 \"$AGENT_BIOS_PACKAGE_ROOT/launch/agent-launch.py\" \"$@\"\n").encode("utf-8")
520
537
 
538
+ def _shell_manager(self):
539
+ path = Path(__file__).resolve().parent.parent / "launch/shell_integration.py"
540
+ spec = importlib.util.spec_from_file_location("agent_bios_shell_integration", path)
541
+ if spec is None or spec.loader is None:
542
+ raise InstallError("shell connection manager is unavailable")
543
+ module = importlib.util.module_from_spec(spec)
544
+ spec.loader.exec_module(module)
545
+ return module.ShellIntegration(self.env, self.repo)
546
+
547
+ def _shell_paths(self, action: str) -> list[dict[str, Any]]:
548
+ try:
549
+ manager = self._shell_manager()
550
+ # A receipt-less managed script may survive an interrupted opt-in.
551
+ # With neither, do not inspect or claim the user's startup file.
552
+ if not manager.has_connection():
553
+ return []
554
+ # Install owns and validates the upcoming launcher projection; it
555
+ # must also be able to repair a missing/non-executable entrypoint.
556
+ changes = manager.plan(action, installing=action == "restore")["changes"]
557
+ except (OSError, RuntimeError) as exc:
558
+ raise InstallError(str(exc)) from exc
559
+ return [{"path": str(row["path"]), "before": self._planned_version(row["before"]),
560
+ "after": self._planned_version(row["after"]), "mode": row["mode"], "archive": False}
561
+ for row in changes]
562
+
521
563
  @_serialized
522
564
  def install(self, domains: str | None = None, dry_run: bool = False) -> dict[str, Any]:
523
565
  files = self._package_files()
@@ -540,6 +582,7 @@ class CorpusInstaller:
540
582
  if dry_run:
541
583
  return {"dry_run": True, "release": str(release), "release_digest": digest,
542
584
  "files": len(entries), "selection": requested}
585
+ shell_paths = self._shell_paths("restore")
543
586
  # Stage the immutable baseline before publishing either source pointers
544
587
  # or launcher projections. Store owns those source pointers; this
545
588
  # coordinator owns all cross-owner ordering.
@@ -581,6 +624,7 @@ class CorpusInstaller:
581
624
  paths = [{"path": str(path), "before": self._file_version(path),
582
625
  "after": self._planned_version(content), "mode": mode}
583
626
  for path, content, mode in owned]
627
+ paths.extend(shell_paths)
584
628
  paths.extend([
585
629
  {"path": str(self.record_path), "before": self._file_version(self.record_path),
586
630
  "after": self._planned_version(_canonical(record) + b"\n"), "mode": 0o600},
@@ -621,7 +665,7 @@ class CorpusInstaller:
621
665
  # is harmless to readers because the journal guard is already durable.
622
666
  for entry in data["paths"]:
623
667
  path = Path(entry["path"])
624
- if not self._matches_version(path, entry["after"]):
668
+ if not self._projection_matches(entry):
625
669
  self._write_planned(path, entry["after"], int(entry["mode"]))
626
670
  data["phase"] = "source"
627
671
  self._journal_write(journal, data)
@@ -645,7 +689,7 @@ class CorpusInstaller:
645
689
 
646
690
  def _verify_transaction_install(self, data: dict[str, Any]) -> None:
647
691
  for entry in data["paths"]:
648
- if not self._matches_version(Path(entry["path"]), entry["after"]):
692
+ if not self._projection_matches(entry):
649
693
  raise InstallError(f"transaction projection did not verify: {entry['path']}")
650
694
  record = _read_json(self.record_path)
651
695
  release = Path(record["package_root"])
@@ -756,13 +800,21 @@ class CorpusInstaller:
756
800
  @_serialized
757
801
  def uninstall(self, dry_run: bool = False) -> dict[str, Any]:
758
802
  record = self._old_record()
803
+ if record is not None:
804
+ release = Path(record.get("package_root", ""))
805
+ if not _inside(release, self.runtime / "releases") or release.is_symlink() or not release.is_dir():
806
+ raise InstallError("private install record has unsafe package root")
807
+ self._validate_owned_record(record, release)
808
+ shell_removed: list[str] = []
809
+ try:
810
+ manager = self._shell_manager()
811
+ if manager.has_connection():
812
+ shell_removed = manager.apply("remove", dry_run)["changed_paths"]
813
+ except (OSError, RuntimeError) as exc:
814
+ raise InstallError(str(exc)) from exc
759
815
  if record is None:
760
- return {"removed": [], "preserved": ["no private install record"]}
761
- release = Path(record.get("package_root", ""))
762
- if not _inside(release, self.runtime / "releases") or release.is_symlink() or not release.is_dir():
763
- raise InstallError("private install record has unsafe package root")
764
- self._validate_owned_record(record, release)
765
- removed: list[str] = []
816
+ return {"removed": shell_removed, "preserved": ["no private install record"]}
817
+ removed: list[str] = list(shell_removed)
766
818
  preserved: list[str] = []
767
819
  for entry in [record.get("launcher"), *(record.get("config_files") or [])]:
768
820
  if not isinstance(entry, dict):
@@ -794,7 +846,7 @@ class CorpusInstaller:
794
846
  def _reset_layout(self, record: dict[str, Any], release: Path) -> tuple[list[Path], Path, Path, list[tuple[Path, bytes, int]]]:
795
847
  local = [self.launch_root / name for name in ("presets.local.toml", "review-methods.local.toml", "launcher.local.toml")]
796
848
  connections = self.launch_root.parent / "agent-bios"
797
- cleanup = [*local, connections / "ingest-url"]
849
+ cleanup = [*local, connections / "ingest-url", self.user_root / "understand" / "state.json"]
798
850
  owned = [(self.bin_root / "agent-launch", self._launcher_body(release), 0o755),
799
851
  (self.launch_root / "profiles.toml", (release / "launch" / "agent-launch.toml").read_bytes(), 0o644)]
800
852
  owned.extend((self.launch_root / "i18n" / p.name, p.read_bytes(), 0o644)
@@ -817,6 +869,7 @@ class CorpusInstaller:
817
869
  cleanup, secret, _connections, owned = self._reset_layout(record, release)
818
870
  targets = [{"path": str(path), "before": self._file_version(path), "after": {"exists": False}, "mode": 0o600,
819
871
  "archive": True} for path in cleanup]
872
+ targets.extend(self._shell_paths("remove"))
820
873
  targets.extend({"path": str(path), "before": self._file_version(path), "after": self._planned_version(body),
821
874
  "mode": mode, "archive": False} for path, body, mode in owned)
822
875
  status_path = self.state_root / "corpus-status.json"
@@ -913,7 +966,7 @@ class CorpusInstaller:
913
966
  result = self._store(release).apply(data["intent"]["plan_id"], data["intent"]["expected_revision"])
914
967
  data["result"] = result; data["phase"] = "projections"; _atomic_json(journal, data)
915
968
  for entry in targets:
916
- if not entry.get("archive") and not self._matches_version(Path(entry["path"]), entry["after"]): self._write_planned(Path(entry["path"]), entry["after"], int(entry["mode"]))
969
+ if not entry.get("archive") and not self._projection_matches(entry): self._write_planned(Path(entry["path"]), entry["after"], int(entry["mode"]))
917
970
  if prior.get("exists"):
918
971
  if self._matches_version(secret, prior):
919
972
  secret.unlink()
@@ -216,10 +216,20 @@ class CodexServer:
216
216
 
217
217
 
218
218
  def config_flags(argv, exclude_developer=False):
219
+ argv = argv[:argv.index('--')] if '--' in argv else argv
219
220
  result = []
220
221
  index = 0
221
222
  while index < len(argv):
222
223
  token = argv[index]
224
+ if token in ('--enable', '--disable'):
225
+ if index + 1 >= len(argv):
226
+ raise SessionError(f"missing value for {token}")
227
+ result += ['-c', f"features.{argv[index + 1]}={'true' if token == '--enable' else 'false'}"]
228
+ index += 2
229
+ continue
230
+ if token.startswith(('--enable=', '--disable=')):
231
+ name, feature = token.split('=', 1)
232
+ result += ['-c', f"features.{feature}={'true' if name == '--enable' else 'false'}"]
223
233
  if token in ('-c', '--config'):
224
234
  if index + 1 >= len(argv):
225
235
  raise SessionError(f"missing value for {token}")
@@ -274,6 +284,8 @@ def named_profile(argv):
274
284
 
275
285
 
276
286
  def replace_developer(argv, text):
287
+ boundary = argv.index('--') if '--' in argv else len(argv)
288
+ argv, tail = argv[:boundary], argv[boundary:]
277
289
  result, index = [], 0
278
290
  while index < len(argv):
279
291
  token = argv[index]
@@ -286,7 +298,7 @@ def replace_developer(argv, text):
286
298
  continue
287
299
  result.append(token)
288
300
  index += 1
289
- return [*result, '-c', 'developer_instructions=' + json.dumps(text)]
301
+ return [*result, '-c', 'developer_instructions=' + json.dumps(text), *tail]
290
302
 
291
303
 
292
304
  def instruction_value(argv, host):
@@ -300,10 +312,15 @@ def instruction_value(argv, host):
300
312
  return ''
301
313
 
302
314
 
303
- def _claude_plugin_paths(snapshot):
315
+ def _native_assets(snapshot):
304
316
  assets = snapshot.get('assets') or {}
305
- if not isinstance(assets, dict) or set(assets) - {'claude_plugins'}:
317
+ if not isinstance(assets, dict) or set(assets) - {'claude_plugins', 'codex_hooks'}:
306
318
  raise SessionError('invalid native snapshot assets')
319
+ return assets
320
+
321
+
322
+ def _claude_plugin_paths(snapshot):
323
+ assets = _native_assets(snapshot)
307
324
  values = assets.get('claude_plugins', [])
308
325
  if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
309
326
  raise SessionError('invalid native plugin paths')
@@ -324,6 +341,82 @@ def _claude_plugin_paths(snapshot):
324
341
  return result
325
342
 
326
343
 
344
+ def _codex_hook_config(snapshot):
345
+ from corpus_catalog import CatalogError, validate_native_hook_config
346
+ hooks = _native_assets(snapshot).get('codex_hooks', {})
347
+ try:
348
+ validate_native_hook_config(hooks, 'codex')
349
+ except CatalogError as exc:
350
+ raise SessionError(f'invalid native snapshot hooks: {exc}') from exc
351
+ return hooks
352
+
353
+
354
+ def _toml_value(value):
355
+ """Serialize the JSON values used by hook config as TOML inline values."""
356
+ if isinstance(value, str):
357
+ return json.dumps(value, ensure_ascii=False)
358
+ if isinstance(value, bool):
359
+ return str(value).lower()
360
+ if isinstance(value, (int, float)):
361
+ return str(value)
362
+ if isinstance(value, list):
363
+ return '[' + ', '.join(_toml_value(item) for item in value) + ']'
364
+ if isinstance(value, dict):
365
+ return '{' + ', '.join(_toml_value(key) + ' = ' + _toml_value(item)
366
+ for key, item in value.items()) + '}'
367
+ raise SessionError('hook config contains a value that TOML cannot represent')
368
+
369
+
370
+ def _with_codex_hooks(argv, hooks, config):
371
+ if not hooks:
372
+ return argv
373
+ # Codex loads every config layer's hooks independently. Copy only existing
374
+ # session-flag groups; copying effective user/project groups would run them twice.
375
+ layers = config.get('layers')
376
+ if not isinstance(layers, list):
377
+ raise SessionError('Codex native hooks require config/read layer provenance')
378
+ session_layers = [layer for layer in layers if layer.get('name', {}).get('type') == 'sessionFlags']
379
+ if len(session_layers) > 1:
380
+ raise SessionError('Codex reported ambiguous session hook configuration')
381
+ existing = session_layers[0].get('config', {}).get('hooks', {}) if session_layers else {}
382
+ if not isinstance(existing, dict):
383
+ raise SessionError('Codex session hooks are not an event mapping')
384
+ flags = []
385
+ for event, groups in sorted(hooks.items()):
386
+ prior = existing.get(event, [])
387
+ if not isinstance(prior, list):
388
+ raise SessionError(f'Codex session hooks.{event} is not a matcher list')
389
+ flags += ['-c', f'hooks.{event}={_toml_value(prior + groups)}']
390
+ boundary = argv.index('--') if '--' in argv else len(argv)
391
+ return [*argv[:boundary], *flags, *argv[boundary:]]
392
+
393
+
394
+ def validate_codex_hooks(command, snapshot, argv, cwd, env):
395
+ """Prove discovery on this runtime; preserve native enablement and trust."""
396
+ hooks = _codex_hook_config(snapshot)
397
+ if not hooks:
398
+ return
399
+ expected = {(event[0].lower() + event[1:], group['matcher'], handler['command'])
400
+ for event, groups in hooks.items() for group in groups for handler in group['hooks']}
401
+ with CodexServer(command, config_flags(argv), cwd, env) as server:
402
+ result = server.call('hooks/list', {'cwds': [str(cwd)]})
403
+ config = server.call('config/read', {'cwd': str(cwd), 'includeLayers': False})
404
+ rows = result.get('data')
405
+ if not isinstance(rows, list) or len(rows) != 1 or rows[0].get('errors'):
406
+ raise SessionError('Codex could not discover the selected session hooks')
407
+ found = {(row.get('eventName'), row.get('matcher'), row.get('command')): row
408
+ for row in rows[0].get('hooks', []) if row.get('source') == 'sessionFlags'}
409
+ if not expected.issubset(found):
410
+ raise SessionError('Codex did not discover every selected session hook; check host hook policy and runtime support')
411
+ if config.get('config', {}).get('features', {}).get('hooks') is False:
412
+ print('agent-bios: selected Codex hooks are disabled by the effective native hooks feature setting.', file=sys.stderr)
413
+ pending = sum(not found[key].get('enabled') or found[key].get('trustStatus') != 'trusted'
414
+ for key in expected)
415
+ if pending:
416
+ print(f'agent-bios: {pending} selected Codex hook(s) need native review or enablement; '
417
+ 'open /hooks in this session. Registration does not establish execution.', file=sys.stderr)
418
+
419
+
327
420
  def validate_claude_plugins(command, snapshot, cwd, env):
328
421
  """Native validation is manifest-only; assert actual carriers separately."""
329
422
  for raw in _claude_plugin_paths(snapshot):
@@ -386,12 +479,14 @@ def compose_argv(command, argv, host, snapshot, cwd=None, env=None, include_glob
386
479
  'effective developer instructions cannot be read without rebuilding native '
387
480
  'profile semantics'
388
481
  )
482
+ hooks = _codex_hook_config(snapshot)
389
483
  with CodexServer(command, config_flags(argv, exclude_developer=True), cwd, env) as server:
390
- config = server.call('config/read', {'cwd': str(cwd or pathlib.Path.cwd()), 'includeLayers': False})
484
+ config = server.call('config/read', {'cwd': str(cwd or pathlib.Path.cwd()), 'includeLayers': bool(hooks)})
391
485
  native = config.get('config', {}).get('developer_instructions') or ''
392
486
  if not isinstance(native, str):
393
487
  raise SessionError('effective developer_instructions is not text')
394
- return replace_developer(argv, '\n\n'.join(x for x in (native, content, contract) if x))
488
+ result = replace_developer(argv, '\n\n'.join(x for x in (native, content, contract) if x))
489
+ return _with_codex_hooks(result, hooks, config)
395
490
  boundary = argv.index('--') if '--' in argv else len(argv)
396
491
  options, tail = argv[:boundary], argv[boundary:]
397
492
  result, index = [], 0
@@ -682,14 +777,18 @@ def launch(command, argv, state_root, host, snapshot, cwd=None, env=None, resume
682
777
  env = restore_environment(record, env)
683
778
  if not _record_instruction_choice(record, env):
684
779
  _claude_exclusion_version(command, record['cwd'], env)
780
+ pinned = _verified_launch_snapshot(state_root, {'path': record['snapshot_path'], 'content_ref': record['content_ref']})
685
781
  if host == 'claude':
686
- pinned = _verified_launch_snapshot(state_root, {'path': record['snapshot_path'], 'content_ref': record['content_ref']})
687
782
  validate_claude_plugins(command, pinned, record['cwd'], env)
783
+ else:
784
+ validate_codex_hooks(command, pinned, record['argv'], record['cwd'], env)
688
785
  argv = record['argv']
689
786
  native = ['resume', resume_id, *argv] if host == 'codex' else ['--resume', resume_id, *argv]
690
787
  return subprocess.call([command, *native], cwd=record['cwd'], env=env)
691
788
  snapshot = _verified_launch_snapshot(state_root, snapshot)
692
789
  argv = compose_argv(command, argv, host, snapshot, cwd, env, include_global_instructions)
790
+ if host == 'codex':
791
+ validate_codex_hooks(command, snapshot, argv, cwd, env)
693
792
  if host == 'claude':
694
793
  validate_claude_plugins(command, snapshot, cwd, env)
695
794
  record = prepare(state_root, host, snapshot, argv, cwd, env, include_global_instructions)
@@ -174,8 +174,17 @@ def _snapshot_file_digests(root: Path, files: list[str]) -> dict[str, str]:
174
174
 
175
175
 
176
176
  def _snapshot_assets(value: Any, files: list[str]) -> dict[str, Any]:
177
- if not isinstance(value, dict) or set(value) - {"claude_plugins"}:
177
+ if not isinstance(value, dict) or set(value) - {"claude_plugins", "codex_hooks"}:
178
178
  raise ValidationError("invalid snapshot native assets")
179
+ if "codex_hooks" in value:
180
+ try:
181
+ from corpus_catalog import CatalogError, validate_native_hook_config
182
+ except ImportError:
183
+ from .corpus_catalog import CatalogError, validate_native_hook_config
184
+ try:
185
+ validate_native_hook_config(value["codex_hooks"], "codex")
186
+ except CatalogError as exc:
187
+ raise ValidationError(f"invalid snapshot native hooks: {exc}") from exc
179
188
  plugins = value.get("claude_plugins", [])
180
189
  if not isinstance(plugins, list) or not all(isinstance(path, str) for path in plugins):
181
190
  raise ValidationError("snapshot plugins must be relative paths")
@@ -1304,7 +1313,7 @@ class CorpusStore:
1304
1313
  staging = root.with_name(f".{root.name}.staging-{uuid.uuid4().hex}")
1305
1314
  staging.mkdir(parents=True, exist_ok=False)
1306
1315
  try:
1307
- compiled = catalog.compile_items(_copy_json(selected), staging, host, native=True) if native else catalog.compile_items(_copy_json(selected), staging, host)
1316
+ compiled = catalog.compile_items(_copy_json(selected), staging, host, native=True, reference_root=root) if native else catalog.compile_items(_copy_json(selected), staging, host)
1308
1317
  if not isinstance(compiled, dict) or not isinstance(compiled.get("instruction_text"), str):
1309
1318
  raise CorpusStoreError("catalog compiler returned invalid output")
1310
1319
  files = _snapshot_relative_paths(compiled.get("files"))
@@ -14,6 +14,7 @@ import hashlib
14
14
  import json
15
15
  import os
16
16
  from pathlib import Path
17
+ import sys
17
18
  import threading
18
19
  from typing import Any, Iterator
19
20
 
@@ -30,6 +31,25 @@ class TransactionPendingError(TransactionError):
30
31
  pass
31
32
 
32
33
 
34
+ def reject_symlink_ancestors(path: Path) -> None:
35
+ """Refuse redirected user paths, allowing only macOS's fixed system aliases.
36
+
37
+ /var, /tmp and /etc are root-owned aliases to /private on macOS. They precede
38
+ ordinary temporary HOME/state roots and are not user-controlled redirections.
39
+ The exception names their exact destinations and never applies to the leaf,
40
+ a same-named link elsewhere, or a user-owned link.
41
+ """
42
+ for ancestor in (path, *path.parents):
43
+ if not ancestor.is_symlink():
44
+ continue
45
+ system_alias = (sys.platform == "darwin" and ancestor != path
46
+ and ancestor in {Path("/var"), Path("/tmp"), Path("/etc")}
47
+ and ancestor.lstat().st_uid == 0
48
+ and ancestor.parent / os.readlink(ancestor) == Path("/private") / ancestor.name)
49
+ if not system_alias:
50
+ raise TransactionError(f"refusing symlink transaction target: {ancestor}")
51
+
52
+
33
53
  def _key(state_root: Path) -> str:
34
54
  return str(Path(state_root).expanduser().resolve())
35
55