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
44 changes: 33 additions & 11 deletions src/specify_cli/workflows/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2354,14 +2354,25 @@ def workflow_info(
raise typer.Exit(1)

if definition:
console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
console.print(f" Version: {definition.version}")
# Escape every user-controlled field: workflow.yml values (name,
# version, author, description, integration, input names/types) are not
# trusted, and console.print has Rich markup enabled, so an unescaped
# `[...]` in any of them is parsed as a style tag and silently swallowed
# (same defect fixed for the step graph below; the sibling workflow_list
# already escapes all of these).
console.print(
f"\n[bold cyan]{_escape_markup(str(definition.name))}[/bold cyan] "
f"({_escape_markup(str(definition.id))})"
)
console.print(f" Version: {_escape_markup(str(definition.version))}")
if definition.author:
console.print(f" Author: {definition.author}")
console.print(f" Author: {_escape_markup(str(definition.author))}")
if definition.description:
console.print(f" Description: {definition.description}")
console.print(f" Description: {_escape_markup(str(definition.description))}")
if definition.default_integration:
console.print(f" Integration: {definition.default_integration}")
console.print(
f" Integration: {_escape_markup(str(definition.default_integration))}"
)
if installed:
console.print(" [green]Installed[/green]")

Expand All @@ -2370,7 +2381,10 @@ def workflow_info(
for name, inp in definition.inputs.items():
if isinstance(inp, dict):
req = "required" if inp.get("required") else "optional"
console.print(f" {name} ({inp.get('type', 'string')}) — {req}")
console.print(
f" {_escape_markup(str(name))} "
f"({_escape_markup(str(inp.get('type', 'string')))}) — {req}"
)

if definition.steps:
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
Expand All @@ -2395,15 +2409,23 @@ def workflow_info(
info = None

if info:
console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})")
console.print(f" Version: {info.get('version', '?')}")
# Catalog-derived fields are untrusted; escape them so bracketed content
# is rendered literally rather than parsed (and swallowed) as Rich markup.
console.print(
f"\n[bold cyan]{_escape_markup(str(info.get('name', workflow_id)))}[/bold cyan] "
f"({_escape_markup(str(workflow_id))})"
)
console.print(f" Version: {_escape_markup(str(info.get('version', '?')))}")
if info.get("description"):
console.print(f" Description: {info['description']}")
console.print(f" Description: {_escape_markup(str(info['description']))}")
if info.get("tags"):
console.print(f" Tags: {', '.join(info['tags'])}")
safe_tags = _escape_markup(", ".join(str(t) for t in info["tags"]))
console.print(f" Tags: {safe_tags}")
console.print(" [yellow]Not installed[/yellow]")
else:
console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found")
console.print(
f"[red]Error:[/red] Workflow '{_escape_markup(str(workflow_id))}' not found"
)
Comment on lines +2426 to +2428
raise typer.Exit(1)


Expand Down
106 changes: 106 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -7929,6 +7929,112 @@ def test_step_type_rendered_in_literal_brackets(self, temp_dir, monkeypatch):
# by Rich as an unknown style tag.
assert "[gate]" in result.output

def test_definition_metadata_fields_escaped(self, temp_dir, monkeypatch):
"""Every metadata field printed from the workflow definition (name,
description, author, integration, input name/type) is untrusted
workflow.yml content. An unescaped `[...]` in any of them would be
parsed as a Rich style tag and silently swallowed, so bracketed text
must survive literally in the output."""
import types

from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.engine import WorkflowEngine

(temp_dir / ".specify" / "workflows").mkdir(parents=True)

fake = types.SimpleNamespace(
name="My [WF]",
id="my-wf",
version="1.0.0 [beta]",
author="Jane [Doe]",
Comment on lines +7947 to +7950
description="Does [stuff] nicely",
default_integration="claude [code]",
inputs={"in [put]": {"type": "str [ing]", "required": True}},
steps=[],
)
monkeypatch.setattr(WorkflowEngine, "load_workflow", lambda self, wid: fake)
monkeypatch.chdir(temp_dir)

result = CliRunner().invoke(app, ["workflow", "info", "my-wf"])

assert result.exit_code == 0, result.output
# Each bracketed token must render literally rather than be consumed as
# an unknown Rich style tag.
assert "My [WF]" in result.output
assert "1.0.0 [beta]" in result.output
assert "Jane [Doe]" in result.output
assert "Does [stuff] nicely" in result.output
assert "claude [code]" in result.output
assert "in [put]" in result.output
assert "str [ing]" in result.output

def test_catalog_metadata_fields_escaped(self, temp_dir, monkeypatch):
"""When the workflow is only found in the catalog (not on disk), its
catalog-derived fields (name, description, tags) are untrusted too and
must be escaped so bracketed content renders literally."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.engine import WorkflowEngine
from specify_cli.workflows import catalog as catalog_mod

(temp_dir / ".specify" / "workflows").mkdir(parents=True)

def _not_on_disk(self, wid):
raise FileNotFoundError(wid)

monkeypatch.setattr(WorkflowEngine, "load_workflow", _not_on_disk)
monkeypatch.setattr(
catalog_mod.WorkflowCatalog,
"get_workflow_info",
lambda self, wid: {
"name": "Cat [WF]",
"version": "2.0.0 [rc]",
"description": "From [catalog]",
"tags": ["a [b]", "c [d]"],
},
)
monkeypatch.chdir(temp_dir)

result = CliRunner().invoke(app, ["workflow", "info", "cat-wf"])

assert result.exit_code == 0, result.output
assert "Cat [WF]" in result.output
assert "2.0.0 [rc]" in result.output
assert "From [catalog]" in result.output
assert "a [b]" in result.output
assert "c [d]" in result.output

def test_not_found_id_escaped(self, temp_dir, monkeypatch):
"""When the workflow is neither on disk nor in the catalog, the
not-found error echoes the requested ID. That ID is user input, so a
bracketed value must render literally instead of being parsed (and
swallowed) as a Rich style tag."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.engine import WorkflowEngine
from specify_cli.workflows import catalog as catalog_mod

(temp_dir / ".specify" / "workflows").mkdir(parents=True)

def _not_on_disk(self, wid):
raise FileNotFoundError(wid)

monkeypatch.setattr(WorkflowEngine, "load_workflow", _not_on_disk)
monkeypatch.setattr(
catalog_mod.WorkflowCatalog,
"get_workflow_info",
lambda self, wid: None,
)
monkeypatch.chdir(temp_dir)

result = CliRunner().invoke(app, ["workflow", "info", "ghost [wf]"])

assert result.exit_code == 1, result.output
assert "not found" in result.output
# The bracketed ID must survive literally, not be eaten as markup.
assert "ghost [wf]" in result.output


class TestWorkflowAddSymlinkGuard:
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
Expand Down