77import hashlib
88import json
99import os
10+ import re
11+ import shutil
1012import tempfile
1113import time
14+ from collections import defaultdict
1215from collections .abc import Mapping
1316from enum import StrEnum
1417from pathlib import Path
4952 "no_proxy" ,
5053)
5154_MCP_TOOL_APPROVAL_MODE = "approve"
55+ _SKILLS_BLOCK = re .compile (r"<skills_instructions>.*?</skills_instructions>" , re .DOTALL )
56+ _SKILL_ENTRY = re .compile (
57+ r"^- (?P<name>[^:\n]+): .* \((?P<kind>file|environment resource|"
58+ r"orchestrator resource|custom resource): (?P<source>[^)\n]+)\)$" ,
59+ re .MULTILINE ,
60+ )
5261
5362
5463class CueLevel (StrEnum ):
@@ -357,6 +366,11 @@ def classify_visibility(
357366 "mcp_wire_bytes" : telemetry .get ("mcp_wire_bytes" , 0 ),
358367 "mcp_model_visible_bytes" : telemetry .get ("mcp_model_visible_bytes" , 0 ),
359368 "mcp_logical_payload_bytes" : telemetry .get ("mcp_logical_payload_bytes" , 0 ),
369+ "empty_payload_probe_count" : telemetry .get ("empty_payload_probe_count" , 0 ),
370+ "failed_operation_attempt_count" : telemetry .get (
371+ "failed_operation_attempt_count" , 0
372+ ),
373+ "repeated_error_count" : telemetry .get ("repeated_error_count" , 0 ),
360374 }
361375
362376
@@ -455,6 +469,7 @@ def _codex_arguments(
455469 "--ephemeral" ,
456470 "--skip-git-repo-check" ,
457471 "--ignore-user-config" ,
472+ "--ignore-rules" ,
458473 "-C" ,
459474 str (workspace ),
460475 "-s" ,
@@ -484,13 +499,146 @@ def _codex_arguments(
484499 return (* arguments , prompt )
485500
486501
487- def _command_version (workspace : Path ) -> str :
502+ def _prepare_isolated_codex_environment (
503+ root : Path ,
504+ * ,
505+ source_environment : Mapping [str , str ] | None = None ,
506+ ) -> tuple [Mapping [str , str ], dict [str , Any ]]:
507+ """Build a clean Codex HOME/CODEX_HOME and copy authentication only."""
508+
509+ source = os .environ if source_environment is None else source_environment
510+ isolated_home = root / "home"
511+ isolated_codex_home = root / "codex-home"
512+ isolated_home .mkdir (parents = True )
513+ isolated_codex_home .mkdir ()
514+ source_home = Path (source .get ("HOME" , str (Path .home ())))
515+ source_codex_home = Path (source .get ("CODEX_HOME" , source_home / ".codex" ))
516+ source_auth = source_codex_home / "auth.json"
517+ auth_seeded = source_auth .is_file ()
518+ if auth_seeded :
519+ target_auth = isolated_codex_home / "auth.json"
520+ shutil .copyfile (source_auth , target_auth )
521+ target_auth .chmod (0o600 )
522+ environment = dict (
523+ operator_environment (
524+ source = source ,
525+ include = _CODEX_ENVIRONMENT ,
526+ declared = {
527+ "HOME" : str (isolated_home ),
528+ "CODEX_HOME" : str (isolated_codex_home ),
529+ },
530+ )
531+ )
532+ return environment , {
533+ "schema_version" : "1" ,
534+ "home_isolated" : True ,
535+ "codex_home_isolated" : True ,
536+ "user_config_loaded" : False ,
537+ "user_rules_loaded" : False ,
538+ "authentication_seeded" : auth_seeded ,
539+ }
540+
541+
542+ def _normalized_skill_source (
543+ source : str ,
544+ * ,
545+ workspace : Path ,
546+ environment : Mapping [str , str ],
547+ ) -> tuple [str , bool ]:
548+ roots = (
549+ ("$CODEX_HOME" , Path (environment ["CODEX_HOME" ])),
550+ ("$HOME" , Path (environment ["HOME" ])),
551+ ("$WORKSPACE" , workspace ),
552+ )
553+ candidate = Path (source )
554+ if not candidate .is_absolute ():
555+ return source , False
556+ for label , root in roots :
557+ try :
558+ relative = candidate .relative_to (root )
559+ except ValueError :
560+ continue
561+ return str (Path (label ) / relative ), False
562+ return source , True
563+
564+
565+ def _inspect_codex_skill_surface (
566+ workspace : Path ,
567+ environment : Mapping [str , str ],
568+ ) -> dict [str , Any ]:
569+ """Render and record the skills actually visible to the evaluated Codex."""
570+
571+ result = run_operator_command (
572+ "codex" ,
573+ ("debug" , "prompt-input" , "evaluation skill-surface snapshot" ),
574+ cwd = workspace ,
575+ timeout_seconds = 30 ,
576+ stdout_limit_bytes = 4 * 1024 * 1024 ,
577+ stderr_limit_bytes = 1024 * 1024 ,
578+ environment = environment ,
579+ )
580+ if result .status is not ToolCommandStatus .EXITED or result .exit_code != 0 :
581+ raise RuntimeError ("codex skill-surface inspection failed" )
582+ try :
583+ messages = json .loads (result .stdout )
584+ except (UnicodeDecodeError , json .JSONDecodeError ) as error :
585+ raise RuntimeError (
586+ "codex skill-surface inspection returned invalid JSON"
587+ ) from error
588+ blocks = [
589+ match .group (0 )
590+ for message in messages
591+ if isinstance (message , Mapping )
592+ for content in message .get ("content" , [])
593+ if isinstance (content , Mapping ) and isinstance (content .get ("text" ), str )
594+ for match in _SKILLS_BLOCK .finditer (content ["text" ])
595+ ]
596+ if len (blocks ) != 1 :
597+ raise RuntimeError ("codex prompt must expose exactly one skill surface" )
598+ records = []
599+ external_file_sources = []
600+ for match in _SKILL_ENTRY .finditer (blocks [0 ]):
601+ source , external = _normalized_skill_source (
602+ match .group ("source" ),
603+ workspace = workspace ,
604+ environment = environment ,
605+ )
606+ record = {
607+ "name" : match .group ("name" ),
608+ "source_kind" : match .group ("kind" ),
609+ "source" : source ,
610+ }
611+ records .append (record )
612+ if external and match .group ("kind" ) == "file" :
613+ external_file_sources .append (source )
614+ candidate_entries = [
615+ line for line in blocks [0 ].splitlines () if line .startswith ("- " )
616+ ]
617+ if len (records ) != len (candidate_entries ):
618+ raise RuntimeError ("codex skill-surface entries use an unknown format" )
619+ normalized_block = blocks [0 ]
620+ for variable in ("CODEX_HOME" , "HOME" ):
621+ normalized_block = normalized_block .replace (
622+ environment [variable ], f"${ variable } "
623+ )
624+ normalized_block = normalized_block .replace (str (workspace ), "$WORKSPACE" )
625+ return {
626+ "skill_count" : len (records ),
627+ "skills" : records ,
628+ "external_file_sources" : sorted (external_file_sources ),
629+ "model_visible_instructions_sha256" : _sha256_bytes (
630+ normalized_block .encode ("utf-8" )
631+ ),
632+ }
633+
634+
635+ def _command_version (workspace : Path , environment : Mapping [str , str ]) -> str :
488636 result = run_operator_command (
489637 "codex" ,
490638 ("--version" ,),
491639 cwd = workspace ,
492640 timeout_seconds = 30 ,
493- environment = operator_environment ( include = _CODEX_ENVIRONMENT ) ,
641+ environment = environment ,
494642 )
495643 if result .status is not ToolCommandStatus .EXITED or result .exit_code != 0 :
496644 raise RuntimeError ("codex --version failed" )
@@ -508,11 +656,11 @@ def _run_case(
508656 mcp_url : str ,
509657 timeout_seconds : float ,
510658 tool_mode : ToolMode ,
659+ environment : Mapping [str , str ],
511660) -> dict [str , Any ]:
512661 stem = f"{ case .case_id } -r{ repetition :02d} "
513662 transcript_path = output / f"{ stem } .jsonl"
514663 stderr_path = output / f"{ stem } .stderr"
515- environment = operator_environment (include = _CODEX_ENVIRONMENT )
516664 command_start = time .monotonic ()
517665 result = run_operator_command (
518666 "codex" ,
@@ -619,6 +767,52 @@ def _validate_mcp_url(value: str) -> None:
619767def _build_summary (runs : list [dict [str , Any ]]) -> dict [str , Any ]:
620768 """Aggregate per-run observations into the report summary block."""
621769
770+ runs_by_case : defaultdict [str , list [dict [str , Any ]]] = defaultdict (list )
771+ for run in runs :
772+ runs_by_case [run ["case_id" ]].append (run )
773+
774+ def rate (numerator : int , denominator : int ) -> float | None :
775+ return round (numerator / denominator , 6 ) if denominator else None
776+
777+ case_repetition_metrics = []
778+ for case_id , case_runs in sorted (runs_by_case .items ()):
779+ run_count = len (case_runs )
780+ satisfied = sum (
781+ run ["classification" ]["contract_satisfied" ] for run in case_runs
782+ )
783+ command_failures = sum (
784+ run ["command" ]["status" ] != ToolCommandStatus .EXITED
785+ or run ["command" ]["exit_code" ] != 0
786+ for run in case_runs
787+ )
788+ failed_attempts = sum (
789+ run ["classification" ]["failed_operation_attempt_count" ] for run in case_runs
790+ )
791+ repeated_errors = sum (
792+ run ["classification" ]["repeated_error_count" ] for run in case_runs
793+ )
794+ runs_with_empty_probe = sum (
795+ run ["classification" ]["empty_payload_probe_count" ] > 0 for run in case_runs
796+ )
797+ case_repetition_metrics .append (
798+ {
799+ "case_id" : case_id ,
800+ "run_count" : run_count ,
801+ "command_failure_count" : command_failures ,
802+ "contract_satisfied_count" : satisfied ,
803+ "contract_satisfaction_rate" : rate (satisfied , run_count ),
804+ "empty_payload_probe_count" : sum (
805+ run ["classification" ]["empty_payload_probe_count" ]
806+ for run in case_runs
807+ ),
808+ "runs_with_empty_payload_probe" : runs_with_empty_probe ,
809+ "empty_payload_probe_run_rate" : rate (runs_with_empty_probe , run_count ),
810+ "failed_operation_attempt_count" : failed_attempts ,
811+ "repeated_error_count" : repeated_errors ,
812+ "repeated_error_rate" : rate (repeated_errors , failed_attempts ),
813+ }
814+ )
815+
622816 return {
623817 "run_count" : len (runs ),
624818 "command_failure_count" : sum (
@@ -671,6 +865,18 @@ def _build_summary(runs: list[dict[str, Any]]) -> dict[str, Any]:
671865 sum (run ["command" ]["elapsed_seconds" ] for run in runs ), 6
672866 ),
673867 },
868+ "recovery_totals" : {
869+ "empty_payload_probe_count" : sum (
870+ run ["classification" ]["empty_payload_probe_count" ] for run in runs
871+ ),
872+ "failed_operation_attempt_count" : sum (
873+ run ["classification" ]["failed_operation_attempt_count" ] for run in runs
874+ ),
875+ "repeated_error_count" : sum (
876+ run ["classification" ]["repeated_error_count" ] for run in runs
877+ ),
878+ },
879+ "case_repetition_metrics" : case_repetition_metrics ,
674880 }
675881
676882
@@ -696,9 +902,18 @@ def main() -> None:
696902 raise SystemExit (f"output directory already exists: { output } " )
697903 surface = asyncio .run (inspect_surface (args .mcp_url , args .timeout_seconds ))
698904 output .mkdir (parents = True )
699- with tempfile .TemporaryDirectory (prefix = "jacobian-codex-visibility-" ) as raw :
905+ with (
906+ tempfile .TemporaryDirectory (prefix = "jacobian-codex-visibility-" ) as raw ,
907+ tempfile .TemporaryDirectory (prefix = "jacobian-codex-isolation-" ) as isolated ,
908+ ):
700909 workspace = Path (raw )
701- codex_version = _command_version (workspace )
910+ environment , isolation = _prepare_isolated_codex_environment (Path (isolated ))
911+ skill_surface = _inspect_codex_skill_surface (workspace , environment )
912+ if skill_surface ["external_file_sources" ]:
913+ raise RuntimeError (
914+ "isolated Codex prompt exposed external file-backed skills"
915+ )
916+ codex_version = _command_version (workspace , environment )
702917 runs = [
703918 _run_case (
704919 case = case ,
@@ -710,6 +925,7 @@ def main() -> None:
710925 mcp_url = args .mcp_url ,
711926 timeout_seconds = args .timeout_seconds ,
712927 tool_mode = args .tool_mode ,
928+ environment = environment ,
713929 )
714930 for case in selected_cases
715931 for repetition in range (1 , args .repetitions + 1 )
@@ -732,6 +948,8 @@ def main() -> None:
732948 "telemetry_parser_sha256" : _sha256_bytes (
733949 (_ROOT / "src/jacobian/eval/telemetry.py" ).read_bytes ()
734950 ),
951+ "isolation" : isolation ,
952+ "skill_surface" : skill_surface ,
735953 },
736954 "codex_version" : codex_version ,
737955 "model" : args .model ,
0 commit comments