Load a run state from disk. Validates ``run_id`` against ``_RUN_ID_PATTERN`` *before* building the lookup path. Without this guard, a caller passing a value like ``../escape`` (e.g. via ``specify workflow resume`` CLI argument) would interpolate path-traversal segmen
(cls, run_id: str, project_root: Path)
| 503 | |
| 504 | @classmethod |
| 505 | def load(cls, run_id: str, project_root: Path) -> RunState: |
| 506 | """Load a run state from disk. |
| 507 | |
| 508 | Validates ``run_id`` against ``_RUN_ID_PATTERN`` *before* building |
| 509 | the lookup path. Without this guard, a caller passing a value like |
| 510 | ``../escape`` (e.g. via ``specify workflow resume`` CLI argument) |
| 511 | would interpolate path-traversal segments into |
| 512 | ``runs_dir`` below, letting ``state_path.exists()`` probe arbitrary |
| 513 | paths and ``json.load`` read attacker-planted JSON from outside |
| 514 | the project's ``runs/`` directory. ``__init__`` already runs this |
| 515 | check on the stored ``state_data["run_id"]``, but that fires |
| 516 | *after* the file lookup — too late to prevent the disclosure. |
| 517 | Mirrors the precedent in ``agents._ensure_within_directory``. |
| 518 | """ |
| 519 | cls._validate_run_id(run_id) |
| 520 | runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id |
| 521 | state_path = runs_dir / "state.json" |
| 522 | if not state_path.exists(): |
| 523 | msg = f"Run state not found: {state_path}" |
| 524 | raise FileNotFoundError(msg) |
| 525 | |
| 526 | with open(state_path, encoding="utf-8") as f: |
| 527 | state_data = json.load(f) |
| 528 | |
| 529 | state = cls( |
| 530 | run_id=state_data["run_id"], |
| 531 | workflow_id=state_data["workflow_id"], |
| 532 | project_root=project_root, |
| 533 | ) |
| 534 | state.status = RunStatus(state_data["status"]) |
| 535 | state.current_step_index = state_data.get("current_step_index", 0) |
| 536 | state.current_step_id = state_data.get("current_step_id") |
| 537 | state.step_results = state_data.get("step_results", {}) |
| 538 | state.created_at = state_data.get("created_at", "") |
| 539 | state.updated_at = state_data.get("updated_at", "") |
| 540 | |
| 541 | inputs_path = runs_dir / "inputs.json" |
| 542 | if inputs_path.exists(): |
| 543 | with open(inputs_path, encoding="utf-8") as f: |
| 544 | inputs_data = json.load(f) |
| 545 | state.inputs = inputs_data.get("inputs", {}) |
| 546 | |
| 547 | return state |
| 548 | |
| 549 | def append_log(self, entry: dict[str, Any]) -> None: |
| 550 | """Append a log entry to the run log. |