fix(workflows): validate non-mapping input in command step#3495
fix(workflows): validate non-mapping input in command step#3495thejesh23 wants to merge 3 commits into
input in command step#3495Conversation
…3492) The `command` step's `execute()` iterates `input.items()` to resolve template expressions, but `validate()` did not check the type. A workflow with `input: null` (or `input: [1, 2]`, `input: "foo"`) passed validation and then crashed at runtime with a bare `AttributeError: 'NoneType' object has no attribute 'items'`. Follow the pattern already applied by the shell / while-loop / do-while validators: reject the non-mapping value in `validate()` with a message that names the field, and defensively coerce it to `{}` in `execute()` so a caller that skips `WorkflowEngine.validate()` fails cleanly rather than raising an internal `AttributeError`. Fixes github#3492
There was a problem hiding this comment.
Pull request overview
Adds type validation for command-step input mappings and regression coverage.
Changes:
- Rejects non-mapping
inputduring validation. - Adds defensive execution handling and tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/specify_cli/workflows/steps/command/__init__.py |
Validates and guards command input. |
tests/test_workflows.py |
Adds non-mapping input regression tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not isinstance(input_data, dict): | ||
| input_data = {} |
There was a problem hiding this comment.
Addressed in df551e1.
execute() now returns a FAILED StepResult (with dispatched: false, exit_code: 1, and a field-specific 'input' must be a mapping' error) before _try_dispatch is ever called. The silent-coerce-and-continue path is gone.
The regression test was also rewritten to patch _try_dispatch itself (via patch.object(CommandStep, "_try_dispatch")) instead of shutil.which. It asserts mock_dispatch.assert_not_called() and that the surfaced error contains 'input' must be a mapping, so it now specifically verifies the guard rather than passing on the incidental "CLI not found" branch.
Verified the test catches regressions: temporarily reintroducing the silent-coerce version made the new test fail with Expected '_try_dispatch' to not have been called. Called 1 times.
Full suite: 414 passed.
| with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): | ||
| result = step.execute( | ||
| {"id": "t", "command": "/speckit.specify", "input": None}, | ||
| ctx, | ||
| ) | ||
| assert result.status == StepStatus.FAILED | ||
| assert result.output["input"] == {} |
There was a problem hiding this comment.
Addressed in df551e1.
execute() now returns a FAILED StepResult (with dispatched: false, exit_code: 1, and a field-specific 'input' must be a mapping' error) before _try_dispatch is ever called. The silent-coerce-and-continue path is gone.
The regression test was also rewritten to patch _try_dispatch itself (via patch.object(CommandStep, "_try_dispatch")) instead of shutil.which. It asserts mock_dispatch.assert_not_called() and that the surfaced error contains 'input' must be a mapping, so it now specifically verifies the guard rather than passing on the incidental "CLI not found" branch.
Verified the test catches regressions: temporarily reintroducing the silent-coerce version made the new test fail with Expected '_try_dispatch' to not have been called. Called 1 times.
Full suite: 414 passed.
| if not isinstance(input_data, dict): | ||
| input_data = {} |
There was a problem hiding this comment.
Addressed in df551e1.
execute() now returns a FAILED StepResult (with dispatched: false, exit_code: 1, and a field-specific 'input' must be a mapping' error) before _try_dispatch is ever called. The silent-coerce-and-continue path is gone.
The regression test was also rewritten to patch _try_dispatch itself (via patch.object(CommandStep, "_try_dispatch")) instead of shutil.which. It asserts mock_dispatch.assert_not_called() and that the surfaced error contains 'input' must be a mapping, so it now specifically verifies the guard rather than passing on the incidental "CLI not found" branch.
Verified the test catches regressions: temporarily reintroducing the silent-coerce version made the new test fail with Expected '_try_dispatch' to not have been called. Called 1 times.
Full suite: 414 passed.
Copilot flagged that the previous defensive coercion (`if not isinstance(
input_data, dict): input_data = {}`) silently continued into
`_try_dispatch`. When the integration CLI is installed, that path could
run the command with empty arguments and return `COMPLETED`, hiding the
misconfiguration and possibly executing an unintended external command.
Return a `FAILED` `StepResult` with a field-specific error before ever
calling `_try_dispatch`. Also rewrite the regression test to patch
`_try_dispatch` itself (not `shutil.which`) so it verifies dispatch is
never attempted and that the surfaced error is the mapping-check error,
rather than passing on the incidental "CLI not found" branch.
Verified: dropping the guard now causes the new test to fail with
"Expected '_try_dispatch' to not have been called. Called 1 times."
Full targeted suite: `.venv/bin/python -m pytest tests/test_workflows.py`
414 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@copilot resolve the merge conflicts in this pull request |
…t-validation # Conflicts: # src/specify_cli/workflows/steps/command/__init__.py # tests/test_workflows.py
|
Closing as superseded by #3262, which merged into After merging Thanks to @jawwad-ali for the more comprehensive fix in #3262. |
Fixes #3492.
What
CommandStep.execute()iteratesinput.items()to resolve template expressions, butCommandStep.validate()did not check the type. A workflow withinput: null(which is what YAML parsesinput:to when no value follows),input: [1, 2], orinput: "foo"passed validation and then crashed at run time with a bare:Fix
Mirror the pattern already applied in the shell / while-loop / do-while validators:
validate()rejects a non-mappinginputwith a message that names the field, so the failure surfaces at load time.execute()defensively coerces a non-mappinginputto{}so a caller that bypassesWorkflowEngine.validate()(e.g. ad-hocstep.execute(...)) still fails cleanly rather than raising an internalAttributeError.Tests
Two new regression tests in
TestCommandStep:test_validate_rejects_non_mapping_input—None, list, string all report a clear'input' must be a mappingerror.test_execute_non_mapping_input_does_not_crash— the validate-bypass path returnsStepStatus.FAILEDwithoutput["input"] == {}instead of raising.Full targeted run:
Manual test results
Agent: N/A — this is a workflow-engine-only fix; no slash-command surface changed. | OS/Shell: macOS 15 / zsh
/speckit.specifyTestCommandStep::test_execute_basicstill passes.Per CONTRIBUTING.md's mapping rules, this change only touches
src/specify_cli/workflows/steps/command/__init__.py(a step-type module inside the engine, not a slash-command template). Notemplates/commands/*file, noscripts/bash|powershell/*file, and nosrc/specify_cli/*CLI entry point is modified, so no slash command's behavior changes.AI disclosure
Per
CONTRIBUTING.md#ai-contributions-in-spec-kit: this PR (bug identification, patch, and tests) was prepared with Claude (Anthropic) assistance in an autonomous agent session. The reproducer was executed against a fresh clone ofmain, the fix was written to match the existing type-validation pattern in sibling step validators, and the full workflow test suite (tests/test_workflows.py, 414 tests) was run green before submission. The change is intentionally minimal — one guard inexecute(), one check invalidate(), two focused tests.🤖 Generated with Claude Code