Skip to content
Draft
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
2 changes: 2 additions & 0 deletions openspec/changes/add-extension-mechanism/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-19
122 changes: 122 additions & 0 deletions openspec/changes/add-extension-mechanism/design.md

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions openspec/changes/add-extension-mechanism/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## Why

`lstk` ships a fixed set of built-in commands, so any new capability — whether built by LocalStack engineers or partners — has to land in the core open-source repository. That blocks closed-source/proprietary features, slows third-party contribution, and couples every new feature's release to the core CLI's release cadence. We want a Git-style extension mechanism so anyone can add `lstk <name>` subcommands as separate binaries on their `PATH`, with lstk handing each extension enough runtime context (resolved emulator endpoint, config dir, auth token) to do useful work — while leaving any authorization decision to the extension itself.

## What Changes

- Introduce a Git-style extension model: when `lstk <name>` is not a built-in command, lstk resolves and executes an `lstk-<name>` executable found on `PATH`, forwarding all remaining arguments and propagating the child's stdin/stdout/stderr and exit code.
- Define an **extension runtime contract**: lstk passes the resolved emulator endpoint, emulator type, config directory, auth token, and resolved global-flag state to the extension process through a stable, versioned set of `LSTK_EXT_*` environment variables so extensions can talk to the emulator and the platform without re-implementing discovery or config resolution.
- **Honor global flags before the command name**: lstk parses its own global flags (e.g. `--non-interactive`, and any added later) when they precede the extension name, consumes them itself, and conveys the resolved state to the extension via `LSTK_EXT_*` (e.g. `LSTK_EXT_NON_INTERACTIVE`) rather than forwarding them on the extension's command line.
- **Bundle LocalStack's own extensions**: ship LocalStack-built extensions (e.g. a closed-source `lstk-deploy`) by default alongside `lstk` in a directory next to the binary, resolved ahead of `PATH` and updated atomically with `lstk` — with no user-facing install step.
- Establish that **authorization is the extension's responsibility**: lstk conveys the user's auth token and makes no entitlement decision of its own. An extension that needs to restrict its use authorizes the user itself (e.g. by calling the LocalStack platform with the conveyed token). A richer lstk-side mechanism — lstk obtaining a LocalStack-signed entitlement description for the extension to verify offline — is deliberately **deferred** to future work.
- List resolvable extensions in `lstk help` by scanning the bundled directory and `PATH` for `lstk-*` executables; bundled extensions show a one-line description read from a static descriptions file generated during the release process, while `PATH`/custom extensions are name-only. Help rendering never executes an extension.
- Keep the entire mechanism in the open-source repository; closed-source extensions ship only as binaries placed on `PATH` and never require source in the core repo.

## Capabilities

### New Capabilities

- `extension-framework`: Git-style discovery, resolution, and dispatch of `lstk-<name>` extension executables (bundled dir + `PATH`), including built-in precedence, leading-only global-flag handling, forwarding of arguments/streams/exit codes, and side-effect-free help listing (bundled extensions described from a static file, others name-only). No lstk-side manifest — extensions are self-describing and self-validating.
- `extension-runtime-context`: The versioned environment-variable contract lstk establishes for an extension process — resolved emulator endpoint/type/port, config directory, auth token, and resolved global-flag state (e.g. non-interactive) — so extensions can reach the emulator and platform and honor lstk's global flags.
- `extension-entitlement`: The authorization model — lstk conveys the auth token and the extension authorizes itself — plus the explicit deferral of any lstk-side signed-entitlement mechanism and the security rationale (lstk is open source, so authorization cannot depend on it).
- `extension-bundling`: Shipping LocalStack-built (possibly closed-source) extensions by default alongside `lstk` — a read-only bundled directory next to the binary, resolution ahead of `PATH`, a release-generated descriptions file for help text, cross-channel packaging (binary archive, Homebrew, npm), and atomic version-matched updates via `internal/update`. Excludes user-facing management commands and a user-mutable directory.

### Modified Capabilities

<!-- No existing capability's requirements change; the IaC proxy specs (terraform-proxy, cdk-proxy) are unaffected. -->

## Impact

- **New code**: `internal/extension/` (bundled-dir + PATH resolution, runtime context builder, global-flag conveyance, exec), and unknown-command dispatch + help-listing wiring in `cmd/root.go`.
- **Touched code**: `cmd/root.go` (fallthrough to extension dispatch for unknown commands; `SetInterspersed(false)` for leading-only global flags; bundled-dir + PATH scan for help), reuse of `internal/auth` (token resolution), `internal/config`/`internal/endpoint` (config dir and emulator endpoint resolution), `internal/container` (running-emulator discovery for endpoint/type), and `internal/update` (atomic version-matched update of bundled extensions).
- **Packaging/release**: binary archive, Homebrew formula, and npm package must lay out bundled extensions where lstk resolves them; the public release workflow must pull prebuilt closed-source bundled binaries from private CI, version-pinned to the lstk release.
- **External dependencies/services**: None required by this change. Extensions that authorize use the existing LocalStack platform with the conveyed auth token; no new platform or emulator endpoints are needed.
- **Security surface**: lstk passes the auth token into extension processes via env (as it already does for IaC proxies); this defines a local trust boundary to document. Authorization guarantees live in the extension, never in lstk.
- **Docs**: New "Extensions" section in CLAUDE.md and a public extension-author guide (manifest-free contract, `LSTK_EXT_*` variables, the self-authorization model and why it cannot rely on lstk).

## Deferred (future work)

- lstk-side entitlement verification and a LocalStack-signed entitlement description (grant) that extensions verify offline against a published public key.
- Emulator genuineness attestation (distinguishing a licensed emulator from a clone).
- User-facing `lstk extension` management commands (`list`/`info`/`install`/`remove`) and a user-mutable managed extensions directory. (Static, ships-with-lstk bundling is **in scope** via the `extension-bundling` capability; only the user-driven install/remove UX is deferred.)
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# extension-bundling Specification

## Purpose

Allow LocalStack to ship its own extensions (for example a closed-source `lstk-deploy`) by default alongside `lstk`, so they are available immediately after a standard install with no separate step, are kept in lockstep with the `lstk` version that ships them, and are resolved deterministically ahead of any same-named executable on `PATH`. This capability covers only static, read-only, ships-with-lstk extensions; user-driven install/remove of extensions and a user-mutable managed directory remain out of scope (deferred).

## ADDED Requirements

### Requirement: Bundled-extensions directory alongside the executable

lstk SHALL look for bundled extensions in a fixed directory derived from the location of its own executable, resolving symlinks so the directory is found even when `lstk` is invoked through a symlink or package shim (e.g. an npm `.bin` link). Bundled extension executables follow the same `lstk-<name>` naming convention as any other extension and SHALL NOT require a manifest. This directory is owned by the lstk distribution and is read-only from the user's perspective: lstk SHALL NOT provide commands to add to or remove from it in this change.

#### Scenario: Bundled directory resolved through a symlink

- **WHEN** `lstk` is invoked via a symlink or package shim and an `lstk-deploy` is bundled
- **THEN** lstk resolves its real executable location, finds the bundled-extensions directory, and can resolve `lstk deploy`

#### Scenario: Naming convention identifies bundled extensions

- **WHEN** the bundled-extensions directory contains an executable named `lstk-deploy`
- **THEN** lstk treats it as the `deploy` extension without reading any manifest

### Requirement: Bundled extensions are available after a standard install

A set of extensions MAY be designated as bundled and SHALL be installed alongside `lstk` by the same single installation command across supported distribution channels (binary archive, Homebrew, npm), placed in the bundled-extensions directory, and resolvable immediately as `lstk <name>` with no separate install step. Packaging SHALL place bundled extensions where lstk resolves them without requiring the user to add them to `PATH`.

#### Scenario: Bundled extension available immediately

- **WHEN** a user installs lstk via the standard installation command for any supported channel and `lstk-deploy` is bundled
- **THEN** `lstk deploy` resolves to the bundled extension with no extra install step

#### Scenario: Bundled extension found without PATH changes

- **WHEN** a user extracts the binary archive and places only `lstk` on `PATH`
- **THEN** a bundled `lstk-deploy` sibling is still resolved by `lstk deploy` because lstk searches the directory alongside its executable

### Requirement: Bundled extensions update atomically with lstk

Updating lstk SHALL update its bundled extensions to the matching version as a single, atomic set, so a running `lstk` and its bundled extensions are never left at mismatched versions. `internal/update` SHALL replace the lstk executable and its bundled extensions together regardless of the install method, or fail without partially updating.

#### Scenario: Bundled extensions updated with lstk

- **WHEN** lstk is updated to a new version that ships a newer bundled `lstk-deploy`
- **THEN** the bundled `lstk-deploy` is replaced with the matching version as part of the same update
- **AND** an interrupted update does not leave lstk and the bundled extension at mismatched versions

### Requirement: Release-generated descriptions file for bundled extensions

The release process SHALL generate a static descriptions file that maps each bundled extension's command name to a one-line description, and SHALL ship it with the distribution where lstk reads it (alongside the bundled extensions). The file SHALL cover only bundled, LocalStack-controlled extensions; it is not a per-extension manifest authored by third parties. It SHALL be versioned and updated together with the bundled extension set, so descriptions never drift from the binaries that ship. lstk reads this file for help rendering (see the extension-framework capability) and never executes an extension to obtain a description.

#### Scenario: Descriptions file ships with the bundled set

- **WHEN** a release bundles `lstk-deploy`
- **THEN** the release process produces a descriptions file entry mapping `deploy` to its one-line description
- **AND** that file is shipped where lstk resolves bundled extensions

#### Scenario: Descriptions update atomically with the bundled set

- **WHEN** lstk is updated to a version that bundles a renamed or re-described extension
- **THEN** the descriptions file is updated as part of the same atomic update
- **AND** lstk never shows a description that disagrees with the bundled binaries

### Requirement: Bundled closed-source extensions still self-authorize

Bundling SHALL NOT change the authorization model: a bundled extension that gates on entitlement (for example a premium closed-source extension) SHALL perform its own authorization using the conveyed auth token exactly as a separately distributed extension would. lstk SHALL NOT treat a bundled extension as automatically entitled.

#### Scenario: Bundled premium extension enforces its own entitlement

- **WHEN** a bundled `lstk-deploy` requires entitlement and an unentitled user runs `lstk deploy`
- **THEN** lstk dispatches to the bundled extension and conveys the token
- **AND** the bundled extension performs its own authorization and refuses the unentitled user
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# extension-entitlement Specification

## Purpose

Establish that authorization for an extension is the extension's own responsibility, and that lstk's role is limited to conveying the user's auth token so the extension can decide for itself whether the user is entitled. A richer lstk-side mechanism (lstk obtaining a LocalStack-signed entitlement description and passing it for offline verification) is deliberately deferred to future work; this capability records that intent and the security rationale behind it.

## ADDED Requirements

### Requirement: lstk conveys the auth token; the extension authorizes

lstk SHALL make the resolved user auth token available to the extension via the runtime context (`LSTK_EXT_AUTH_TOKEN`) and SHALL NOT itself perform any entitlement or license decision for the extension. An extension that wishes to restrict its use SHALL perform its own authorization check — for example, by calling the LocalStack platform with the conveyed token — and SHALL refuse to perform protected work when that check does not pass.

#### Scenario: Token conveyed for the extension to authorize

- **WHEN** a user with a resolved auth token invokes an extension
- **THEN** lstk passes the token to the extension via `LSTK_EXT_AUTH_TOKEN`
- **AND** lstk invokes the extension without making any entitlement decision of its own

#### Scenario: Extension enforces its own authorization

- **WHEN** an extension that gates on entitlement determines the user is not entitled
- **THEN** the extension refuses to perform its protected work
- **AND** this decision is made by the extension, not by lstk

#### Scenario: Unauthenticated invocation still dispatches

- **WHEN** no auth token is available and a user invokes an extension
- **THEN** lstk still resolves and executes the extension (omitting `LSTK_EXT_AUTH_TOKEN`)
- **AND** any requirement for authentication is enforced by the extension itself

### Requirement: Security rests on the extension, not on lstk

Because lstk is open source and can be rebuilt with any check removed, no authorization guarantee SHALL depend on lstk behaving honestly. An extension that needs durable protection SHALL anchor its enforcement in something a modified lstk cannot forge — a server-side check against the LocalStack platform using the conveyed token, and/or verification it performs itself — rather than relying on lstk to gate invocation.

#### Scenario: Modified lstk cannot bypass extension authorization

- **WHEN** a rebuilt lstk skips conveying the token or alters its behavior
- **THEN** an extension that authorizes server-side (or otherwise verifies independently) still refuses unauthorized work
- **AND** the absence of an lstk-side gate does not weaken the extension's protection

### Requirement: Signed-entitlement mechanism is deferred

This change SHALL NOT implement lstk-side entitlement verification, signed grant/entitlement-description issuance, or offline grant verification. These remain future work. lstk SHALL NOT set `LSTK_EXT_GRANT` or `LSTK_EXT_PUBLIC_KEY`; if a future change introduces a LocalStack-signed entitlement description, it will be added as an additive extension to the runtime-context contract under a documented version bump.

#### Scenario: No grant or public key conveyed

- **WHEN** lstk invokes any extension
- **THEN** `LSTK_EXT_GRANT` is not set
- **AND** `LSTK_EXT_PUBLIC_KEY` is not set
Loading
Loading