@@ -82,7 +82,7 @@ def __call__(
8282# actually builds them) and re-exported as ``cli.ROLES``, which `cmd_validate`
8383# and the test suite resolve.
8484ROLES = ("dev" , "review" , "triage" )
85- SWEEP_OPTIONS_VERSION = 1
85+ SWEEP_OPTIONS_VERSION = 2
8686_MAX_SWEEP_OPTIONS_BYTES = 64 * 1024
8787
8888
@@ -843,9 +843,15 @@ class SweepResumeOptions:
843843 values : dict [str , Any ]
844844 only_ids : tuple [str , ...] | None
845845 min_severity : str | None
846+ digest : str | None = None
846847
847848
848- def load_sweep_resume_options (run_dir : Path , * , required : bool = False ) -> SweepResumeOptions :
849+ def load_sweep_resume_options (
850+ run_dir : Path ,
851+ * ,
852+ required : bool = False ,
853+ expected_digest : str | None = None ,
854+ ) -> SweepResumeOptions :
849855 """Load bounded, non-redirected sweep.json bytes and validate selectors."""
850856
851857 def corrupt (message : str , * , cause : BaseException | None = None ) -> SweepResumeOptions :
@@ -893,6 +899,9 @@ def corrupt(message: str, *, cause: BaseException | None = None) -> SweepResumeO
893899 data = b"" .join (chunks )
894900 if len (data ) > _MAX_SWEEP_OPTIONS_BYTES :
895901 raise SweepOptionsError (f"sweep.json exceeds { _MAX_SWEEP_OPTIONS_BYTES } bytes" )
902+ digest = hashlib .sha256 (data ).hexdigest ()
903+ if expected_digest is not None and digest != expected_digest :
904+ raise SweepOptionsError ("sweep.json no longer matches the options bound at launch" )
896905 try :
897906 text = data .decode ("utf-8" )
898907 except UnicodeDecodeError as exc :
@@ -943,24 +952,33 @@ def corrupt(message: str, *, cause: BaseException | None = None) -> SweepResumeO
943952
944953 if only_ids is not None and min_severity is not None :
945954 return corrupt ("sweep.json cannot contain both 'only' and 'min_severity'" )
946- # Missing keys are the pre-selector format and remain unrestricted. Explicit
947- # nulls are the current unrestricted format; both intentionally converge.
948- if not only_present :
955+ # Version-zero callers predate selectors and must never inherit selector-
956+ # shaped keys from a stray or replaced options file. Current callers pass
957+ # ``required=True`` and bind these exact bytes through RunState.
958+ if not required or not only_present :
949959 only_ids = None
950- if not min_present :
960+ if not required or not min_present :
951961 min_severity = None
952- return SweepResumeOptions (opts , only_ids , min_severity )
962+ return SweepResumeOptions (opts , only_ids , min_severity , digest )
953963
954964
955965def validate_sweep_options_version (version : int ) -> None :
956966 """Refuse state whose sweep options semantics this binary cannot interpret."""
957- if version < 0 or version > SWEEP_OPTIONS_VERSION :
967+ if version not in ( 0 , SWEEP_OPTIONS_VERSION ) :
958968 raise SweepOptionsError (
959- f"unsupported sweep options version { version } ; this binary supports "
960- f"0 through { SWEEP_OPTIONS_VERSION } "
969+ f"unsupported sweep options version { version } ; this binary supports legacy 0 "
970+ f"or current { SWEEP_OPTIONS_VERSION } "
961971 )
962972
963973
974+ def validate_sweep_options_binding (
975+ version : int , expected_digest : str , options : SweepResumeOptions
976+ ) -> None :
977+ """Bind current sweep options to the exact bytes published at launch."""
978+ if version == SWEEP_OPTIONS_VERSION and options .digest != expected_digest :
979+ raise SweepOptionsError ("sweep.json no longer matches the options bound at launch" )
980+
981+
964982def _claim_run_dir (run_dir : Path ) -> os .stat_result :
965983 """Take exclusive ownership of a fresh run directory, refusing an id that
966984 already names a run.
@@ -1313,6 +1331,7 @@ def compose_sweep(
13131331 "trigger" : trigger ,
13141332 }
13151333 options_text = json .dumps (options , indent = 2 )
1334+ options_digest = hashlib .sha256 (options_text .encode ("utf-8" )).hexdigest ()
13161335 if len (options_text .encode ("utf-8" )) > _MAX_SWEEP_OPTIONS_BYTES :
13171336 raise SweepOptionsError (f"sweep options exceed { _MAX_SWEEP_OPTIONS_BYTES } bytes" )
13181337
@@ -1334,6 +1353,7 @@ def compose_sweep(
13341353 policy_snapshot = policy .to_dict (),
13351354 run_type = "sweep" ,
13361355 sweep_options_version = SWEEP_OPTIONS_VERSION ,
1356+ sweep_options_digest = options_digest ,
13371357 trusted_config_digest = trusted_config_digest ,
13381358 )
13391359 # Persist the sweep options atomically (tmp + os.replace), the way save_state
@@ -1421,12 +1441,25 @@ def compose_resume(
14211441 if sweep_options is not None
14221442 else (
14231443 load_sweep_resume_options (
1424- run_dir , required = state .sweep_options_version >= SWEEP_OPTIONS_VERSION
1444+ run_dir ,
1445+ required = state .sweep_options_version >= SWEEP_OPTIONS_VERSION ,
1446+ expected_digest = (
1447+ state .sweep_options_digest
1448+ if state .sweep_options_version == SWEEP_OPTIONS_VERSION
1449+ else None
1450+ ),
14251451 )
14261452 if state .run_type == "sweep"
14271453 else None
14281454 )
14291455 )
1456+ if state .run_type == "sweep" :
1457+ assert resolved_sweep_options is not None
1458+ validate_sweep_options_binding (
1459+ state .sweep_options_version ,
1460+ state .sweep_options_digest ,
1461+ resolved_sweep_options ,
1462+ )
14301463 # drop any stale agent session so the run spins up a fresh one (a stopped or
14311464 # interrupted run can leave a lingering bmad-loop-<id> session behind).
14321465 runs .kill_session (run_dir .name )
0 commit comments