diff --git a/agent_assembly/cli/adapter_validator.py b/agent_assembly/cli/adapter_validator.py index 3ca11747..0611a464 100644 --- a/agent_assembly/cli/adapter_validator.py +++ b/agent_assembly/cli/adapter_validator.py @@ -173,11 +173,27 @@ def _check_unregister_hooks_idempotent( def _check_entry_point_metadata(cls: type, path_or_module: str) -> AdapterValidationResult: """Check entry point metadata in pyproject.toml if present at the given path.""" - search_path = Path(path_or_module) + # Canonicalize the CLI-supplied path before any file access. resolve() + # collapses ".." segments and symlinks, breaking path-traversal taint flows + # (pythonsecurity:S8707): the validator only inspects the pyproject.toml that + # sits directly inside the resolved adapter directory, never one reached by + # escaping it. + search_path = Path(path_or_module).resolve() if search_path.is_file(): search_path = search_path.parent - pyproject_path = search_path / "pyproject.toml" + pyproject_path = (search_path / "pyproject.toml").resolve() + + # Containment guard: the resolved manifest must live directly under the + # resolved adapter directory. A symlinked pyproject.toml pointing outside the + # adapter root is rejected rather than read. + if pyproject_path.parent != search_path: + return AdapterValidationResult( + check_name="entry_point_metadata", + passed=False, + message="pyproject.toml resolves outside the adapter directory; refusing to read it.", + ) + if not pyproject_path.is_file(): return AdapterValidationResult( check_name="entry_point_metadata", diff --git a/test/unit/cli/test_adapter_validator.py b/test/unit/cli/test_adapter_validator.py index ecb596f5..8a22bc17 100644 --- a/test/unit/cli/test_adapter_validator.py +++ b/test/unit/cli/test_adapter_validator.py @@ -184,6 +184,45 @@ def test_no_pyproject_skips(self, valid_adapter_cls: type, tmp_path: object) -> assert result.passed is True assert "skipping" in result.message.lower() + def test_traversal_path_resolved_to_valid_dir(self, valid_adapter_cls: type, tmp_path: object) -> None: + """A path containing '..' that resolves to a real adapter dir still validates.""" + import pathlib + + assert isinstance(tmp_path, pathlib.Path) + adapter_dir = tmp_path / "adapter" + adapter_dir.mkdir() + pyproject = adapter_dir / "pyproject.toml" + qualname = f"{valid_adapter_cls.__module__}:{valid_adapter_cls.__qualname__}" + pyproject.write_text(f'[project.entry-points."agent_assembly.adapters"]\n' f'test_framework = "{qualname}"\n') + # "/adapter/../adapter" canonicalizes back to "/adapter". + traversal = str(adapter_dir / ".." / "adapter") + result = _check_entry_point_metadata(valid_adapter_cls, traversal) + assert result.passed is True + + def test_symlinked_pyproject_outside_dir_rejected(self, valid_adapter_cls: type, tmp_path: object) -> None: + """A pyproject.toml symlink escaping the adapter dir is refused, not read.""" + import pathlib + + assert isinstance(tmp_path, pathlib.Path) + outside = tmp_path / "outside" + outside.mkdir() + secret = outside / "pyproject.toml" + secret.write_text("[project]\nname = 'secret'\n") + + adapter_dir = tmp_path / "adapter" + adapter_dir.mkdir() + link = adapter_dir / "pyproject.toml" + try: + link.symlink_to(secret) + except (OSError, NotImplementedError): + import pytest + + pytest.skip("platform does not support symlinks") + + result = _check_entry_point_metadata(valid_adapter_cls, str(adapter_dir)) + assert result.passed is False + assert "outside the adapter directory" in result.message + class TestValidateAdapter: """Tests for validate_adapter orchestrator."""