Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,39 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta

> **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.

### Per-Step Integration Configuration

Command steps may pass structured runtime configuration to integrations that
support it:

```yaml
- id: implement-with-docker-agent
type: command
command: speckit.implement
integration: docker-agent
integration_args:
- "{{ inputs.agent_config }}"
integration_options:
agent: root
safety: balanced
model: "openai/gpt-5"
input:
args: "{{ inputs.spec }}"
```

`integration_args` is an ordered list of strings. `integration_options` is a
mapping with string keys. Values in both fields are resolved with the workflow
expression mechanism and validated by the selected integration; unsupported,
unknown, or malformed values fail with an actionable error. Docker Agent uses
its single positional argument as the agent configuration reference and accepts
`agent` and `safety` as named integration options. Configure its model through
the command step's top-level `model` field.

Resolved runtime configuration is recorded in workflow run state. When a failed
or paused command is resumed, the complete dispatch configuration is re-resolved
from the current inputs, so values supplied with `workflow resume --input` take
effect consistently. A resume without updated inputs reproduces the same values.

## Expressions

Steps can reference inputs and previous step outputs using `{{ expression }}` syntax:
Expand Down
4 changes: 4 additions & 0 deletions src/specify_cli/integrations/agy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import re
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -87,8 +88,11 @@ def build_exec_args(
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
# agy does not support --model or JSON output; both params are ignored
self.validate_runtime_config(integration_args, integration_options)
args = [self._resolve_executable(), "--print", prompt]
# Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags),
# appended after the positional prompt like the devin integration.
Expand Down
51 changes: 50 additions & 1 deletion src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import subprocess
import sys
from abc import ABC
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
Expand Down Expand Up @@ -245,6 +246,8 @@ def build_exec_args(
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
"""Build CLI arguments for non-interactive execution.

Expand All @@ -254,8 +257,38 @@ def build_exec_args(

Subclasses for CLI-based integrations should override this.
"""
self.validate_runtime_config(integration_args, integration_options)
return None

def validate_runtime_config(
self,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> None:
"""Validate per-step CLI configuration for this integration.

Runtime configuration is deliberately separate from :meth:`options`,
which describes install-time ``--integration-options`` accepted by
``specify init`` and the integration management commands. Integrations
opt in by overriding this hook and translating the validated values in
:meth:`build_exec_args` (or a custom :meth:`dispatch_command`).

The default accepts empty configuration for backward compatibility and
rejects non-empty values instead of silently ignoring a misspelled or
unsupported runtime option.
"""
if integration_args:
raise ValueError(
f"Integration {self.key!r} does not support per-step "
"'integration_args'."
)
if integration_options:
option_names = ", ".join(sorted(str(key) for key in integration_options))
raise ValueError(
f"Integration {self.key!r} does not support per-step "
f"'integration_options' ({option_names})."
)

def _resolve_executable(self) -> str:
"""Return the executable for this integration's CLI tool.

Expand Down Expand Up @@ -345,6 +378,8 @@ def dispatch_command(
model: str | None = None,
timeout: int = 600,
stream: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Dispatch a Spec Kit command through this integration's CLI.

Expand All @@ -365,11 +400,16 @@ def dispatch_command(
"""
import subprocess

self.validate_runtime_config(integration_args, integration_options)
prompt = self.build_command_invocation(command_name, args)
# When streaming to the terminal, request text output so the
# user sees readable output instead of raw JSONL events.
exec_args = self.build_exec_args(
prompt, model=model, output_json=not stream
prompt,
model=model,
output_json=not stream,
integration_args=integration_args,
integration_options=integration_options,
)

if exec_args is None:
Expand Down Expand Up @@ -1025,7 +1065,10 @@ def build_exec_args(
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
self.validate_runtime_config(integration_args, integration_options)
if not self.config or not self.config.get("requires_cli"):
return None
args = [self._resolve_executable(), "-p", prompt]
Expand Down Expand Up @@ -1116,7 +1159,10 @@ def build_exec_args(
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
self.validate_runtime_config(integration_args, integration_options)
if not self.config or not self.config.get("requires_cli"):
return None
args = [self._resolve_executable(), "-p", prompt]
Expand Down Expand Up @@ -1585,7 +1631,10 @@ def build_exec_args(
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
self.validate_runtime_config(integration_args, integration_options)
if not self.config or not self.config.get("requires_cli"):
return None
args = [self._resolve_executable(), "-p", prompt]
Expand Down
6 changes: 6 additions & 0 deletions src/specify_cli/integrations/codex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import Any

from ..base import IntegrationOption, SkillsIntegration


Expand Down Expand Up @@ -46,10 +49,13 @@ def build_exec_args(
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
# Resolve argv[0] via the shared executable resolver so operators can
# override the binary with SPECKIT_INTEGRATION_CODEX_EXECUTABLE.
self.validate_runtime_config(integration_args, integration_options)
args: list[str] = [self._resolve_executable(), "exec", prompt]
self._apply_extra_args_env_var(args)
if model:
Expand Down
9 changes: 9 additions & 0 deletions src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

from __future__ import annotations

from collections.abc import Mapping, Sequence

import json
import os
import shutil
Expand Down Expand Up @@ -305,7 +307,10 @@ def build_exec_args(
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
self.validate_runtime_config(integration_args, integration_options)
# GitHub Copilot CLI uses ``copilot -p "prompt"`` for
# non-interactive mode. --yolo enables all permissions
# (tools, paths, and URLs) so the agent can perform file
Expand Down Expand Up @@ -348,6 +353,8 @@ def dispatch_command(
model: str | None = None,
timeout: int = 600,
stream: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Dispatch via ``--agent speckit.<stem>`` instead of slash-commands.

Expand All @@ -360,6 +367,8 @@ def dispatch_command(
"""
import subprocess

self.validate_runtime_config(integration_args, integration_options)

stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
Expand Down
6 changes: 6 additions & 0 deletions src/specify_cli/integrations/cursor_agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import Any

from ..base import IntegrationOption, SkillsIntegration


Expand Down Expand Up @@ -63,6 +66,8 @@ def build_exec_args(
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
"""Build CLI arguments for non-interactive ``cursor-agent`` execution.

Expand Down Expand Up @@ -94,6 +99,7 @@ def build_exec_args(
either drops tool calls or exits non-zero on the first approval
prompt.
"""
self.validate_runtime_config(integration_args, integration_options)
args = [
self._resolve_executable(),
"-p",
Expand Down
6 changes: 6 additions & 0 deletions src/specify_cli/integrations/devin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import Any

from ..base import IntegrationOption, SkillsIntegration


Expand Down Expand Up @@ -58,6 +61,8 @@ def build_exec_args(
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
) -> list[str] | None:
"""Build non-interactive CLI args for Devin for Terminal.

Expand All @@ -68,6 +73,7 @@ def build_exec_args(
stdout instead of structured JSON. ``requires_cli=True`` is
kept on the integration for tool detection.
"""
self.validate_runtime_config(integration_args, integration_options)
args = [self._resolve_executable(), "-p", prompt]
self._apply_extra_args_env_var(args)
if model:
Expand Down
Loading