Skip to content

Commit a046e6a

Browse files
authored
Validate declarative flow definition paths (#6311)
1 parent 1862ff8 commit a046e6a

3 files changed

Lines changed: 207 additions & 11 deletions

File tree

lib/cli/src/crewai_cli/run_declarative_flow.py

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import json
4-
from pathlib import Path
4+
from pathlib import Path, PureWindowsPath
55
import subprocess
66
from typing import Any
77

@@ -12,7 +12,7 @@
1212

1313

1414
def run_declarative_flow_in_project_env(
15-
definition: str, inputs: str | None = None
15+
definition: str | Path, inputs: str | None = None
1616
) -> None:
1717
"""Run a declarative flow inside the project's Python environment."""
1818
if is_declarative_flow_project_env() or not _has_project_file():
@@ -25,7 +25,7 @@ def run_declarative_flow_in_project_env(
2525
_execute_declarative_flow_command(["uv", "run", "crewai", "run"])
2626

2727

28-
def plot_declarative_flow_in_project_env(definition: str) -> None:
28+
def plot_declarative_flow_in_project_env(definition: str | Path) -> None:
2929
"""Plot a declarative flow inside the project's Python environment."""
3030
if is_declarative_flow_project_env() or not _has_project_file():
3131
plot_declarative_flow(definition=definition)
@@ -34,7 +34,7 @@ def plot_declarative_flow_in_project_env(definition: str) -> None:
3434
_execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"])
3535

3636

37-
def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
37+
def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> None:
3838
"""Run a declarative flow from a definition path."""
3939
parsed_inputs = _parse_inputs(inputs)
4040

@@ -50,7 +50,7 @@ def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
5050
click.echo(_format_result(result))
5151

5252

53-
def plot_declarative_flow(definition: str) -> None:
53+
def plot_declarative_flow(definition: str | Path) -> None:
5454
"""Plot a declarative flow from a definition path."""
5555
try:
5656
flow = load_declarative_flow(definition)
@@ -62,7 +62,7 @@ def plot_declarative_flow(definition: str) -> None:
6262
raise SystemExit(1) from exc
6363

6464

65-
def load_declarative_flow(definition: str) -> Any:
65+
def load_declarative_flow(definition: str | Path) -> Any:
6666
"""Load a declarative Flow instance from a definition path."""
6767
try:
6868
from crewai.flow.flow import Flow
@@ -102,7 +102,8 @@ def load_declarative_flow(definition: str) -> Any:
102102

103103
def configured_project_declarative_flow(
104104
pyproject_data: dict[str, Any] | None = None,
105-
) -> str | None:
105+
project_root: Path | None = None,
106+
) -> Path | None:
106107
"""Return the configured declarative flow source for flow projects."""
107108
if pyproject_data is None:
108109
try:
@@ -118,7 +119,66 @@ def configured_project_declarative_flow(
118119
definition = crewai_config.get("definition")
119120
if not isinstance(definition, str):
120121
return None
121-
return definition.strip() or None
122+
definition = definition.strip()
123+
if not definition:
124+
return None
125+
126+
return _resolve_project_definition_path(
127+
definition=definition,
128+
project_root=project_root or Path.cwd(),
129+
)
130+
131+
132+
def _resolve_project_definition_path(definition: str, project_root: Path) -> Path:
133+
definition_path = Path(definition)
134+
windows_definition_path = PureWindowsPath(definition)
135+
136+
if definition.startswith("~"):
137+
raise click.UsageError(
138+
"[tool.crewai] definition must be a project-local path; "
139+
f"got {definition!r}."
140+
)
141+
142+
if definition_path.is_absolute() or windows_definition_path.is_absolute():
143+
raise click.UsageError(
144+
"[tool.crewai] definition must be relative to the project root; "
145+
f"got {definition!r}."
146+
)
147+
148+
try:
149+
root = project_root.resolve(strict=True)
150+
except OSError as exc:
151+
raise click.UsageError(
152+
f"Invalid project root for [tool.crewai] definition: {exc}"
153+
) from exc
154+
155+
candidate = root / definition_path
156+
try:
157+
resolved_candidate = candidate.resolve(strict=False)
158+
except OSError as exc:
159+
raise click.UsageError(
160+
f"Invalid [tool.crewai] definition path {definition!r}: {exc}"
161+
) from exc
162+
163+
if not resolved_candidate.is_relative_to(root):
164+
raise click.UsageError(
165+
"[tool.crewai] definition must resolve inside the project root; "
166+
f"got {definition!r}."
167+
)
168+
169+
if not resolved_candidate.exists():
170+
raise click.UsageError(
171+
"[tool.crewai] definition must point to an existing file; "
172+
f"got {definition!r}."
173+
)
174+
175+
if not resolved_candidate.is_file():
176+
raise click.UsageError(
177+
"[tool.crewai] definition must point to a regular file; "
178+
f"got {definition!r}."
179+
)
180+
181+
return resolved_candidate
122182

123183

124184
def _execute_declarative_flow_command(command: list[str]) -> None:

lib/cli/tests/test_flow_commands.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pathlib import Path
44
import subprocess
55

6+
import click
67
import pytest
78
from click.testing import CliRunner
89

@@ -107,11 +108,141 @@ def test_configured_project_declarative_flow(
107108
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
108109
) -> None:
109110
monkeypatch.chdir(tmp_path)
111+
definition_path = tmp_path / "flow.yaml"
112+
definition_path.write_text(FLOW_YAML, encoding="utf-8")
110113
(tmp_path / "pyproject.toml").write_text(
111114
'[tool.crewai]\ntype = "flow"\ndefinition = " flow.yaml "\n',
112115
encoding="utf-8",
113116
)
114117

115118
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
116119

117-
assert configured_project_declarative_flow() == "flow.yaml"
120+
assert configured_project_declarative_flow() == definition_path.resolve()
121+
122+
123+
@pytest.mark.parametrize(
124+
("definition", "expected_error"),
125+
[
126+
("C:/tmp/flow.yaml", "must be relative to the project root"),
127+
("~/flow.yaml", "must be a project-local path"),
128+
("../flow.yaml", "must resolve inside the project root"),
129+
],
130+
)
131+
def test_configured_project_declarative_flow_rejects_unsafe_paths(
132+
monkeypatch: pytest.MonkeyPatch,
133+
tmp_path: Path,
134+
definition: str,
135+
expected_error: str,
136+
) -> None:
137+
monkeypatch.chdir(tmp_path)
138+
(tmp_path / "pyproject.toml").write_text(
139+
f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition}"\n',
140+
encoding="utf-8",
141+
)
142+
143+
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
144+
145+
with pytest.raises(click.UsageError) as exc_info:
146+
configured_project_declarative_flow()
147+
148+
assert expected_error in exc_info.value.message
149+
150+
151+
def test_configured_project_declarative_flow_allows_normalized_project_path(
152+
monkeypatch: pytest.MonkeyPatch,
153+
tmp_path: Path,
154+
) -> None:
155+
monkeypatch.chdir(tmp_path)
156+
definition_path = tmp_path / "flow.yaml"
157+
definition_path.write_text(FLOW_YAML, encoding="utf-8")
158+
(tmp_path / "src").mkdir()
159+
(tmp_path / "pyproject.toml").write_text(
160+
'[tool.crewai]\ntype = "flow"\ndefinition = "src/../flow.yaml"\n',
161+
encoding="utf-8",
162+
)
163+
164+
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
165+
166+
assert configured_project_declarative_flow() == definition_path.resolve()
167+
168+
169+
def test_configured_project_declarative_flow_rejects_absolute_path(
170+
monkeypatch: pytest.MonkeyPatch,
171+
tmp_path: Path,
172+
) -> None:
173+
monkeypatch.chdir(tmp_path)
174+
definition = tmp_path / "flow.yaml"
175+
(tmp_path / "pyproject.toml").write_text(
176+
f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition.as_posix()}"\n',
177+
encoding="utf-8",
178+
)
179+
180+
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
181+
182+
with pytest.raises(click.UsageError) as exc_info:
183+
configured_project_declarative_flow()
184+
185+
assert "must be relative to the project root" in exc_info.value.message
186+
187+
188+
def test_configured_project_declarative_flow_rejects_symlink_escape(
189+
monkeypatch: pytest.MonkeyPatch,
190+
tmp_path: Path,
191+
) -> None:
192+
monkeypatch.chdir(tmp_path)
193+
outside_definition = tmp_path.parent / "outside-flow.yaml"
194+
outside_definition.write_text(FLOW_YAML, encoding="utf-8")
195+
link = tmp_path / "flow.yaml"
196+
try:
197+
link.symlink_to(outside_definition)
198+
except (NotImplementedError, OSError) as exc:
199+
pytest.skip(f"symlinks unavailable: {exc}")
200+
201+
(tmp_path / "pyproject.toml").write_text(
202+
'[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
203+
encoding="utf-8",
204+
)
205+
206+
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
207+
208+
with pytest.raises(click.UsageError) as exc_info:
209+
configured_project_declarative_flow()
210+
211+
assert "must resolve inside the project root" in exc_info.value.message
212+
213+
214+
def test_configured_project_declarative_flow_rejects_missing_file(
215+
monkeypatch: pytest.MonkeyPatch,
216+
tmp_path: Path,
217+
) -> None:
218+
monkeypatch.chdir(tmp_path)
219+
(tmp_path / "pyproject.toml").write_text(
220+
'[tool.crewai]\ntype = "flow"\ndefinition = "missing-flow.yaml"\n',
221+
encoding="utf-8",
222+
)
223+
224+
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
225+
226+
with pytest.raises(click.UsageError) as exc_info:
227+
configured_project_declarative_flow()
228+
229+
assert "must point to an existing file" in exc_info.value.message
230+
231+
232+
def test_configured_project_declarative_flow_rejects_directory(
233+
monkeypatch: pytest.MonkeyPatch,
234+
tmp_path: Path,
235+
) -> None:
236+
monkeypatch.chdir(tmp_path)
237+
(tmp_path / "flow.yaml").mkdir()
238+
(tmp_path / "pyproject.toml").write_text(
239+
'[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
240+
encoding="utf-8",
241+
)
242+
243+
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
244+
245+
with pytest.raises(click.UsageError) as exc_info:
246+
configured_project_declarative_flow()
247+
248+
assert "must point to a regular file" in exc_info.value.message

lib/cli/tests/test_run_crew.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -705,9 +705,14 @@ def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
705705
assert "--filename can only be used when running crews" in exc_info.value.message
706706

707707

708-
def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
708+
def test_run_crew_runs_configured_declarative_flow_project(
709+
monkeypatch, tmp_path: Path, capsys
710+
):
709711
calls = []
710712

713+
monkeypatch.chdir(tmp_path)
714+
definition_path = tmp_path / "flow.yaml"
715+
definition_path.write_text("schema: crewai.flow/v1\n", encoding="utf-8")
711716
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
712717
monkeypatch.setattr(
713718
run_crew_module,
@@ -734,4 +739,4 @@ def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
734739
run_crew_module.run_crew()
735740

736741
assert capsys.readouterr().out == ""
737-
assert calls == [("flow.yaml", None)]
742+
assert calls == [(definition_path.resolve(), None)]

0 commit comments

Comments
 (0)