Skip to content

Commit d8133ba

Browse files
author
Tom Brandenburg
committed
fix(agent): safe variable-only interpolation for expandPrompt
Replace unquoted heredoc shell expansion with quoted heredoc plus explicit per-variable plain-text substitution. - Always emit <<'DELIMITER' so prompt content is never shell-evaluated - When expandPrompt: true, scan prompt for ${VAR} / $VAR tokens and emit safe bash string replacements using a helper _p variable - Backticks, $(...), globs, and all other shell syntax pass through to the agent literally - Add Field descriptions to prompt and expandPrompt for --schema output Fixes accidental execution of shell expressions in agent prompts.
1 parent 2337f09 commit d8133ba

6 files changed

Lines changed: 41 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "uv_build"
44

55
[project]
66
name = "flowsh-cli"
7-
version = "0.4.0"
7+
version = "0.4.2"
88
description = "Generate Bash harness scripts from workflow YAML files."
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/flowsh_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from flowsh_cli.models import Workflow, WorkflowParseError, parse_workflows
44
from flowsh_cli.render import harness_path, render_harness
55

6-
__version__ = "0.4.0"
6+
__version__ = "0.4.2"
77

88
__all__ = [
99
"Workflow",

src/flowsh_cli/models.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,14 @@ def validate_run(cls, value: str) -> str:
7373

7474
class AgentStep(BaseStep):
7575
type: Literal["agent"]
76-
prompt: str
76+
prompt: str = Field(
77+
description=(
78+
"The prompt text sent to the agent. "
79+
"May contain markdown, code fences, backticks, $(...), and other shell syntax freely — "
80+
"the prompt is never passed through a shell. "
81+
"Use expandPrompt: true to substitute ${VAR} / $VAR tokens from vars steps."
82+
)
83+
)
7784
agent: str | None = None
7885
model: str | None = None
7986
command: str | None = None
@@ -84,7 +91,17 @@ class AgentStep(BaseStep):
8491
"dangerously-skip-permissions",
8592
),
8693
)
87-
expandPrompt: bool = False
94+
expandPrompt: bool = Field(
95+
default=False,
96+
description=(
97+
"When true, substitutes ${VAR} and $VAR tokens from vars steps into the prompt "
98+
"at runtime using safe plain-text replacement. "
99+
"Shell expressions such as $(cmd), `cmd`, $((expr)), and globs are NOT evaluated — "
100+
"they pass through to the agent literally. "
101+
"Only uppercase variable names matching [A-Z_][A-Z0-9_]* found in the prompt text "
102+
"are replaced. All other shell syntax in the prompt is safe to use."
103+
),
104+
)
88105

89106
@model_validator(mode="before")
90107
@classmethod

src/flowsh_cli/render.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,16 +257,25 @@ def render_step(index: int, step: Step, used_function_names: set[str] | None = N
257257
)
258258
elif isinstance(step, AgentStep):
259259
delimiter = heredoc_delimiter("PROMPT", step.prompt)
260-
heredoc = f"<<{delimiter}" if step.expandPrompt else f"<<'{delimiter}'"
261260
lines.extend(
262261
[
263262
" local prompt",
264-
f" prompt=$(cat {heredoc}",
263+
f" prompt=$(cat <<'{delimiter}'",
265264
*step.prompt.splitlines(),
266265
delimiter,
267266
" )",
268267
]
269268
)
269+
if step.expandPrompt:
270+
braced = re.findall(r"\$\{([A-Z_][A-Z0-9_]*)\}", step.prompt)
271+
bare = re.findall(r"\$([A-Z_][A-Z0-9_]*)(?!\w)", step.prompt)
272+
seen: dict[str, None] = {}
273+
for var in braced + bare:
274+
seen[var] = None
275+
for var in seen:
276+
lines.append(f' _p=\'${{{var}}}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
277+
lines.append(f' _p=\'${var}\'; prompt="${{prompt//"$_p"/"${var}"}}"')
278+
270279
lines.append(f" local agent={bash_quote(step.agent or '')}")
271280
lines.append(f" local model={bash_quote(step.model or '')}")
272281
lines.append(f" local command={bash_quote(step.command or '')}")

tests/test_workflow_to_harness.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -557,23 +557,27 @@ def test_render_harness_quotes_agent_prompt_heredoc_by_default() -> None:
557557
assert "prompt=$(cat <<PROMPT_EOF" not in script
558558

559559

560-
def test_render_harness_unquotes_agent_prompt_heredoc_when_expand_prompt_enabled() -> None:
560+
def test_render_harness_safe_variable_substitution_when_expand_prompt_enabled() -> None:
561561
workflow = Workflow(
562562
id="wf_prompt_expand",
563563
name="Prompt Expand",
564564
steps=[
565565
AgentStep(
566566
type="agent",
567-
prompt="Work on issue $ISSUE_NUMBER.",
567+
prompt="Work on issue ${ISSUE_NUMBER}.",
568568
expandPrompt=True,
569569
)
570570
],
571571
)
572572

573573
script = render_harness(workflow)
574574

575-
assert "prompt=$(cat <<PROMPT_EOF" in script
576-
assert "prompt=$(cat <<'PROMPT_EOF'" not in script
575+
# Always uses quoted heredoc - no shell expansion of the raw prompt
576+
assert "prompt=$(cat <<'PROMPT_EOF'" in script
577+
assert "prompt=$(cat <<PROMPT_EOF" not in script
578+
# Safe substitution lines emitted for the declared variable
579+
assert '_p=\'${ISSUE_NUMBER}\'; prompt="${prompt//"$_p"/"$ISSUE_NUMBER"}"' in script
580+
assert '_p=\'$ISSUE_NUMBER\'; prompt="${prompt//"$_p"/"$ISSUE_NUMBER"}"' in script
577581

578582

579583
def test_render_harness_disambiguates_duplicate_step_function_names(tmp_path: Path) -> None:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)