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
25 changes: 24 additions & 1 deletion design/workflow-step.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ see [Workflows](../docs/reference/workflows.md).
| Route | Source | Registration |
|---|---|---|
| Built-in | `src/specify_cli/workflows/step/<package>/__init__.py` | Explicit import and `_register_step()` in `src/specify_cli/workflows/__init__.py` |
| Installed | Package under `.specify/workflows/steps/<id>/`, containing `step.yml` and `__init__.py` | `load_custom_steps(project_root)` loads a matching `StepBase` subclass at workflow run/resume |
| Installed | Package under `.specify/workflows/steps/<id>/`, containing `step.yml` and `__init__.py` | After explicit consent, `load_custom_steps(project_root, trusted=True)` loads a matching `StepBase` subclass for workflow add/update/run/resume |

`STEP_REGISTRY` maps each `type_key` to a single step instance. Built-in keys
are snapshotted in `BUILTIN_STEP_TYPES` before project steps are loaded; do not
Expand All @@ -22,6 +22,29 @@ The default community catalog is discovery-only, not an install or trust
endorsement. Review external step code before installing it: loading a package
imports and executes its Python module.

Loading project step code also requires consent for the current invocation.
`load_custom_steps(project_root, trusted=False)` refuses imports by default;
neither files on disk nor `step-registry.json` grant trust. CLI commands that
load custom steps (`workflow add`, `update`, `run`, and `resume`) ask for
confirmation on interactive stdin, defaulting to refusal. `--trust-steps`
explicitly authorizes all custom step packages in that project for that
invocation, including unreferenced packages, and works without a terminal.
Approval is never persisted or inherited by subsequent invocations.
Bundle `add`, `install`, `update`, and `init` commands forward the same explicit
consent through their primitive installer to both bundled and catalog workflow
delegates. Bundle metadata, offline mode, and refresh do not imply trust.
An interactive approval is shared by the bundle invocation's primitive
installer for that project, so subsequent workflow delegates do not prompt
again while the step packages remain unchanged. A step install or refresh
invalidates that project's cached interactive approval before mutation; the
next workflow delegate requires renewed consent. Explicit `--trust-steps`
continues to authorize all custom step code for the invocation, including
new or refreshed packages. Approval is discarded with the invocation, never
stored globally.
Workflow delegates remain bound to the approved absolute project root. Their
temporary project override must not reinterpret a relative `SPECIFY_INIT_DIR`
from a changed working directory, and must restore the caller's override afterward.

## Step contract

Subclass `StepBase` from `src/specify_cli/workflows/base.py`, set `type_key`
Expand Down
9 changes: 6 additions & 3 deletions docs/community/workflow-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
> their authors. Maintainers verify submission metadata and package shape; they
> do **not review, audit, endorse, or support the executable Python code**.
> Installation validates and copies the package without importing its Python
> code. Later `specify workflow add`, `run`, and `resume` commands load installed
> step packages, executing import-time code with the user's privileges. Review
> the source before installation and use.
> code. Later `specify workflow add`, `update`, `run`, and `resume` commands load
> installed step packages only after explicit consent for that invocation: a
> default-No interactive confirmation or `--trust-steps`. Loading executes
> import-time code with the user's privileges. Review the source before
> installation and use. See [Custom step trust](../reference/workflows.md#custom-step-trust)
> for consent scope, bundle delegation, and renewed approval after step changes.

Custom workflow step packages add a `type:` that workflows can use beyond the
built-in step types. The canonical intake path is the
Expand Down
21 changes: 21 additions & 0 deletions docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,28 @@ specify bundle install <bundle_id | path>
| `--offline` | Do not access the network |
| `--refresh` | Refresh owned components from the supplied bundle source |
| `--version` | Install an exact historical or current catalog release |
| `--trust-steps` | Authorize all project custom step Python for this invocation |

Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack, unless the manifest declares `provides.bundles`. A local parent with bundle references resolves those children through the catalog stack at command time, so it needs catalog and network access (see [Bundle references](#bundle-references)).

When a workflow install or refresh loads custom step Python, the same
[per-invocation trust approval](workflows.md#custom-step-trust) applies as for
direct workflow commands. `bundle install`, its `bundle add` alias, `bundle
update`, and `bundle init` forward `--trust-steps` to both bundled and catalog
workflow installers. Without the switch, interactive invocations ask for
approval and unattended invocations refuse to load custom steps. Neither
`--offline` nor `--refresh` implies approval, and consent is never persisted.
One interactive approval covers subsequent workflow delegates in the same
bundle command, including a mix of bundled and catalog workflows, while the
custom step packages remain unchanged. Installing or refreshing a step package
invalidates that interactive approval, including during `bundle update --all`;
the next workflow delegate asks again before importing the new code.
`--trust-steps` explicitly authorizes all custom step code for that invocation,
including newly installed or refreshed packages. Later commands require
approval again.
Delegated operations remain bound to the approved project, including when
`SPECIFY_INIT_DIR` was relative to the bundle command's starting directory.

If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — already-installed components matching their pins are skipped, as are bundle-owned compatible packaged patch releases with a warning (see below). Other differing or missing installed versions cannot satisfy a pin. Only `--refresh` can repair a drifted version owned exclusively by this bundle; independently installed components are skipped and never adopted or replaced. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain.

Installed-bundle records store required component pins separately from contributed components. A compatible independently installed component satisfies a bundle's requirement but remains independently owned: its pin still blocks a later bundle from installing an incompatible version, while removing the bundle does not uninstall it. An unpinned install or refresh cannot replace a component another bundle requires at a pinned version; sharing an already installed matching version without refreshing remains allowed. Shared bundles must also agree on component source and, for presets, priority and strategy before installing or refreshing the shared component. Older records without the requirement list retain their known contributed pins; reinstall or refresh the bundle to record its full requirements.
Expand Down Expand Up @@ -233,6 +252,7 @@ specify bundle update [<bundle_id>]
| `--all` | Update every installed bundle |
| `--integration` | Override the integration used when refreshing components; applied only when the project's active integration can't be determined |
| `--offline` | Do not access the network |
| `--trust-steps` | Authorize all project custom step Python for this invocation |

Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions (or compatible newer packaged patches, with a warning) while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.

Expand Down Expand Up @@ -276,6 +296,7 @@ specify bundle init [<bundle_id>]
| ---------------- | ---------------------------------------- |
| `--integration` | Integration override |
| `--offline` | Do not access the network |
| `--trust-steps` | Authorize all project custom step Python for this invocation |

Ensures the current directory is a Spec Kit project (initializing it idempotently if needed), then optionally installs the given bundle. Useful as an explicit one-step bootstrap for a new checkout.

Expand Down
46 changes: 41 additions & 5 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ specify workflow run <source>
| ------------------- | -------------------------------------------------------- |
| `-i` / `--input` | Pass input values as `key=value` (repeatable) |
| `--json` | Emit the run outcome as a single JSON object |
| `--trust-steps` | Authorize all project custom step Python for this invocation |

Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively.

Expand Down Expand Up @@ -80,6 +81,7 @@ specify workflow resume <run_id>
| ------------------- | -------------------------------------------------------- |
| `-i` / `--input` | Updated input values as `key=value` (repeatable) |
| `--json` | Emit the resume outcome as a single JSON object |
| `--trust-steps` | Authorize all project custom step Python for this invocation |

Resumes a paused or failed workflow run from its persisted execution state. A
`running` run is not resumable. Resume replays completed work into its
Expand Down Expand Up @@ -128,6 +130,7 @@ specify workflow add <source>
| `--dev` | Install from a local YAML file, package directory, or archive |
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
| `--version <version>` | Install an exact advertised catalog release (`<source>` must be a workflow ID) |
| `--trust-steps` | Authorize all project custom step Python for this invocation |

Installs a workflow from the catalog, an HTTPS URL, a local YAML file, a
directory containing `workflow.yml`, or a `.zip`, `.tar.gz`, or `.tgz`
Expand Down Expand Up @@ -398,6 +401,10 @@ specify workflow update [workflow_id]

Updates one installed catalog workflow — or all of them when no ID is given — to the latest catalog version. Prompts for confirmation and keeps the installed copy if a download or validation fails.

`--trust-steps` authorizes loading project custom step code for this invocation.
Without it, custom steps require interactive confirmation as described under
[Custom step trust](#custom-step-trust).

## Enable or Disable a Workflow

```bash
Expand Down Expand Up @@ -992,18 +999,47 @@ Every source is validated identically before anything is committed:
validation: at most 512 archive entries, 50 MiB downloaded or extracted, and
10 MiB per archive member. Catalog files have a 50 MiB per-response bound.
- Installation validates and copies the package but does **not** import or
execute `__init__.py`. Installed custom step modules are loaded during startup
of `workflow add`, `workflow run`, and `workflow resume`, before any particular
custom step necessarily executes.
execute `__init__.py`. With explicit consent, custom step modules are loaded
during startup of `workflow add`, `workflow update`, `workflow run`, and
`workflow resume`, before any particular custom step necessarily executes.

> **Security note:** Loading a custom step runs its Python with **your**
> privileges. Only install and retain step packages from sources you trust.

#### Custom step trust

Finding a package on disk does not authorize executing it, even if the project's
`step-registry.json` says it was installed. Before importing custom step Python,
`workflow add`, `workflow update`, `workflow run`, and `workflow resume` ask for
confirmation when stdin is interactive. The default answer is **No**. Declining,
interrupting the prompt, or running without a terminal blocks loading and fails
the command before custom step code executes.

After reviewing all packages in `.specify/workflows/steps/`, use `--trust-steps`
to approve them explicitly, including in unattended scripts:

```bash
specify workflow run my-workflow --trust-steps
specify workflow resume <run_id> --trust-steps
```

Approval applies only to that invocation and includes **all discovered custom
step packages**, even ones the requested workflow does not reference. It is
never saved: each invocation requires confirmation or `--trust-steps` again.
Projects without custom step packages need no approval. Under `--json`, trust
prompts go to stderr and refusal produces a single JSON failure object on stdout.

The [bundle commands](bundles.md#install-a-bundle) that install or refresh
workflows (`add`, `install`, `update`, and `init`) accept and forward the same
`--trust-steps` option for that bundle invocation. Interactive approval is
renewed if a bundle installs or refreshes a step package during that command;
the explicit switch authorizes those new or refreshed packages as well.

#### Listing, running, and removing

Installed custom steps appear in `specify workflow step list` and are loaded
automatically by `workflow add`, `workflow run`, and `workflow resume`. Remove
one with:
by `workflow add`, `workflow update`, `workflow run`, and `workflow resume`
only after the trust approval above. Remove one with:

```bash
specify workflow step remove my-step
Expand Down
20 changes: 18 additions & 2 deletions src/specify_cli/bundles/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,20 @@ class DefaultPrimitiveInstaller:
*allow_network* mirrors the bundle command's ``--offline`` flag: when False,
component kinds that can only be sourced from a remote catalog refuse rather
than touching the network. Bundled presets/extensions still install offline.
*trust_steps* forwards explicit consent for custom step imports during this
bundle invocation only; it is never derived from bundle metadata.
Interactive approvals are shared across this installer's workflow delegates,
scoped to each project and discarded with the bundle invocation's installer.
Installing or refreshing a step invalidates that project's interactive
approval before the package can change.
"""

def __init__(self, *, allow_network: bool = True) -> None:
def __init__(
self, *, allow_network: bool = True, trust_steps: bool = False,
) -> None:
self._allow_network = allow_network
self._trust_steps = trust_steps
self._approved_step_projects: set[Path] = set()

def is_installed(self, project_root: Path, component: ComponentRef) -> bool:
manager = self._manager_for(component, project_root)
Expand Down Expand Up @@ -379,12 +389,16 @@ def install(
self, project_root: Path, component: ComponentRef,
) -> BundledPatchWarning | None:
manager = self._manager_for(component, project_root)
if component.kind == "steps":
self._approved_step_projects.discard(project_root.resolve())
return manager.install(component)

def refresh(
self, project_root: Path, component: ComponentRef,
) -> BundledPatchWarning | None:
manager = self._manager_for(component, project_root)
if component.kind == "steps":
self._approved_step_projects.discard(project_root.resolve())
return manager.refresh(component)

def remove(self, project_root: Path, component: ComponentRef) -> None:
Expand All @@ -396,5 +410,7 @@ def _manager_for(self, component: ComponentRef, project_root: Path):
from .primitives import primitive_manager

return primitive_manager(
component.kind, project_root, allow_network=self._allow_network
component.kind, project_root, allow_network=self._allow_network,
trust_steps=self._trust_steps,
approved_step_projects=self._approved_step_projects,
)
9 changes: 9 additions & 0 deletions src/specify_cli/bundles/command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Annotated

import typer

from ._commands import bundle_app
Expand All @@ -27,6 +29,12 @@ def bundle_add(
"--version",
help="Install an exact bundle release from the catalog",
),
trust_steps: Annotated[
bool,
typer.Option(
"--trust-steps", help="Authorize all custom step Python for this invocation",
),
] = False,
) -> None:
"""Install a bundle's full component set (alias for install)."""
bundle_install(
Expand All @@ -35,4 +43,5 @@ def bundle_add(
offline=offline,
refresh=refresh,
version=version,
trust_steps=trust_steps,
)
9 changes: 9 additions & 0 deletions src/specify_cli/bundles/command_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Annotated

import typer
from rich.markup import escape as _escape_markup

Expand All @@ -23,6 +25,12 @@ def bundle_init(
bundle: str = typer.Argument(None, help="Optional bundle to install after init"),
integration: str = typer.Option(None, "--integration", help="Integration override"),
offline: bool = typer.Option(False, "--offline", help="Do not access the network"),
trust_steps: Annotated[
bool,
typer.Option(
"--trust-steps", help="Authorize all custom step Python for this invocation",
),
] = False,
) -> None:
"""Ensure the project is initialized (idempotent), then optionally install a bundle."""
from .project import find_project_root
Expand Down Expand Up @@ -53,4 +61,5 @@ def bundle_init(
integration=integration,
offline=offline,
refresh=False,
trust_steps=trust_steps,
)
9 changes: 8 additions & 1 deletion src/specify_cli/bundles/command_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from pathlib import Path
from typing import Annotated

import typer
from rich.markup import escape as _escape_markup
Expand Down Expand Up @@ -49,6 +50,12 @@ def bundle_install(
"--version",
help="Install an exact bundle release from the catalog",
),
trust_steps: Annotated[
bool,
typer.Option(
"--trust-steps", help="Authorize all custom step Python for this invocation",
),
] = False,
) -> None:
"""Install a bundle's full component set through each primitive's machinery.

Expand Down Expand Up @@ -155,7 +162,7 @@ def bundle_install(
result = install_bundle(
project_root,
plan,
DefaultPrimitiveInstaller(allow_network=not offline),
DefaultPrimitiveInstaller(allow_network=not offline, trust_steps=trust_steps),
manifest=manifest,
refresh=refresh,
)
Expand Down
Loading
Loading