diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index b70492777..ea289d00b 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from pathlib import Path +from pathlib import Path, PureWindowsPath import subprocess from typing import Any @@ -12,7 +12,7 @@ from crewai_cli.utils import build_env_with_all_tool_credentials def run_declarative_flow_in_project_env( - definition: str, inputs: str | None = None + definition: str | Path, inputs: str | None = None ) -> None: """Run a declarative flow inside the project's Python environment.""" if is_declarative_flow_project_env() or not _has_project_file(): @@ -25,7 +25,7 @@ def run_declarative_flow_in_project_env( _execute_declarative_flow_command(["uv", "run", "crewai", "run"]) -def plot_declarative_flow_in_project_env(definition: str) -> None: +def plot_declarative_flow_in_project_env(definition: str | Path) -> None: """Plot a declarative flow inside the project's Python environment.""" if is_declarative_flow_project_env() or not _has_project_file(): plot_declarative_flow(definition=definition) @@ -34,7 +34,7 @@ def plot_declarative_flow_in_project_env(definition: str) -> None: _execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"]) -def run_declarative_flow(definition: str, inputs: str | None = None) -> None: +def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> None: """Run a declarative flow from a definition path.""" parsed_inputs = _parse_inputs(inputs) @@ -50,7 +50,7 @@ def run_declarative_flow(definition: str, inputs: str | None = None) -> None: click.echo(_format_result(result)) -def plot_declarative_flow(definition: str) -> None: +def plot_declarative_flow(definition: str | Path) -> None: """Plot a declarative flow from a definition path.""" try: flow = load_declarative_flow(definition) @@ -62,7 +62,7 @@ def plot_declarative_flow(definition: str) -> None: raise SystemExit(1) from exc -def load_declarative_flow(definition: str) -> Any: +def load_declarative_flow(definition: str | Path) -> Any: """Load a declarative Flow instance from a definition path.""" try: from crewai.flow.flow import Flow @@ -102,7 +102,8 @@ def load_declarative_flow(definition: str) -> Any: def configured_project_declarative_flow( pyproject_data: dict[str, Any] | None = None, -) -> str | None: + project_root: Path | None = None, +) -> Path | None: """Return the configured declarative flow source for flow projects.""" if pyproject_data is None: try: @@ -118,7 +119,66 @@ def configured_project_declarative_flow( definition = crewai_config.get("definition") if not isinstance(definition, str): return None - return definition.strip() or None + definition = definition.strip() + if not definition: + return None + + return _resolve_project_definition_path( + definition=definition, + project_root=project_root or Path.cwd(), + ) + + +def _resolve_project_definition_path(definition: str, project_root: Path) -> Path: + definition_path = Path(definition) + windows_definition_path = PureWindowsPath(definition) + + if definition.startswith("~"): + raise click.UsageError( + "[tool.crewai] definition must be a project-local path; " + f"got {definition!r}." + ) + + if definition_path.is_absolute() or windows_definition_path.is_absolute(): + raise click.UsageError( + "[tool.crewai] definition must be relative to the project root; " + f"got {definition!r}." + ) + + try: + root = project_root.resolve(strict=True) + except OSError as exc: + raise click.UsageError( + f"Invalid project root for [tool.crewai] definition: {exc}" + ) from exc + + candidate = root / definition_path + try: + resolved_candidate = candidate.resolve(strict=False) + except OSError as exc: + raise click.UsageError( + f"Invalid [tool.crewai] definition path {definition!r}: {exc}" + ) from exc + + if not resolved_candidate.is_relative_to(root): + raise click.UsageError( + "[tool.crewai] definition must resolve inside the project root; " + f"got {definition!r}." + ) + + if not resolved_candidate.exists(): + raise click.UsageError( + "[tool.crewai] definition must point to an existing file; " + f"got {definition!r}." + ) + + if not resolved_candidate.is_file(): + raise click.UsageError( + "[tool.crewai] definition must point to a regular file; " + f"got {definition!r}." + ) + + return resolved_candidate def _execute_declarative_flow_command(command: list[str]) -> None: diff --git a/lib/cli/tests/test_flow_commands.py b/lib/cli/tests/test_flow_commands.py index 00e39b6db..5743275e7 100644 --- a/lib/cli/tests/test_flow_commands.py +++ b/lib/cli/tests/test_flow_commands.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path import subprocess +import click import pytest from click.testing import CliRunner @@ -107,6 +108,8 @@ def test_configured_project_declarative_flow( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.chdir(tmp_path) + definition_path = tmp_path / "flow.yaml" + definition_path.write_text(FLOW_YAML, encoding="utf-8") (tmp_path / "pyproject.toml").write_text( '[tool.crewai]\ntype = "flow"\ndefinition = " flow.yaml "\n', encoding="utf-8", @@ -114,4 +117,132 @@ def test_configured_project_declarative_flow( from crewai_cli.run_declarative_flow import configured_project_declarative_flow - assert configured_project_declarative_flow() == "flow.yaml" + assert configured_project_declarative_flow() == definition_path.resolve() + + +@pytest.mark.parametrize( + ("definition", "expected_error"), + [ + ("C:/tmp/flow.yaml", "must be relative to the project root"), + ("~/flow.yaml", "must be a project-local path"), + ("../flow.yaml", "must resolve inside the project root"), + ], +) +def test_configured_project_declarative_flow_rejects_unsafe_paths( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + definition: str, + expected_error: str, +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition}"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert expected_error in exc_info.value.message + + +def test_configured_project_declarative_flow_allows_normalized_project_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + definition_path = tmp_path / "flow.yaml" + definition_path.write_text(FLOW_YAML, encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "flow"\ndefinition = "src/../flow.yaml"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + assert configured_project_declarative_flow() == definition_path.resolve() + + +def test_configured_project_declarative_flow_rejects_absolute_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + definition = tmp_path / "flow.yaml" + (tmp_path / "pyproject.toml").write_text( + f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition.as_posix()}"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert "must be relative to the project root" in exc_info.value.message + + +def test_configured_project_declarative_flow_rejects_symlink_escape( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + outside_definition = tmp_path.parent / "outside-flow.yaml" + outside_definition.write_text(FLOW_YAML, encoding="utf-8") + link = tmp_path / "flow.yaml" + try: + link.symlink_to(outside_definition) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert "must resolve inside the project root" in exc_info.value.message + + +def test_configured_project_declarative_flow_rejects_missing_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "flow"\ndefinition = "missing-flow.yaml"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert "must point to an existing file" in exc_info.value.message + + +def test_configured_project_declarative_flow_rejects_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "flow.yaml").mkdir() + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert "must point to a regular file" in exc_info.value.message diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index fd9daf167..6db073919 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -705,9 +705,14 @@ def test_run_crew_rejects_filename_for_flow_project(monkeypatch): assert "--filename can only be used when running crews" in exc_info.value.message -def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys): +def test_run_crew_runs_configured_declarative_flow_project( + monkeypatch, tmp_path: Path, capsys +): calls = [] + monkeypatch.chdir(tmp_path) + definition_path = tmp_path / "flow.yaml" + definition_path.write_text("schema: crewai.flow/v1\n", encoding="utf-8") monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) monkeypatch.setattr( run_crew_module, @@ -734,4 +739,4 @@ def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys): run_crew_module.run_crew() assert capsys.readouterr().out == "" - assert calls == [("flow.yaml", None)] + assert calls == [(definition_path.resolve(), None)]