From f6fb3d5f3720ab9da92832ab91b79ceeb044fe78 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:05:55 -0700 Subject: [PATCH] Contain workspace clone targets --- .../tests/test_workspace_clone.py | 38 +++++++++++++++++++ .../base_projects/workspace_clone_command.py | 20 +++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/cli/python/base_projects/tests/test_workspace_clone.py b/cli/python/base_projects/tests/test_workspace_clone.py index 541e6b19..459dae12 100644 --- a/cli/python/base_projects/tests/test_workspace_clone.py +++ b/cli/python/base_projects/tests/test_workspace_clone.py @@ -242,6 +242,44 @@ def test_workspace_clone_uses_configured_manifest_when_flag_is_omitted(self) -> [f"repo clone codeforester/api --path {(workspace / 'api').resolve()} --dry-run"], ) + def test_workspace_clone_rejects_repository_target_outside_workspace(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + workspace = root / "workspace" + outside = root / "outside" + base_home = root / "base" + state_file = root / "basectl-calls" + manifest_path = root / "workspace.yaml" + home.mkdir() + workspace.mkdir() + outside.mkdir() + base_home.mkdir() + (workspace / "api").symlink_to(outside, target_is_directory=True) + write_fake_basectl(base_home, state_file) + write_workspace_manifest( + manifest_path, + """ +schema_version: 1 +workspace: + name: demo-suite +repos: + - name: api + url: https://github.com/codeforester/api.git +""", + ) + + status, stdout, stderr = invoke_engine( + ["clone", "--workspace", str(workspace), "--manifest", str(manifest_path)], + base_home, + home, + ) + + self.assertEqual(status, 1) + self.assertIn("resolves outside workspace root", stderr) + self.assertIn("Workspace clone completed with 1 error(s).", stdout) + self.assertFalse(state_file.exists()) + def test_workspace_clone_requires_manifest(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) diff --git a/cli/python/base_projects/workspace_clone_command.py b/cli/python/base_projects/workspace_clone_command.py index c0e450ac..8db549b2 100644 --- a/cli/python/base_projects/workspace_clone_command.py +++ b/cli/python/base_projects/workspace_clone_command.py @@ -37,6 +37,8 @@ def workspace_clone_command(ctx: base_cli.Context, options: WorkspaceCloneOption ctx.log.error(str(exc)) return base_cli.ExitCode.FAILURE + workspace_root = workspace_root.resolve(strict=False) + if ctx.base_home is None: ctx.log.error("BASE_HOME is required to clone workspace repositories.") return base_cli.ExitCode.FAILURE @@ -47,7 +49,12 @@ def workspace_clone_command(ctx: base_cli.Context, options: WorkspaceCloneOption errors = 0 for repo in manifest.repos: - target = (workspace_root / repo.name).resolve() + try: + target = resolve_workspace_clone_target(workspace_root, repo.name) + except ProjectUsageError as exc: + ctx.log.error(str(exc)) + errors += 1 + continue required_label = "required" if repo.required else "optional" if should_skip_optional_clone(repo, target, options.include_optional): print_optional_clone_skip(repo, target) @@ -66,6 +73,17 @@ def workspace_clone_command(ctx: base_cli.Context, options: WorkspaceCloneOption return base_cli.ExitCode.SUCCESS +def resolve_workspace_clone_target(workspace_root: Path, repo_name: str) -> Path: + target = (workspace_root / repo_name).resolve(strict=False) + try: + target.relative_to(workspace_root) + except ValueError as exc: + raise ProjectUsageError( + f"Repository '{repo_name}' resolves outside workspace root '{workspace_root}'." + ) from exc + return target + + def require_workspace_clone_manifest(ctx: base_cli.Context, workspace_manifest: str | None) -> WorkspaceManifest: effective_manifest = effective_workspace_manifest(ctx, workspace_manifest) if effective_manifest is None: