fix(workflows): reject non-string/non-boolean 'condition' in if/while/do-while steps - #3706
Conversation
There was a problem hiding this comment.
Pull request overview
Adds validation to prevent non-string workflow conditions from being silently truthiness-coerced.
Changes:
- Validates conditions for if, while, and do-while steps.
- Adds parameterized regression tests for invalid condition types.
Show a summary per file
| File | Description |
|---|---|
if_then/__init__.py |
Rejects non-string if conditions. |
while_loop/__init__.py |
Rejects non-string while conditions. |
do_while/__init__.py |
Rejects non-string do-while conditions. |
tests/test_workflows.py |
Covers invalid and valid condition values. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 4/4 changed files
- Comments generated: 4
- Review effort level: Medium
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
|
Good catch — fixed in 6a78c02. You're right that
Comments only; no behaviour change. |
|
Please address Copilot feedback |
|
Thanks — I took the second option you offered ("update the PR/API contract if boolean literals are intentionally supported"), because rejecting Accepting booleans is deliberate. Verified against So I've updated the title and description to state the contract accurately ("non-string/non-boolean") and documented why booleans are accepted, and the bad-value matrix is now |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (3)
tests/test_workflows.py:2494
- This name says every non-string condition is rejected, but the adjacent contract explicitly accepts non-string booleans. Rename it to describe unsupported condition types so test reports do not contradict the behavior under test.
def test_validate_rejects_non_string_condition(self, bad):
tests/test_workflows.py:3043
- This name says every non-string condition is rejected, but the adjacent contract explicitly accepts non-string booleans. Rename it to describe unsupported condition types so test reports do not contradict the behavior under test.
def test_validate_rejects_non_string_condition(self, bad):
tests/test_workflows.py:2911
- This name says every non-string condition is rejected, but the adjacent contract explicitly accepts non-string booleans. Rename it to describe unsupported condition types so test reports do not contradict the behavior under test.
This issue also appears on line 3043 of the same file.
def test_validate_rejects_non_string_condition(self, bad):
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Medium
`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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
7bca519 to
ce6d3ac
Compare
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (3)
tests/test_workflows.py:2494
- This name says every non-string value is rejected, but the adjacent contract test verifies that booleans are accepted. Rename it to describe the actual non-string/non-boolean boundary so test reports do not contradict the behavior.
def test_validate_rejects_non_string_condition(self, bad):
tests/test_workflows.py:3043
- This name says every non-string value is rejected, but the adjacent contract test verifies that booleans are accepted. Rename it to describe the actual non-string/non-boolean boundary so test reports do not contradict the behavior.
def test_validate_rejects_non_string_condition(self, bad):
tests/test_workflows.py:2911
- This name says every non-string value is rejected, but the adjacent contract test verifies that booleans are accepted. Rename it to describe the actual non-string/non-boolean boundary so test reports do not contradict the behavior.
This issue also appears on line 3043 of the same file.
def test_validate_rejects_non_string_condition(self, bad):
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Medium
|
Addressed the latest Copilot review (the three suppressed low-confidence notes on It was right, and it was my leftover. When I updated the contract to accept booleans I renamed the acceptance test to Renamed in all three step classes (if/while/do-while): That now matches the validator's own message, Test names only — no behaviour change, and the parametrized values are untouched ( Verification: 38 condition tests pass · @mnriem this should clear the outstanding Copilot feedback on this PR. To restate the one earlier point I didn't action, so it's not lost: Copilot asked for |
|
Correction to my comment above: I posted it without realising this PR had already been merged (a9c9905, ~3 minutes earlier), so the commit I referenced never became part of it. Thanks for the merge, @mnriem. The Copilot note it addressed is still valid against Everything else in my comment above still stands — in particular the reason booleans stay valid rather than being rejected. |
Problem
IfThenStep,WhileStep, andDoWhileStepvalidate()confirm thatconditionis present but never check its type.At run time
execute()feedsconditiontoevaluate_condition(), which first delegates toevaluate_expression()— that returns a non-string unchanged — and then coerces the result withbool():So a list/dict/number condition silently resolves to its truthiness instead of erroring on the authoring mistake:
condition: [1, 2]→bool([1, 2])→ always True → the if-branch is always taken / the while-loop spins tomax_iterationscondition: {}→ always False, etc.No error is reported, so the mistake is invisible.
Fix
Reject a present-but-non-
str/non-boolconditionin each of the three steps'validate(), mirroring the existingprompt/shell/commandtype checks.Why booleans are accepted (contract note)
An earlier revision of this PR rejected every non-string, including
bool. That was wrong — it would have been a breaking change, so the validator now accepts(str, bool):condition: falseis idiomatic YAML, and it already resolves correctly today:evaluate_expressionpasses the bool through andevaluate_condition'sbool()is a no-op. Verified onmain:evaluate_condition(False)→False,evaluate_condition(True)→True, and end-to-endcondition: falsetakes the else-branch whilecondition: truedispatchesthen.conditionto the boolFalsethemselves (config.get("condition", False)), soboolis the field's own natural type — not an authoring mistake.A bool is therefore not part of the silent-coercion bug this PR fixes; only
list/dict/int/floatare."true"/"false"and expressions like"{{ inputs.flag }}"are strings and stay valid, as before.Verification
[list, dict, int, float]) across all three step classes: fail before the guard, pass after.test_validate_accepts_string_or_bool_conditioncovers"true"/"false"/"{{ ... }}"and literalTrue/Falsein each step — the regression guard for the contract above.TestIfThenStep/TestWhileStep/TestDoWhileStepgreen;ruff@0.15.0clean.AI-assisted: authored with Claude Code. I traced the runtime path (
evaluate_expression→evaluate_condition→bool()), confirmed the silent coercion onmain, and confirmed that literal booleans already behave correctly there — which is why they are accepted rather than rejected.