Skip to content

Commit 4868652

Browse files
fix(workflows): reject a non-string 'integration'/'model' in command & prompt steps (#3597)
* fix(workflows): reject a non-string 'integration'/'model' in command & prompt steps A non-string `integration` on a command or prompt step is passed to `get_integration()`, which uses it as a dict key: an unhashable list/dict raises a raw `TypeError` there — and because neither `validate()` nor `validate_workflow` checked the type, this crashes even a *validated* run, not just an unvalidated one. A non-string `model` likewise reaches `build_exec_args()` and is fed into the CLI argv. Guard both fields in `validate()` (reject a literal non-string, mirroring the existing 'command'/'prompt'/'input'/'options' checks) and in `execute()` (fail the step cleanly rather than take down the whole run, mirroring the 'input'/'options' guards). An explicit YAML-null (inherit the workflow default) and a "{{ ... }}" expression both stay valid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(workflows): route falsey non-string integration/model to the type guard Address Copilot review: `config.get("integration") or context.default_integration` (and the model equivalent) coerced a *falsey* non-string ([], {}, 0, False) into the workflow default before the type guard ran. On an unvalidated execute() such a step was silently accepted and — with a configured default — could dispatch using the wrong integration/model instead of failing with the contract error. Fall back to the workflow default only for genuinely-unset values (missing / YAML-null / empty string) so every non-string reaches the guard. Add parametrized falsey execute() cases ([], {}, 0, False) to both TestCommandStep and TestPromptStep; with the fix stashed all 8 fail (swallowed into the default). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ebd3097 commit 4868652

3 files changed

Lines changed: 278 additions & 8 deletions

File tree

src/specify_cli/workflows/steps/command/__init__.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,52 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
6666
for key, value in input_data.items():
6767
resolved_input[key] = evaluate_expression(value, context)
6868

69-
# Resolve integration (step → workflow default → project default)
70-
integration = config.get("integration") or context.default_integration
69+
# Resolve integration (step → workflow default → project default).
70+
# Fall back to the workflow default ONLY for a genuinely-unset value
71+
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
72+
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
73+
# it to the default before the guard below runs — so on an unvalidated
74+
# execute() such a step would silently dispatch with the configured
75+
# default instead of failing. Fall through instead, so every non-string
76+
# reaches the type guard.
77+
integration = config.get("integration")
78+
if integration is None or integration == "":
79+
integration = context.default_integration
7180
if integration and isinstance(integration, str) and "{{" in integration:
7281
integration = evaluate_expression(integration, context)
7382

74-
# Resolve model
75-
model = config.get("model") or context.default_model
83+
# Resolve model (same fallback rationale as 'integration' above).
84+
model = config.get("model")
85+
if model is None or model == "":
86+
model = context.default_model
7687
if model and isinstance(model, str) and "{{" in model:
7788
model = evaluate_expression(model, context)
7889

90+
# A non-string integration/model — a literal list/dict/number that
91+
# skipped validation, an unvalidated workflow-level default, or an
92+
# expression that resolved to one — crashes downstream: get_integration()
93+
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
94+
# even on a *validated* run) and build_exec_args() feeds model into the
95+
# CLI argv. Fail the step with the contract error rather than taking down
96+
# the whole run, mirroring the 'input'/'options' guards above. ``None``
97+
# stays valid — it means "unset" and falls back to dispatch-not-possible.
98+
if integration is not None and not isinstance(integration, str):
99+
return StepResult(
100+
status=StepStatus.FAILED,
101+
error=(
102+
f"Command step {config.get('id', '?')!r}: 'integration' must "
103+
f"be a string, got {type(integration).__name__}."
104+
),
105+
)
106+
if model is not None and not isinstance(model, str):
107+
return StepResult(
108+
status=StepStatus.FAILED,
109+
error=(
110+
f"Command step {config.get('id', '?')!r}: 'model' must be a "
111+
f"string, got {type(model).__name__}."
112+
),
113+
)
114+
79115
# Merge options (workflow defaults ← step overrides)
80116
options = dict(context.default_options)
81117
step_options = config.get("options", {})
@@ -217,4 +253,23 @@ def validate(self, config: dict[str, Any]) -> list[str]:
217253
errors.append(
218254
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
219255
)
256+
# execute() passes 'integration' to get_integration(), which uses it as a
257+
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
258+
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
259+
# literal non-string here, mirroring the sibling type checks. ``None``
260+
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
261+
# workflow default" and stays valid; an expression like "{{ ... }}" is
262+
# still a str, so it stays valid too.
263+
integration = config.get("integration")
264+
if integration is not None and not isinstance(integration, str):
265+
errors.append(
266+
f"Command step {config.get('id', '?')!r}: 'integration' must be a "
267+
f"string, got {type(integration).__name__}."
268+
)
269+
model = config.get("model")
270+
if model is not None and not isinstance(model, str):
271+
errors.append(
272+
f"Command step {config.get('id', '?')!r}: 'model' must be a "
273+
f"string, got {type(model).__name__}."
274+
)
220275
return errors

src/specify_cli/workflows/steps/prompt/__init__.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,52 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
4242
if not isinstance(prompt, str):
4343
prompt = str(prompt)
4444

45-
# Resolve integration (step → workflow default)
46-
integration = config.get("integration") or context.default_integration
45+
# Resolve integration (step → workflow default).
46+
# Fall back to the workflow default ONLY for a genuinely-unset value
47+
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
48+
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
49+
# it to the default before the guard below runs — so on an unvalidated
50+
# execute() such a step would silently dispatch with the configured
51+
# default instead of failing. Fall through instead, so every non-string
52+
# reaches the type guard.
53+
integration = config.get("integration")
54+
if integration is None or integration == "":
55+
integration = context.default_integration
4756
if integration and isinstance(integration, str) and "{{" in integration:
4857
integration = evaluate_expression(integration, context)
4958

50-
# Resolve model
51-
model = config.get("model") or context.default_model
59+
# Resolve model (same fallback rationale as 'integration' above).
60+
model = config.get("model")
61+
if model is None or model == "":
62+
model = context.default_model
5263
if model and isinstance(model, str) and "{{" in model:
5364
model = evaluate_expression(model, context)
5465

66+
# A non-string integration/model — a literal list/dict/number that
67+
# skipped validation, an unvalidated workflow-level default, or an
68+
# expression that resolved to one — crashes downstream: get_integration()
69+
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
70+
# even on a *validated* run) and build_exec_args() feeds model into the
71+
# CLI argv. Fail the step with the contract error rather than taking down
72+
# the whole run. ``None`` stays valid — it means "unset" and falls back
73+
# to dispatch-not-possible.
74+
if integration is not None and not isinstance(integration, str):
75+
return StepResult(
76+
status=StepStatus.FAILED,
77+
error=(
78+
f"Prompt step {config.get('id', '?')!r}: 'integration' must "
79+
f"be a string, got {type(integration).__name__}."
80+
),
81+
)
82+
if model is not None and not isinstance(model, str):
83+
return StepResult(
84+
status=StepStatus.FAILED,
85+
error=(
86+
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
87+
f"string, got {type(model).__name__}."
88+
),
89+
)
90+
5591
# Attempt CLI dispatch
5692
dispatch_result = self._try_dispatch(
5793
prompt, integration, model, context
@@ -172,4 +208,23 @@ def validate(self, config: dict[str, Any]) -> list[str]:
172208
f"Prompt step {config.get('id', '?')!r}: 'prompt' must be a "
173209
f"string, got {type(config['prompt']).__name__}."
174210
)
211+
# execute() passes 'integration' to get_integration(), which uses it as a
212+
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
213+
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
214+
# literal non-string here, mirroring the 'prompt' check above. ``None``
215+
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
216+
# workflow default" and stays valid; an expression like "{{ ... }}" is
217+
# still a str, so it stays valid too.
218+
integration = config.get("integration")
219+
if integration is not None and not isinstance(integration, str):
220+
errors.append(
221+
f"Prompt step {config.get('id', '?')!r}: 'integration' must be a "
222+
f"string, got {type(integration).__name__}."
223+
)
224+
model = config.get("model")
225+
if model is not None and not isinstance(model, str):
226+
errors.append(
227+
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
228+
f"string, got {type(model).__name__}."
229+
)
175230
return errors

tests/test_workflows.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,41 @@ def test_validate_rejects_non_mapping_input_and_options(self):
10261026
assert res_opt.status is StepStatus.FAILED
10271027
assert "'options' must be a mapping" in (res_opt.error or "")
10281028

1029+
@pytest.mark.parametrize("bad", [["claude"], {"a": 1}, 5, True])
1030+
def test_validate_rejects_non_string_integration_and_model(self, bad):
1031+
"""A non-string 'integration'/'model' must be rejected at validation.
1032+
1033+
execute() passes 'integration' to get_integration(), which uses it as a
1034+
dict key — an unhashable list/dict raises a raw TypeError there, even on
1035+
a validated run — and feeds 'model' into the CLI argv. Mirrors the
1036+
'command'/'input'/'options' type checks.
1037+
"""
1038+
from specify_cli.workflows.steps.command import CommandStep
1039+
1040+
step = CommandStep()
1041+
errs = step.validate({"id": "c", "command": "/x", "integration": bad})
1042+
assert any("'integration' must be a string" in e for e in errs), bad
1043+
errs = step.validate({"id": "c", "command": "/x", "model": bad})
1044+
assert any("'model' must be a string" in e for e in errs), bad
1045+
1046+
def test_validate_accepts_none_and_expression_integration_model(self):
1047+
"""An explicit YAML-null (inherit default) or a '{{ ... }}' expression
1048+
integration/model stays valid — only literal non-strings are rejected."""
1049+
from specify_cli.workflows.steps.command import CommandStep
1050+
1051+
step = CommandStep()
1052+
assert step.validate(
1053+
{"id": "c", "command": "/x", "integration": None, "model": None}
1054+
) == []
1055+
assert step.validate(
1056+
{
1057+
"id": "c",
1058+
"command": "/x",
1059+
"integration": "{{ inputs.agent }}",
1060+
"model": "{{ inputs.model }}",
1061+
}
1062+
) == []
1063+
10291064
def test_validate_rejects_non_string_command(self):
10301065
from specify_cli.workflows.steps.command import CommandStep
10311066

@@ -1061,6 +1096,55 @@ def test_execute_non_string_command_fails_cleanly(self):
10611096
assert result.status is StepStatus.FAILED, bad
10621097
assert "'command' must be a string" in (result.error or ""), bad
10631098

1099+
def test_execute_non_string_integration_fails_loudly(self):
1100+
"""On an unvalidated run, an unhashable 'integration' would crash
1101+
get_integration() (dict.get on a list) with a raw TypeError. execute()
1102+
must fail the step with the contract error instead."""
1103+
from specify_cli.workflows.steps.command import CommandStep
1104+
from specify_cli.workflows.base import StepContext, StepStatus
1105+
1106+
step = CommandStep()
1107+
res = step.execute(
1108+
{"id": "c", "command": "speckit.specify", "integration": ["claude"]},
1109+
StepContext(),
1110+
)
1111+
assert res.status is StepStatus.FAILED
1112+
assert "'integration' must be a string" in (res.error or "")
1113+
# non-string model likewise fails before build_exec_args
1114+
res = step.execute(
1115+
{"id": "c", "command": "speckit.specify", "integration": "claude", "model": ["m"]},
1116+
StepContext(),
1117+
)
1118+
assert res.status is StepStatus.FAILED
1119+
assert "'model' must be a string" in (res.error or "")
1120+
1121+
@pytest.mark.parametrize("falsey", [[], {}, 0, False])
1122+
def test_execute_falsey_non_string_integration_fails_loudly(self, falsey):
1123+
"""A *falsey* non-string ([], {}, 0, False) must fail the step, not be
1124+
swallowed by an ``or``-fallback to the workflow default.
1125+
1126+
A ``config.get('integration') or context.default_integration`` coerces a
1127+
falsey non-string to the default *before* the type guard runs, so with a
1128+
configured default the step would silently dispatch using the wrong
1129+
integration instead of surfacing the contract error. The default is set
1130+
here so a regression dispatches rather than fails-not-possible."""
1131+
from specify_cli.workflows.steps.command import CommandStep
1132+
from specify_cli.workflows.base import StepContext, StepStatus
1133+
1134+
step = CommandStep()
1135+
ctx = StepContext(default_integration="claude", default_model="sonnet")
1136+
res = step.execute(
1137+
{"id": "c", "command": "speckit.specify", "integration": falsey}, ctx
1138+
)
1139+
assert res.status is StepStatus.FAILED, falsey
1140+
assert "'integration' must be a string" in (res.error or ""), falsey
1141+
# a falsey non-string model likewise reaches the guard
1142+
res = step.execute(
1143+
{"id": "c", "command": "speckit.specify", "model": falsey}, ctx
1144+
)
1145+
assert res.status is StepStatus.FAILED, falsey
1146+
assert "'model' must be a string" in (res.error or ""), falsey
1147+
10641148
def test_step_override_integration(self):
10651149
from unittest.mock import patch
10661150
from specify_cli.workflows.steps.command import CommandStep
@@ -1448,6 +1532,82 @@ def test_validate_accepts_expression_prompt(self):
14481532
)
14491533
assert errors == []
14501534

1535+
@pytest.mark.parametrize("bad", [["claude"], {"a": 1}, 5, True])
1536+
def test_validate_rejects_non_string_integration_and_model(self, bad):
1537+
"""A non-string 'integration'/'model' must be rejected at validation.
1538+
1539+
execute() passes 'integration' to get_integration(), which uses it as a
1540+
dict key — an unhashable list/dict raises a raw TypeError there, even on
1541+
a validated run — and feeds 'model' into the CLI argv."""
1542+
from specify_cli.workflows.steps.prompt import PromptStep
1543+
1544+
step = PromptStep()
1545+
errs = step.validate({"id": "p", "prompt": "hi", "integration": bad})
1546+
assert any("'integration' must be a string" in e for e in errs), bad
1547+
errs = step.validate({"id": "p", "prompt": "hi", "model": bad})
1548+
assert any("'model' must be a string" in e for e in errs), bad
1549+
1550+
def test_validate_accepts_none_and_expression_integration_model(self):
1551+
"""An explicit YAML-null (inherit default) or a '{{ ... }}' expression
1552+
integration/model stays valid — only literal non-strings are rejected."""
1553+
from specify_cli.workflows.steps.prompt import PromptStep
1554+
1555+
step = PromptStep()
1556+
assert step.validate(
1557+
{"id": "p", "prompt": "hi", "integration": None, "model": None}
1558+
) == []
1559+
assert step.validate(
1560+
{
1561+
"id": "p",
1562+
"prompt": "hi",
1563+
"integration": "{{ inputs.agent }}",
1564+
"model": "{{ inputs.model }}",
1565+
}
1566+
) == []
1567+
1568+
def test_execute_non_string_integration_fails_loudly(self):
1569+
"""On an unvalidated run, an unhashable 'integration' would crash
1570+
get_integration() (dict.get on a dict) with a raw TypeError. execute()
1571+
must fail the step with the contract error instead."""
1572+
from specify_cli.workflows.steps.prompt import PromptStep
1573+
from specify_cli.workflows.base import StepContext, StepStatus
1574+
1575+
step = PromptStep()
1576+
res = step.execute(
1577+
{"id": "p", "prompt": "hi", "integration": {"a": 1}}, StepContext()
1578+
)
1579+
assert res.status is StepStatus.FAILED
1580+
assert "'integration' must be a string" in (res.error or "")
1581+
res = step.execute(
1582+
{"id": "p", "prompt": "hi", "integration": "claude", "model": ["m"]},
1583+
StepContext(),
1584+
)
1585+
assert res.status is StepStatus.FAILED
1586+
assert "'model' must be a string" in (res.error or "")
1587+
1588+
@pytest.mark.parametrize("falsey", [[], {}, 0, False])
1589+
def test_execute_falsey_non_string_integration_fails_loudly(self, falsey):
1590+
"""A *falsey* non-string ([], {}, 0, False) must fail the step, not be
1591+
swallowed by an ``or``-fallback to the workflow default.
1592+
1593+
A ``config.get('integration') or context.default_integration`` coerces a
1594+
falsey non-string to the default *before* the type guard runs, so with a
1595+
configured default the step would silently dispatch using the wrong
1596+
integration instead of surfacing the contract error. The default is set
1597+
here so a regression dispatches rather than fails-not-possible."""
1598+
from specify_cli.workflows.steps.prompt import PromptStep
1599+
from specify_cli.workflows.base import StepContext, StepStatus
1600+
1601+
step = PromptStep()
1602+
ctx = StepContext(default_integration="claude", default_model="sonnet")
1603+
res = step.execute({"id": "p", "prompt": "hi", "integration": falsey}, ctx)
1604+
assert res.status is StepStatus.FAILED, falsey
1605+
assert "'integration' must be a string" in (res.error or ""), falsey
1606+
# a falsey non-string model likewise reaches the guard
1607+
res = step.execute({"id": "p", "prompt": "hi", "model": falsey}, ctx)
1608+
assert res.status is StepStatus.FAILED, falsey
1609+
assert "'model' must be a string" in (res.error or ""), falsey
1610+
14511611

14521612
class TestShellStep:
14531613
"""Test the shell step type."""

0 commit comments

Comments
 (0)