mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
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>
This commit is contained in:
@@ -66,16 +66,52 @@ class CommandStep(StepBase):
|
||||
for key, value in input_data.items():
|
||||
resolved_input[key] = evaluate_expression(value, context)
|
||||
|
||||
# Resolve integration (step → workflow default → project default)
|
||||
integration = config.get("integration") or context.default_integration
|
||||
# Resolve integration (step → workflow default → project default).
|
||||
# Fall back to the workflow default ONLY for a genuinely-unset value
|
||||
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
|
||||
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
|
||||
# it to the default before the guard below runs — so on an unvalidated
|
||||
# execute() such a step would silently dispatch with the configured
|
||||
# default instead of failing. Fall through instead, so every non-string
|
||||
# reaches the type guard.
|
||||
integration = config.get("integration")
|
||||
if integration is None or integration == "":
|
||||
integration = context.default_integration
|
||||
if integration and isinstance(integration, str) and "{{" in integration:
|
||||
integration = evaluate_expression(integration, context)
|
||||
|
||||
# Resolve model
|
||||
model = config.get("model") or context.default_model
|
||||
# Resolve model (same fallback rationale as 'integration' above).
|
||||
model = config.get("model")
|
||||
if model is None or model == "":
|
||||
model = context.default_model
|
||||
if model and isinstance(model, str) and "{{" in model:
|
||||
model = evaluate_expression(model, context)
|
||||
|
||||
# A non-string integration/model — a literal list/dict/number that
|
||||
# skipped validation, an unvalidated workflow-level default, or an
|
||||
# expression that resolved to one — crashes downstream: get_integration()
|
||||
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
|
||||
# even on a *validated* run) and build_exec_args() feeds model into the
|
||||
# CLI argv. Fail the step with the contract error rather than taking down
|
||||
# the whole run, mirroring the 'input'/'options' guards above. ``None``
|
||||
# stays valid — it means "unset" and falls back to dispatch-not-possible.
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'integration' must "
|
||||
f"be a string, got {type(integration).__name__}."
|
||||
),
|
||||
)
|
||||
if model is not None and not isinstance(model, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Merge options (workflow defaults ← step overrides)
|
||||
options = dict(context.default_options)
|
||||
step_options = config.get("options", {})
|
||||
@@ -217,4 +253,23 @@ class CommandStep(StepBase):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
|
||||
)
|
||||
# execute() passes 'integration' to get_integration(), which uses it as a
|
||||
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
|
||||
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
|
||||
# literal non-string here, mirroring the sibling type checks. ``None``
|
||||
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
|
||||
# workflow default" and stays valid; an expression like "{{ ... }}" is
|
||||
# still a str, so it stays valid too.
|
||||
integration = config.get("integration")
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'integration' must be a "
|
||||
f"string, got {type(integration).__name__}."
|
||||
)
|
||||
model = config.get("model")
|
||||
if model is not None and not isinstance(model, str):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -42,16 +42,52 @@ class PromptStep(StepBase):
|
||||
if not isinstance(prompt, str):
|
||||
prompt = str(prompt)
|
||||
|
||||
# Resolve integration (step → workflow default)
|
||||
integration = config.get("integration") or context.default_integration
|
||||
# Resolve integration (step → workflow default).
|
||||
# Fall back to the workflow default ONLY for a genuinely-unset value
|
||||
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
|
||||
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
|
||||
# it to the default before the guard below runs — so on an unvalidated
|
||||
# execute() such a step would silently dispatch with the configured
|
||||
# default instead of failing. Fall through instead, so every non-string
|
||||
# reaches the type guard.
|
||||
integration = config.get("integration")
|
||||
if integration is None or integration == "":
|
||||
integration = context.default_integration
|
||||
if integration and isinstance(integration, str) and "{{" in integration:
|
||||
integration = evaluate_expression(integration, context)
|
||||
|
||||
# Resolve model
|
||||
model = config.get("model") or context.default_model
|
||||
# Resolve model (same fallback rationale as 'integration' above).
|
||||
model = config.get("model")
|
||||
if model is None or model == "":
|
||||
model = context.default_model
|
||||
if model and isinstance(model, str) and "{{" in model:
|
||||
model = evaluate_expression(model, context)
|
||||
|
||||
# A non-string integration/model — a literal list/dict/number that
|
||||
# skipped validation, an unvalidated workflow-level default, or an
|
||||
# expression that resolved to one — crashes downstream: get_integration()
|
||||
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
|
||||
# even on a *validated* run) and build_exec_args() feeds model into the
|
||||
# CLI argv. Fail the step with the contract error rather than taking down
|
||||
# the whole run. ``None`` stays valid — it means "unset" and falls back
|
||||
# to dispatch-not-possible.
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'integration' must "
|
||||
f"be a string, got {type(integration).__name__}."
|
||||
),
|
||||
)
|
||||
if model is not None and not isinstance(model, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
dispatch_result = self._try_dispatch(
|
||||
prompt, integration, model, context
|
||||
@@ -172,4 +208,23 @@ class PromptStep(StepBase):
|
||||
f"Prompt step {config.get('id', '?')!r}: 'prompt' must be a "
|
||||
f"string, got {type(config['prompt']).__name__}."
|
||||
)
|
||||
# execute() passes 'integration' to get_integration(), which uses it as a
|
||||
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
|
||||
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
|
||||
# literal non-string here, mirroring the 'prompt' check above. ``None``
|
||||
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
|
||||
# workflow default" and stays valid; an expression like "{{ ... }}" is
|
||||
# still a str, so it stays valid too.
|
||||
integration = config.get("integration")
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
errors.append(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'integration' must be a "
|
||||
f"string, got {type(integration).__name__}."
|
||||
)
|
||||
model = config.get("model")
|
||||
if model is not None and not isinstance(model, str):
|
||||
errors.append(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
)
|
||||
return errors
|
||||
|
||||
Reference in New Issue
Block a user