From a9c990540099087e215b5d4ce8f1d3448169dcac Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:40:36 +0500 Subject: [PATCH] fix(workflows): reject non-string/non-boolean 'condition' in if/while/do-while steps (#3706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workflows): reject non-string 'condition' in if/while/do-while steps `if_then`, `while_loop`, and `do_while` validate() confirm `condition` is present but never that it is a string. execute() feeds it to `evaluate_condition()`, which returns a non-string as-is and takes `bool()` of it -- so `condition: [1, 2]` (a list authoring mistake) silently resolves to `True`, branching wrongly / spinning the loop to `max_iterations`, with no error reported. Reject a present-but-non-string `condition` at validation, mirroring the existing prompt/shell/command 'must be a string' guards. `"true"`/`"false"` and expressions like `"{{ ... }}"` are strings, so they stay valid. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) * docs(workflows): describe the evaluate_expression/evaluate_condition split accurately Address review feedback: the guard comments attributed the non-string pass-through to evaluate_condition(), which always returns a bool. It is evaluate_expression() (called by evaluate_condition) that returns a non-string unchanged; evaluate_condition then applies bool() to that value. Reword all four sites (if/while/do-while guards + the mirror test comment) to name the two stages correctly. Comments only -- no behaviour change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) * fix(workflows): keep a literal bool 'condition' valid Self-review catch: the guard rejected EVERY non-string, which broke an input that previously worked. An unquoted ``condition: false`` is idiomatic YAML and resolves exactly today -- evaluate_expression passes the bool through and evaluate_condition's bool() is a no-op (verified: evaluate_condition(False) is False, (True) is True). The if/while steps even default ``condition`` to the bool ``False`` themselves, so bool is the field's natural type, not an authoring mistake. Accept (str, bool) and reject only the genuinely silent-coercion types (list/dict/int/float, e.g. condition: [1, 2] is always True). Message updated to "must be a string or boolean"; the bad-value tests drop True and gain 1.5, and each step gains a positive bool case. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../workflows/steps/do_while/__init__.py | 18 ++++++ .../workflows/steps/if_then/__init__.py | 18 ++++++ .../workflows/steps/while_loop/__init__.py | 18 ++++++ tests/test_workflows.py | 63 +++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py index ca6047a57..024ced55b 100644 --- a/src/specify_cli/workflows/steps/do_while/__init__.py +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -70,6 +70,24 @@ class DoWhileStep(StepBase): f"Do-while step {config.get('id', '?')!r} is missing " f"'condition' field." ) + elif not isinstance(config["condition"], (str, bool)): + # The engine re-evaluates 'condition' via evaluate_condition() after + # each iteration. That call first delegates to + # evaluate_expression() -- which returns a non-string unchanged -- + # and then coerces the result with bool(). So a list/dict/number + # condition silently resolves to its truthiness (e.g. + # condition: [1, 2] is always truthy, looping to max_iterations) + # with no error. Reject those at validation, mirroring the + # prompt/shell/command 'must be a string' checks. + # + # A literal ``bool`` stays valid: an unquoted ``condition: false`` + # is idiomatic YAML and evaluate_condition() already resolves it + # exactly (bool passthrough, then a no-op bool()). "true"/"false" + # and an expression like "{{ ... }}" stay valid too. + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'condition' must be a " + f"string or boolean, got {type(config['condition']).__name__}." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py index b2ed88067..7189ff815 100644 --- a/src/specify_cli/workflows/steps/if_then/__init__.py +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -61,6 +61,24 @@ class IfThenStep(StepBase): errors.append( f"If step {config.get('id', '?')!r} is missing 'condition' field." ) + elif not isinstance(config["condition"], (str, bool)): + # execute() feeds 'condition' to evaluate_condition(), which first + # delegates to evaluate_expression() -- that returns a non-string + # unchanged -- and then coerces the result with bool(). So a + # list/dict/number condition silently resolves to its truthiness + # (e.g. condition: [1, 2] is always True) with no error, branching + # wrongly on an authoring mistake. Reject those at validation, + # mirroring the prompt/shell/command 'must be a string' checks. + # + # A literal ``bool`` stays valid: an unquoted ``condition: false`` + # is idiomatic YAML, evaluate_condition() already resolves it + # exactly (bool passthrough, then a no-op bool()), and this step + # itself defaults ``condition`` to ``False``. "true"/"false" and an + # expression like "{{ ... }}" are strings, so they stay valid too. + errors.append( + f"If step {config.get('id', '?')!r}: 'condition' must be a " + f"string or boolean, got {type(config['condition']).__name__}." + ) if "then" not in config: errors.append( f"If step {config.get('id', '?')!r} is missing 'then' field." diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py index e2dbb1930..e80b93d7f 100644 --- a/src/specify_cli/workflows/steps/while_loop/__init__.py +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -79,6 +79,24 @@ class WhileStep(StepBase): f"While step {config.get('id', '?')!r} is missing " f"'condition' field." ) + elif not isinstance(config["condition"], (str, bool)): + # execute() feeds 'condition' to evaluate_condition(), which first + # delegates to evaluate_expression() -- that returns a non-string + # unchanged -- and then coerces the result with bool(). So a + # list/dict/number condition silently resolves to its truthiness + # (e.g. condition: [1, 2] is always truthy, spinning the loop to + # max_iterations) with no error. Reject those at validation, + # mirroring the prompt/shell/command 'must be a string' checks. + # + # A literal ``bool`` stays valid: an unquoted ``condition: false`` + # is idiomatic YAML, evaluate_condition() already resolves it + # exactly (bool passthrough, then a no-op bool()), and this step + # itself defaults ``condition`` to ``False``. "true"/"false" and an + # expression like "{{ ... }}" are strings, so they stay valid too. + errors.append( + f"While step {config.get('id', '?')!r}: 'condition' must be a " + f"string or boolean, got {type(config['condition']).__name__}." + ) max_iter = config.get("max_iterations") if max_iter is not None: # bool is a subclass of int, so isinstance(True, int) is True and diff --git a/tests/test_workflows.py b/tests/test_workflows.py index e147f6dd7..9d02b3a6f 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2490,6 +2490,33 @@ class TestIfThenStep: errors = step.validate({"id": "test", "then": []}) assert any("missing 'condition'" in e for e in errors) + @pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5]) + def test_validate_rejects_non_string_condition(self, bad): + # A list/dict/number condition is returned unchanged by + # evaluate_expression, and evaluate_condition then bool()-coerces it, so + # it silently resolves to its truthiness (e.g. [1, 2] is always True) + # instead of erroring on the authoring mistake. + from specify_cli.workflows.steps.if_then import IfThenStep + + step = IfThenStep() + errors = step.validate({"id": "test", "condition": bad, "then": []}) + assert any("'condition' must be a" in e for e in errors), bad + + @pytest.mark.parametrize( + "good", + [ + "true", "false", "{{ inputs.flag }}", + True, False, # unquoted YAML bool: resolved exactly, and it is the + # default this step itself uses -- must stay valid + ], + ) + def test_validate_accepts_string_or_bool_condition(self, good): + from specify_cli.workflows.steps.if_then import IfThenStep + + step = IfThenStep() + errors = step.validate({"id": "test", "condition": good, "then": []}) + assert not any("'condition' must be a" in e for e in errors), good + @pytest.mark.parametrize("bad_branch", [{"id": "x"}, "oops", 5]) def test_execute_non_list_then_fails_loudly(self, bad_branch): """A non-list ``then`` must fail the step, not crash the run. @@ -2880,6 +2907,24 @@ class TestWhileStep: assert any("missing 'condition'" in e for e in errors) # max_iterations is optional (defaults to 10) + @pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5]) + def test_validate_rejects_non_string_condition(self, bad): + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + errors = step.validate({"id": "test", "condition": bad, "steps": []}) + assert any("'condition' must be a" in e for e in errors), bad + + @pytest.mark.parametrize("good", [True, False, "true", "{{ inputs.go }}"]) + def test_validate_accepts_string_or_bool_condition(self, good): + # ``condition: false`` unquoted is idiomatic YAML and is this step's own + # default, so a literal bool must not be rejected. + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + errors = step.validate({"id": "test", "condition": good, "steps": []}) + assert not any("'condition' must be a" in e for e in errors), good + def test_validate_invalid_max_iterations(self): from specify_cli.workflows.steps.while_loop import WhileStep @@ -2994,6 +3039,24 @@ class TestDoWhileStep: assert any("missing 'condition'" in e for e in errors) # max_iterations is optional (defaults to 10) + @pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5]) + def test_validate_rejects_non_string_condition(self, bad): + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({"id": "test", "condition": bad, "steps": []}) + assert any("'condition' must be a" in e for e in errors), bad + + @pytest.mark.parametrize("good", [True, False, "true", "{{ inputs.go }}"]) + def test_validate_accepts_string_or_bool_condition(self, good): + # ``condition: false`` unquoted is idiomatic YAML; evaluate_condition + # resolves a literal bool exactly, so it must not be rejected. + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({"id": "test", "condition": good, "steps": []}) + assert not any("'condition' must be a" in e for e in errors), good + def test_validate_steps_not_list(self): from specify_cli.workflows.steps.do_while import DoWhileStep