Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ All notable changes to weft are documented here. Format follows [Keep a Changelo
- `Makefile` with `install`, `test`, `lint`, `clean` targets for one-command contributor onboarding.

### Fixed
- **Template name path traversal.** `templates.load_template`/`save_template` interpolated the caller-supplied `name` straight into `<dir>/<name>.json`, so a name like `../../evil` escaped the templates directory (read or write). Names are now validated to a single safe stem (no separators, no `..`, no leading dot); legitimate names are unchanged, and the `weft start`/`preview` CLI paths reject a bad name cleanly.
- **Dashboard dependencies declared as the `dashboard` extra.** `core/dashboard.py` imports `textual` and `rich`, but `pyproject.toml` declared no runtime deps, so `weft dashboard` (and `import core.dashboard`) hard-failed with `ModuleNotFoundError` on a clean install. The two libraries now ship as `pip install weft[dashboard]` (keeping the core engine stdlib-only); the module imports regardless, and launching the dashboard without the extra now exits with an actionable `pip install weft[dashboard]` message instead of a traceback.
- **`insights` step field is now actually populated.** `generate_context_md` rendered per-step `insights` (💡 lines) and the field was documented, but `start_workflow`/`rebuild_from_events` never copied it from the template into step state — so it silently rendered nothing. Both build sites now carry `insights`, matching the existing `suggest`-style optional fields. (`suggest` has the same latent gap; wire it the same way if a template ever uses it.)

### Changed
Expand Down
12 changes: 10 additions & 2 deletions core/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,11 @@ def cmd_start(args: list[str]) -> None:
step_names = [s.strip() for s in name.split(",")]
tmpl = templates.template_from_steps(step_names)
else:
tmpl = templates.load_template(name, _project_dir())
try:
tmpl = templates.load_template(name, _project_dir())
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
if not tmpl:
print(f"Template not found: {name}", file=sys.stderr)
print("Available:", file=sys.stderr)
Expand Down Expand Up @@ -358,7 +362,11 @@ def cmd_preview(args: list[str]) -> None:
print("Use 'start' with no args to list templates.", file=sys.stderr)
sys.exit(1)

detail = templates.template_detail(args[0], _project_dir())
try:
detail = templates.template_detail(args[0], _project_dir())
except ValueError as e:
print(str(e), file=sys.stderr)
sys.exit(1)
if not detail:
print(f"Template not found: {args[0]}", file=sys.stderr)
sys.exit(1)
Expand Down
61 changes: 54 additions & 7 deletions core/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,59 @@
import sys
from pathlib import Path

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Footer, Header, Static
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from typing import TYPE_CHECKING

# textual + rich are optional — they ship only with the `dashboard` extra
# (`pip install weft[dashboard]`), keeping the core engine stdlib-only. Guard the
# import so this module still loads on a clean install; `_require_deps` surfaces a
# helpful message if someone actually launches the dashboard without them.
if TYPE_CHECKING:
# Type checkers always see the real symbols (deps present in a lint env).
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Footer, Header, Static
from rich.panel import Panel
from rich.table import Table
from rich.text import Text

_MISSING_DEP: str | None = None
else:
try:
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Footer, Header, Static
from rich.panel import Panel
from rich.table import Table
from rich.text import Text

_MISSING_DEP = None
except ModuleNotFoundError as exc:
_MISSING_DEP = exc.name

# Minimal runtime fallbacks so the module imports and class/function
# definitions evaluate. None of these are used unless the dashboard is
# actually run, which `_require_deps` blocks first.
App = object
ComposeResult = object

def Binding(*_args, **_kwargs):
return None

Horizontal = Vertical = VerticalScroll = None
Footer = Header = Static = None
Panel = Table = Text = None


def _require_deps() -> None:
"""Fail with an actionable message if the dashboard extra isn't installed."""
if _MISSING_DEP is not None:
raise SystemExit(
f"weft dashboard requires the '{_MISSING_DEP}' package, which is not "
"installed.\nInstall the dashboard extra: pip install weft[dashboard]"
)


sys.path.insert(0, str(Path(__file__).parent.parent))

Expand Down Expand Up @@ -461,6 +507,7 @@ class WeftDashboard(App):
]

def __init__(self, project_dir: str | None = None):
_require_deps()
super().__init__()
self.project_dir = project_dir

Expand Down
16 changes: 15 additions & 1 deletion core/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ def _user_templates_dir() -> Path:
return Path.home() / ".weft" / "templates"


def _safe_name(name: str) -> str:
"""Validate a template name used to build a `<dir>/<name>.json` path.

A name must be a single safe path stem: no directory separators, no `..`,
and no leading dot. This blocks traversal like `../../evil` escaping the
templates directory. Legitimate names (e.g. `generic`, `feature-workflow`)
are returned unchanged.
"""
if not name or Path(name).name != name or name.startswith("."):
raise ValueError(f"Invalid template name: {name!r}")
return name


def list_templates(project_dir: str | None = None) -> list[dict]:
"""List all available templates (plugin + user-global + project-local)."""
templates = []
Expand All @@ -48,6 +61,7 @@ def list_templates(project_dir: str | None = None) -> list[dict]:

def load_template(name: str, project_dir: str | None = None) -> dict | None:
"""Load a template by name. Project-local > user-global > plugin."""
name = _safe_name(name)
for d in [_project_templates_dir(project_dir), _user_templates_dir(), _plugin_templates_dir()]:
path = d / f"{name}.json"
if path.exists():
Expand All @@ -72,7 +86,7 @@ def template_from_steps(step_names: list[str], name: str = "adhoc") -> dict:

def save_template(template: dict, project_dir: str | None = None) -> Path:
"""Save a template to the project-local templates directory. Returns the path."""
name = template.get("name", "unnamed")
name = _safe_name(template.get("name", "unnamed"))
d = _project_templates_dir(project_dir)
d.mkdir(parents=True, exist_ok=True)
path = d / f"{name}.json"
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ dependencies = []

[project.optional-dependencies]
dev = ["pytest>=7"]
# The TUI dashboard (`weft dashboard`) is the one feature that needs third-party
# libraries. Kept out of the core install so the engine stays stdlib-only;
# install with `pip install weft[dashboard]`.
dashboard = ["textual>=0.40", "rich>=13.0"]

[project.urls]
Homepage = "https://github.com/dioptx/weft"
Expand Down
17 changes: 17 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,3 +756,20 @@ def test_unknown_command_points_at_help(self, project_dir):
r = run_cli(["bogus"], str(project_dir))
assert r.returncode == 1
assert "--help" in r.stderr


class TestTemplateNameTraversalCLI:
"""integration: a traversal template name is rejected cleanly end-to-end
(clean exit + message, not an unhandled traceback)."""

def test_start_rejects_traversal(self, project_dir):
r = run_cli(["start", "../../evil"], str(project_dir))
assert r.returncode == 1
assert "Invalid template name" in r.stderr
assert "Traceback" not in r.stderr

def test_preview_rejects_traversal(self, project_dir):
r = run_cli(["preview", "../../evil"], str(project_dir))
assert r.returncode == 1
assert "Invalid template name" in r.stderr
assert "Traceback" not in r.stderr
103 changes: 103 additions & 0 deletions tests/test_dashboard_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for the dashboard's optional-dependency handling.

The TUI dashboard (`core/dashboard.py`) needs `textual` and `rich`. The core
engine is stdlib-only, so those ship as the `dashboard` extra
(`pip install weft[dashboard]`). These tests pin that contract: the extra is
declared, importing the module never hard-fails on a clean install, and
launching without the deps gives an actionable error.
"""

import sys
from pathlib import Path

import pytest

PLUGIN_ROOT = Path(__file__).parent.parent

# tomllib is stdlib on 3.11+; the repo supports 3.10+, so fall back to a text
# parse when it's unavailable. (dev extra has no tomli to lean on.)
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - only on Python 3.10
tomllib = None


def _pyproject_text() -> str:
return (PLUGIN_ROOT / "pyproject.toml").read_text()


# --- frame / packaging: the extra is declared with both libraries ---
class TestDashboardExtraDeclared:
def _optional_deps(self) -> dict | None:
if tomllib is None:
return None
data = tomllib.loads(_pyproject_text())
return data["project"].get("optional-dependencies", {})

def test_dashboard_extra_exists(self):
deps = self._optional_deps()
if deps is None:
assert "dashboard = [" in _pyproject_text()
else:
assert "dashboard" in deps, (
"pyproject.toml must declare a [dashboard] extra so the "
"dashboard's third-party deps install via pip install weft[dashboard]"
)

def test_dashboard_extra_declares_textual_and_rich(self):
deps = self._optional_deps()
if deps is None:
blob = _pyproject_text().lower()
assert "dashboard = [" in blob
else:
blob = " ".join(deps.get("dashboard", [])).lower()
assert "textual" in blob
assert "rich" in blob

def test_core_dependencies_stay_empty(self):
# The engine must remain stdlib-only; third-party deps belong in extras.
deps = self._optional_deps()
if deps is None:
assert "dependencies = []" in _pyproject_text()
else:
data = tomllib.loads(_pyproject_text())
assert data["project"]["dependencies"] == []


# --- frame: the module imports even when textual/rich are absent (regression) ---
class TestDashboardImportIsSoft:
def test_module_imports_without_hard_failure(self):
# Previously `import core.dashboard` raised ModuleNotFoundError on a clean
# install (textual/rich at module top level). It must now import and merely
# record the missing dep.
sys.modules.pop("core.dashboard", None)
import core.dashboard as dash

assert hasattr(dash, "WeftDashboard")
assert hasattr(dash, "_MISSING_DEP")


# --- functional: launching without deps fails with a helpful message ---
class TestImportGuard:
def test_require_deps_raises_helpful_message(self, monkeypatch):
import core.dashboard as dash

# Simulate the deps being absent regardless of what's installed locally.
monkeypatch.setattr(dash, "_MISSING_DEP", "textual")
with pytest.raises(SystemExit) as exc:
dash._require_deps()
assert "pip install weft[dashboard]" in str(exc.value)

def test_require_deps_noop_when_present(self, monkeypatch):
import core.dashboard as dash

monkeypatch.setattr(dash, "_MISSING_DEP", None)
dash._require_deps() # must not raise

def test_constructing_dashboard_without_deps_is_guarded(self, monkeypatch):
import core.dashboard as dash

monkeypatch.setattr(dash, "_MISSING_DEP", "textual")
with pytest.raises(SystemExit) as exc:
dash.WeftDashboard()
assert "pip install weft[dashboard]" in str(exc.value)
54 changes: 54 additions & 0 deletions tests/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import json

import pytest

from core import templates


Expand Down Expand Up @@ -178,3 +180,55 @@ def test_plan_has_push_guard(self, project_dir):
plan = next(s for s in tmpl["steps"] if s["name"] == "plan-and-worktree")
guards = plan.get("guards", [])
assert len(guards) > 0


class TestTemplateNameSafety:
"""Template names are interpolated into `<dir>/<name>.json`. A name must be a
single safe stem so it cannot escape the templates directory (path traversal).
"""

# --- unit: the validator itself ---
def test_safe_name_passes_legitimate_stems(self):
for name in ["generic", "feature-workflow", "my_custom", "codex-review"]:
assert templates._safe_name(name) == name

@pytest.mark.parametrize(
"name",
["../../evil", "..", "a/b", "/etc/passwd", "sub/dir/x", ".hidden", "", "."],
)
def test_safe_name_rejects_unsafe(self, name):
with pytest.raises(ValueError):
templates._safe_name(name)

# --- security: load_template must not read outside the templates dirs ---
def test_load_template_rejects_traversal(self, project_dir, tmp_path):
# Plant a file one level above where a project-local "evil" template could
# land, then try to reach it via traversal.
secret = tmp_path / ".claude" / "weft" / "secret.json"
secret.write_text(json.dumps({"name": "secret", "steps": []}))
with pytest.raises(ValueError):
templates.load_template("../secret", str(project_dir))

def test_load_template_rejects_absolute(self, project_dir):
with pytest.raises(ValueError):
templates.load_template("/etc/passwd", str(project_dir))

# --- security: save_template must not write outside the project templates dir ---
def test_save_template_rejects_traversal(self, project_dir):
evil = {"name": "../../evil", "description": "x", "steps": [{"name": "s"}]}
with pytest.raises(ValueError):
templates.save_template(evil, str(project_dir))
# Nothing escaped the sandbox.
assert not (project_dir.parent.parent / "evil.json").exists()

# --- functional: legitimate names behave exactly as before (regression) ---
def test_legitimate_names_unaffected(self, project_dir):
assert templates.load_template("generic", str(project_dir)) is not None
assert templates.load_template("nonexistent", str(project_dir)) is None

def test_save_then_load_roundtrip(self, project_dir):
tmpl = {"name": "my-flow", "description": "d", "steps": [{"name": "s"}]}
path = templates.save_template(tmpl, str(project_dir))
assert path.name == "my-flow.json"
assert path.parent == project_dir / ".claude" / "weft" / "templates"
assert templates.load_template("my-flow", str(project_dir))["name"] == "my-flow"