From 33d08faf70a3bc1811af8494da7cb0babc029b5a Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 27 May 2026 15:01:16 +0200 Subject: [PATCH] feat: allow relative target paths for git mounts - Remove absolute path requirement from target_path validation - Resolve relative paths against working_directory at instance startup - Fall back to /home/user if no working_directory is configured - Update frontend to allow relative target paths - Update spec to document relative path support - Update tests to allow relative paths and test path traversal rejection --- apps/api/src/api/config_profiles.py | 2 -- apps/api/src/api/tool_instances.py | 12 ++++++++++-- .../tests/integration/test_config_profiles_api.py | 6 +++--- apps/web/src/components/git-mount-editor.tsx | 3 +-- .../specs/config-profile-git-mounts/spec.md | 2 +- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 52279f3..d36c71b 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -82,8 +82,6 @@ class GitMountItem(BaseModel): @field_validator("target_path") @classmethod def validate_target_path(cls, v: str) -> str: - if not v.startswith("/"): - raise ValueError("target_path must be absolute (start with /)") if ".." in v: raise ValueError("target_path cannot contain path traversal (..)") return v diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index d1cd5a8..5ba9a36 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -61,6 +61,7 @@ async def _resolve_git_mounts( session: AsyncSession, resolved: ResolvedProfile, instance_dir: str | None = None, + working_directory: str | None = None, ) -> list[dict]: """Convert git mounts from resolved profile to Docker volume mounts. @@ -74,7 +75,7 @@ async def _resolve_git_mounts( # Process all git mounts concurrently tasks = [] for git_mount in resolved.git_mounts: - tasks.append(_resolve_single_git_mount(session, git_mount, instance_dir)) + tasks.append(_resolve_single_git_mount(session, git_mount, instance_dir, working_directory)) results = await asyncio.gather(*tasks, return_exceptions=True) @@ -93,6 +94,7 @@ async def _resolve_single_git_mount( session: AsyncSession, git_mount: dict, instance_dir: str | None = None, + working_directory: str | None = None, ) -> list[dict]: """Resolve a single git mount to volume mount entries. @@ -108,6 +110,12 @@ async def _resolve_single_git_mount( logger.warning("Invalid git mount skipped: missing remote_url or target_path") return [] + # Resolve relative target paths against working directory + if target_path and not target_path.startswith("/"): + base = working_directory or "/home/user" + target_path = os.path.join(base, target_path) + logger.info("Resolved relative target path to %s", target_path) + if not instance_dir: logger.warning("Git mount skipped: no instance_dir provided for cloning") return [] @@ -918,7 +926,7 @@ async def start_instance( # Profile mounts are added to extra volumes extra_volumes.extend(profile_mounts) # Git repository mounts are resolved and added - git_mount_volumes = await _resolve_git_mounts(session, resolved, instance_dir) + git_mount_volumes = await _resolve_git_mounts(session, resolved, instance_dir, working_directory) extra_volumes.extend(git_mount_volumes) # Profile runtime hints override tool config values if profile_hints.get("start_command"): diff --git a/apps/api/tests/integration/test_config_profiles_api.py b/apps/api/tests/integration/test_config_profiles_api.py index 1ecdb37..b1a6011 100644 --- a/apps/api/tests/integration/test_config_profiles_api.py +++ b/apps/api/tests/integration/test_config_profiles_api.py @@ -402,8 +402,8 @@ class TestConfigProfilesAPI: ) assert response.status_code == 422 - def test_create_config_profile_invalid_git_mount_target_path(self, authenticated_client: TestClient, test_project_and_repo) -> None: - """Test that invalid git mount target paths are rejected.""" + def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None: + """Test that git mount target paths with traversal are rejected.""" _project_id, repo_id = test_project_and_repo response = authenticated_client.post( @@ -416,7 +416,7 @@ class TestConfigProfilesAPI: { "remote_url": "https://github.com/user/repo.git", "source_path": ".", - "target_path": "relative/path", + "target_path": "../../../etc/passwd", } ], }, diff --git a/apps/web/src/components/git-mount-editor.tsx b/apps/web/src/components/git-mount-editor.tsx index e9c52ec..a3cdc0b 100644 --- a/apps/web/src/components/git-mount-editor.tsx +++ b/apps/web/src/components/git-mount-editor.tsx @@ -36,7 +36,6 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => { const validatePath = (path: string, isTarget: boolean): string | null => { if (!path) return isTarget ? "Target path is required" : null; if (path.includes("..")) return "Path cannot contain .."; - if (isTarget && !path.startsWith("/")) return "Target path must be absolute"; if (!isTarget && path.startsWith("/")) return "Source path must be relative"; return null; }; @@ -200,7 +199,7 @@ const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNe placeholder="e.g., /app/config" className={errors.target_path ? "error" : ""} /> - Absolute path inside container + Absolute or relative path inside container (relative resolved against working dir) {errors.target_path && {errors.target_path}} diff --git a/openspec/changes/config-profile-git-mounts/specs/config-profile-git-mounts/spec.md b/openspec/changes/config-profile-git-mounts/specs/config-profile-git-mounts/spec.md index 16fe1c5..71a67f3 100644 --- a/openspec/changes/config-profile-git-mounts/specs/config-profile-git-mounts/spec.md +++ b/openspec/changes/config-profile-git-mounts/specs/config-profile-git-mounts/spec.md @@ -16,7 +16,7 @@ The system SHALL allow config profiles to include git repository mounts that bin - **THEN** the system validates that: - `remote_url` is a valid git URL (starts with https://, git@, or ssh://) - `source_path` is a relative path (no leading `/`) - - `target_path` is an absolute path (starts with `/`) + - `target_path` can be absolute (starts with `/`) or relative (resolved against working directory, defaulting to `/home/user`) - `target_path` does not contain path traversal sequences (`..`) - No database lookup or repository existence check is performed (validation is deferred to clone time)