Validate declarative flow definition paths (#6311)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled

This commit is contained in:
Vinicius Brasil
2026-06-23 19:28:35 -07:00
committed by GitHub
parent 1862ff8f6c
commit a046e6a50b
3 changed files with 207 additions and 11 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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)]