From 2387eeb74da2ff586ea1b136c6b9d662e68e7ccf Mon Sep 17 00:00:00 2001 From: duang777 Date: Sat, 24 Oct 2026 09:15:52 +0800 Subject: [PATCH] fix(bootstrap): validate goal ids before path construction Reject invalid goal IDs before deriving state paths or writing bootstrap state, so a failed request cannot leave a partial registry or state file outside the project. Signed-off-by: duang777 --- loopx/bootstrap.py | 3 +- .../test_goal_scoped_log_paths.py | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index e4f3871300..aac03ac5dd 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -56,6 +56,7 @@ rel_or_abs, require_single_goal_state_route, resolve_runtime_root, + validate_goal_id_path_segment, ) from .control_plane.goals.active_state_metadata import markdown_blockquote, markdown_frontmatter_string from .registry_writability import probe_registry_write_path @@ -327,10 +328,10 @@ def bootstrap_project( creation_operation_id: str | None = None, ) -> dict[str, Any]: project = project.expanduser().resolve() + goal_id = validate_goal_id_path_segment(goal_id or default_goal_id(project)) registry_path = registry_path.expanduser() if not registry_path.is_absolute(): registry_path = project / registry_path - goal_id = goal_id or default_goal_id(project) explicit_state_file = state_file is not None state_file = state_file or registered_goal_state_file( project, goal_id, read_json_if_exists(registry_path) diff --git a/tests/control_plane/test_goal_scoped_log_paths.py b/tests/control_plane/test_goal_scoped_log_paths.py index 493db5cd2a..c1c7d331a5 100644 --- a/tests/control_plane/test_goal_scoped_log_paths.py +++ b/tests/control_plane/test_goal_scoped_log_paths.py @@ -103,6 +103,46 @@ def test_history_evidence_cli_rejects_unsafe_goal_without_writing_outside_root( assert not (runtime_root / "escape").exists() +def test_bootstrap_rejects_unsafe_goal_id_before_any_write( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + import json + + from loopx.cli import main + + project = tmp_path / "project" + registry_path = project / ".loopx" / "registry.json" + runtime_root = tmp_path / "runtime" + escaped_state = tmp_path / "escaped" / "ACTIVE_GOAL_STATE.md" + + exit_code = main( + [ + "--registry", + str(registry_path), + "--runtime-root", + str(runtime_root), + "--format", + "json", + "bootstrap", + "--project", + str(project), + "--goal-id", + "../../../escaped", + "--objective", + "Synthetic path boundary check", + "--no-global-sync", + ] + ) + + assert exit_code == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + assert payload["error"] == "goal id must be a single path segment" + assert not registry_path.exists() + assert not escaped_state.exists() + + def test_cli_rollout_event_does_not_create_path_outside_goals_root(tmp_path: Path) -> None: from loopx.cli_rollout import append_cli_rollout_event