fix(workflows): validate requires keys and reject phantom permissions gate (#3079)

* fix(workflows): validate requires keys and reject phantom permissions gate

A workflow's `requires` block was parsed but its keys were never
validated, so a typo or an unsupported key was silently ignored. Most
importantly, authors could write `requires.permissions.shell: true`
expecting a runtime capability gate — but no such gate exists: a `shell`
step always runs with the user's privileges. The declaration gave a
false sense of sandboxing.

`validate_workflow` now accepts only the recognised keys
(`speckit_version`, `integrations`, `tools`, `mcp`) and rejects anything
else, with an explicit error for `requires.permissions` pointing authors
to `gate` steps for approval. Docs and the model comment are updated to
state that `requires` is advisory, not a security boundary.

- Reject non-mapping `requires`, unknown keys, and `requires.permissions`
- Clarify workflows reference + PUBLISHING.md shell-step guidance
- Tests for valid keys, non-mapping, unknown key, and permissions

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Assisted-by: AI

* fix(workflows): address review feedback on requires validation

Follow-up to the review on #3079:

- Guard `requires` validation on `is not None` instead of truthiness so a
  falsy non-mapping value (e.g. `requires: []` or `requires: ''`) is
  reported as an error instead of being silently skipped; `requires:`
  (YAML null) is still treated as an omitted block. Add a regression test.
- Reword the workflows security note so `requires.permissions` is shown
  as rejected/unsupported rather than as a valid example of `requires`.
- Standardize on US spelling (`_RECOGNIZED_REQUIRES_KEYS`, "recognized")
  to match the surrounding code and ease searching.
- Tighten the permissions-rejection test to assert on specific message
  markers (`requires.permissions` and the `gate` guidance) so it fails if
  the validation path or wording drifts.

Assisted-by: AI
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>

* fix(workflows): scope requires validation to workflow keys (drop tools/mcp)

tools and mcp belong to the bundle manifest requires schema (bundler/models/manifest.py, resolved in bundler/services/resolver.py), not the workflow requires validated here. Drop them from _RECOGNIZED_REQUIRES_KEYS and revert the PUBLISHING.md claim that this PR had introduced, so workflow requires only recognizes speckit_version and integrations.

This keeps the existing docs accurate and resolves the inline doc-consistency review comments.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* refactor(workflows): type WorkflowDefinition.requires as Any pre-validation

self.requires holds the raw parsed value, which before validate_workflow()
runs may be a non-mapping (None for a bare 'requires:', a list for
'requires: []', etc.). Annotating it dict[str, Any] was misleading for
editors/type-checkers; use Any and document that validate_workflow() enforces
the mapping shape.

Addresses Copilot review feedback on engine.py.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* fix(workflows): reject YAML-null requires: as a non-mapping

Address Copilot review: validate requires the same way as inputs. A
bare requires: parses as YAML null and was previously treated as an
omitted block, which is inconsistent with inputs and lets a stray
requires: line be silently ignored.

Drop the is-not-None guard and check isinstance(..., dict) directly: an
omitted block still defaults to {} (valid), but a present-but-non-mapping
value -- YAML null, [] or '' -- is now an authoring error that surfaces.

Tests: add YAML-null rejection + an omitted-is-still-valid guard test.
Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

---------

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>
This commit is contained in:
Zied Jlassi
2026-06-24 21:49:43 +02:00
committed by GitHub
parent 37e0e71b4e
commit f846d6526c
4 changed files with 196 additions and 3 deletions

View File

@@ -2115,6 +2115,148 @@ steps:
errors = validate_workflow(definition)
assert any("invalid type" in e.lower() for e in errors)
def test_requires_with_recognized_keys_is_valid(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
speckit_version: ">=0.7.2"
integrations:
any: ["claude", "gemini"]
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert errors == []
def test_requires_must_be_mapping(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires: "claude"
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'requires' must be a mapping" in e for e in errors)
def test_requires_unknown_key_is_rejected(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
speckit_version: ">=0.7.2"
typo_key: true
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("typo_key" in e and "requires" in e for e in errors)
def test_requires_permissions_is_rejected_as_not_enforced(self):
"""A `requires.permissions` block looks like a runtime capability gate
but no such gate exists — shell steps always run with the user's
privileges. Reject it explicitly so authors are not misled into
believing the declaration sandboxes execution.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
permissions:
shell: true
steps:
- id: run
type: shell
run: "echo hi"
""")
errors = validate_workflow(definition)
# Assert on specific markers from the intended message (the offending
# key and the `gate` remediation) so the test fails if the validation
# path or wording drifts, rather than passing on any error that merely
# happens to contain "permissions" and "not".
assert any("requires.permissions" in e and "gate" in e for e in errors)
def test_requires_empty_sequence_is_rejected_as_non_mapping(self):
"""A non-mapping ``requires`` (e.g. an empty list) is an authoring
error. Mirroring ``inputs``, validation checks ``isinstance(..., dict)``
so ``requires: []`` surfaces instead of silently passing.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires: []
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'requires' must be a mapping" in e for e in errors)
def test_requires_yaml_null_is_rejected_as_non_mapping(self):
"""A bare ``requires:`` parses as YAML null. Like ``inputs``, a present
block must be a mapping, so YAML null is rejected as an authoring error
rather than being silently treated as an omitted block. (A truly
omitted ``requires`` defaults to ``{}`` and stays valid.)
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'requires' must be a mapping" in e for e in errors)
def test_requires_omitted_is_valid(self):
"""A workflow with no ``requires`` block at all defaults to ``{}`` and
must validate cleanly — only a present-but-non-mapping value is an
error (guards against over-correcting YAML-null rejection into also
flagging the omitted case).
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert not any("requires" in e for e in errors)
# ===== Workflow Engine Tests =====