Skip to content

Commit f846d65

Browse files
authored
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>
1 parent 37e0e71 commit f846d65

4 files changed

Lines changed: 196 additions & 3 deletions

File tree

docs/reference/workflows.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
270270
| `fan-out` | Dispatch a step for each item in a list |
271271
| `fan-in` | Aggregate results from a fan-out step |
272272

273+
> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands.
274+
273275
## Expressions
274276

275277
Steps can reference inputs and previous step outputs using `{{ expression }}` syntax:

src/specify_cli/workflows/engine.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,18 @@ def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> Non
5252
if not isinstance(self.default_options, dict):
5353
self.default_options = {}
5454

55-
# Requirements (declared but not yet enforced at runtime;
56-
# enforcement is a planned enhancement)
57-
self.requires: dict[str, Any] = data.get("requires", {})
55+
# Advisory pre-conditions (spec-kit version / integrations a workflow
56+
# expects). Validated by ``validate_workflow`` (recognized keys only;
57+
# see ``_RECOGNIZED_REQUIRES_KEYS``) but NOT enforced at run time — they
58+
# are not a security boundary. In particular there is no
59+
# ``requires.permissions`` capability gate: shell steps always run with
60+
# the user's privileges.
61+
#
62+
# Holds the raw parsed value, so before ``validate_workflow`` runs it may
63+
# be a non-mapping (``None`` for a bare ``requires:``, a list for
64+
# ``requires: []``, etc.); typed ``Any`` rather than ``dict[str, Any]``
65+
# to avoid implying it is always a mapping at this point.
66+
self.requires: Any = data.get("requires", {})
5867

5968
# Inputs
6069
self.inputs: dict[str, Any] = data.get("inputs", {})
@@ -87,6 +96,15 @@ def from_string(cls, content: str) -> WorkflowDefinition:
8796
# ID format: lowercase alphanumeric with hyphens
8897
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")
8998

99+
# Keys accepted under a workflow's ``requires`` block: the advisory
100+
# pre-conditions documented for workflows (``speckit_version`` and
101+
# ``integrations``). This is the *workflow* schema only — the bundle manifest's
102+
# ``requires`` (see ``bundler/models/manifest.py``) is a separate schema that
103+
# also carries ``tools``/``mcp``; those are not workflow ``requires`` keys.
104+
# Any other key — notably ``permissions`` — is rejected by ``validate_workflow``
105+
# so it is never mistaken for an enforced runtime control.
106+
_RECOGNIZED_REQUIRES_KEYS = frozenset({"speckit_version", "integrations"})
107+
90108
# Valid step types (matching STEP_REGISTRY keys)
91109
def _get_valid_step_types() -> set[str]:
92110
"""Return valid step types from the registry, with a built-in fallback."""
@@ -177,6 +195,36 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
177195
f"Input {input_name!r} has invalid default: {exc}"
178196
)
179197

198+
# -- Requires ---------------------------------------------------------
199+
# ``requires`` declares advisory pre-conditions (the spec-kit version and
200+
# integrations a workflow expects). Only a fixed set of keys is recognized;
201+
# reject anything else so authoring typos surface here instead of being
202+
# silently ignored at runtime. In particular ``requires.permissions`` is
203+
# rejected explicitly: it reads like a runtime capability gate, but no such
204+
# gate exists — a ``shell`` step always runs with the user's privileges, so
205+
# declaring it would give a false sense of sandboxing.
206+
#
207+
# Mirror ``inputs`` validation: an omitted block defaults to ``{}`` and is
208+
# valid, but any present-but-non-mapping value — ``requires:`` (YAML null),
209+
# ``requires: []`` or ``requires: ''`` — is an authoring error and must
210+
# surface here rather than be silently ignored at runtime.
211+
if not isinstance(definition.requires, dict):
212+
errors.append("'requires' must be a mapping (or omitted).")
213+
else:
214+
for key in definition.requires:
215+
if key == "permissions":
216+
errors.append(
217+
"'requires.permissions' is not a recognized or "
218+
"enforced capability gate — shell steps always run "
219+
"with the user's privileges. Remove it and gate "
220+
"sensitive steps with a 'gate' step instead."
221+
)
222+
elif key not in _RECOGNIZED_REQUIRES_KEYS:
223+
errors.append(
224+
f"Unknown 'requires' key {key!r}. Recognized keys: "
225+
f"{', '.join(sorted(_RECOGNIZED_REQUIRES_KEYS))}."
226+
)
227+
180228
# -- Steps ------------------------------------------------------------
181229
if not isinstance(definition.steps, list):
182230
errors.append("'steps' must be a list.")

tests/test_workflows.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2115,6 +2115,148 @@ def test_invalid_input_type(self):
21152115
errors = validate_workflow(definition)
21162116
assert any("invalid type" in e.lower() for e in errors)
21172117

2118+
def test_requires_with_recognized_keys_is_valid(self):
2119+
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
2120+
2121+
definition = WorkflowDefinition.from_string("""
2122+
workflow:
2123+
id: "test"
2124+
name: "Test"
2125+
version: "1.0.0"
2126+
requires:
2127+
speckit_version: ">=0.7.2"
2128+
integrations:
2129+
any: ["claude", "gemini"]
2130+
steps:
2131+
- id: step-one
2132+
command: speckit.specify
2133+
""")
2134+
errors = validate_workflow(definition)
2135+
assert errors == []
2136+
2137+
def test_requires_must_be_mapping(self):
2138+
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
2139+
2140+
definition = WorkflowDefinition.from_string("""
2141+
workflow:
2142+
id: "test"
2143+
name: "Test"
2144+
version: "1.0.0"
2145+
requires: "claude"
2146+
steps:
2147+
- id: step-one
2148+
command: speckit.specify
2149+
""")
2150+
errors = validate_workflow(definition)
2151+
assert any("'requires' must be a mapping" in e for e in errors)
2152+
2153+
def test_requires_unknown_key_is_rejected(self):
2154+
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
2155+
2156+
definition = WorkflowDefinition.from_string("""
2157+
workflow:
2158+
id: "test"
2159+
name: "Test"
2160+
version: "1.0.0"
2161+
requires:
2162+
speckit_version: ">=0.7.2"
2163+
typo_key: true
2164+
steps:
2165+
- id: step-one
2166+
command: speckit.specify
2167+
""")
2168+
errors = validate_workflow(definition)
2169+
assert any("typo_key" in e and "requires" in e for e in errors)
2170+
2171+
def test_requires_permissions_is_rejected_as_not_enforced(self):
2172+
"""A `requires.permissions` block looks like a runtime capability gate
2173+
but no such gate exists — shell steps always run with the user's
2174+
privileges. Reject it explicitly so authors are not misled into
2175+
believing the declaration sandboxes execution.
2176+
"""
2177+
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
2178+
2179+
definition = WorkflowDefinition.from_string("""
2180+
workflow:
2181+
id: "test"
2182+
name: "Test"
2183+
version: "1.0.0"
2184+
requires:
2185+
permissions:
2186+
shell: true
2187+
steps:
2188+
- id: run
2189+
type: shell
2190+
run: "echo hi"
2191+
""")
2192+
errors = validate_workflow(definition)
2193+
# Assert on specific markers from the intended message (the offending
2194+
# key and the `gate` remediation) so the test fails if the validation
2195+
# path or wording drifts, rather than passing on any error that merely
2196+
# happens to contain "permissions" and "not".
2197+
assert any("requires.permissions" in e and "gate" in e for e in errors)
2198+
2199+
def test_requires_empty_sequence_is_rejected_as_non_mapping(self):
2200+
"""A non-mapping ``requires`` (e.g. an empty list) is an authoring
2201+
error. Mirroring ``inputs``, validation checks ``isinstance(..., dict)``
2202+
so ``requires: []`` surfaces instead of silently passing.
2203+
"""
2204+
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
2205+
2206+
definition = WorkflowDefinition.from_string("""
2207+
workflow:
2208+
id: "test"
2209+
name: "Test"
2210+
version: "1.0.0"
2211+
requires: []
2212+
steps:
2213+
- id: step-one
2214+
command: speckit.specify
2215+
""")
2216+
errors = validate_workflow(definition)
2217+
assert any("'requires' must be a mapping" in e for e in errors)
2218+
2219+
def test_requires_yaml_null_is_rejected_as_non_mapping(self):
2220+
"""A bare ``requires:`` parses as YAML null. Like ``inputs``, a present
2221+
block must be a mapping, so YAML null is rejected as an authoring error
2222+
rather than being silently treated as an omitted block. (A truly
2223+
omitted ``requires`` defaults to ``{}`` and stays valid.)
2224+
"""
2225+
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
2226+
2227+
definition = WorkflowDefinition.from_string("""
2228+
workflow:
2229+
id: "test"
2230+
name: "Test"
2231+
version: "1.0.0"
2232+
requires:
2233+
steps:
2234+
- id: step-one
2235+
command: speckit.specify
2236+
""")
2237+
errors = validate_workflow(definition)
2238+
assert any("'requires' must be a mapping" in e for e in errors)
2239+
2240+
def test_requires_omitted_is_valid(self):
2241+
"""A workflow with no ``requires`` block at all defaults to ``{}`` and
2242+
must validate cleanly — only a present-but-non-mapping value is an
2243+
error (guards against over-correcting YAML-null rejection into also
2244+
flagging the omitted case).
2245+
"""
2246+
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
2247+
2248+
definition = WorkflowDefinition.from_string("""
2249+
workflow:
2250+
id: "test"
2251+
name: "Test"
2252+
version: "1.0.0"
2253+
steps:
2254+
- id: step-one
2255+
command: speckit.specify
2256+
""")
2257+
errors = validate_workflow(definition)
2258+
assert not any("requires" in e for e in errors)
2259+
21182260

21192261
# ===== Workflow Engine Tests =====
21202262

workflows/PUBLISHING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ When releasing a new version:
268268

269269
### Shell Steps
270270

271+
- **Shell runs with the user's privileges** — a `shell` step executes a local command directly; there is no capability sandbox. `requires` is an advisory pre-condition block (recognised keys: `speckit_version`, `integrations`), **not** a runtime permission gate — there is no `requires.permissions`. Gate sensitive commands explicitly with a `gate` step.
271272
- **Avoid destructive commands** — don't delete files or directories without explicit confirmation via a gate
272273
- **Quote variables** — use proper quoting in shell commands to handle spaces
273274
- **Check exit codes** — shell step failures stop the workflow; make sure commands are robust

0 commit comments

Comments
 (0)