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
18 changes: 18 additions & 0 deletions design/workflow-step.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ 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. The approval is discarded with that 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
16 changes: 16 additions & 0 deletions docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,23 @@ 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. 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 +247,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 +291,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
44 changes: 39 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,45 @@ 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.

#### 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
14 changes: 12 additions & 2 deletions src/specify_cli/bundles/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,18 @@ 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.
"""

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 @@ -396,5 +404,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
12 changes: 11 additions & 1 deletion src/specify_cli/bundles/command_update.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 Down Expand Up @@ -29,6 +31,12 @@ def bundle_update(
),
integration: str = typer.Option(None, "--integration", help="Override integration"),
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:
"""Re-resolve and refresh a bundle's components via each primitive's update path."""
try:
Expand All @@ -46,7 +54,9 @@ def bundle_update(
from .installer import install_bundle
from .resolver import resolve_install_plan

installer = DefaultPrimitiveInstaller(allow_network=not offline)
installer = DefaultPrimitiveInstaller(
allow_network=not offline, trust_steps=trust_steps,
)
for target in targets:
if not any(r.bundle_id == target for r in records):
raise BundlerError(f"Bundle '{target}' is not installed.")
Expand Down
Loading
Loading