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
233 changes: 233 additions & 0 deletions docs/content/sips/022-environment-variable-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
title = "SIP 022 - Environment Variable Isolation for Composed Components"
template = "main"
date = "2026-02-15T00:00:00Z"
---

Summary: Automatically isolate WASI environment variables within a component composition so that each component in a dependency graph sees only its own declared variables, despite sharing a single host process.

Owner(s): till@tillschneidereit.net

Created: Feb 15, 2026

## Background

Spin applications can compose multiple WebAssembly components together using component dependencies (see [SIP 020](../sips/020-component-dependencies.md)). When a parent component declares a dependency, Spin statically composes the two components into a single Wasm component at build time or at load time.

A consequence of this composition is that all components in the resulting graph share the same host `wasi:cli/environment` import. Any environment variables set on the parent component are visible to every dependency, and dependencies cannot have their own distinct environment configurations. This is a problem for several reasons:

1. **Leaking configuration.** A parent component's secrets or internal configuration (e.g. API keys set via environment variables) are visible to dependencies that should not see them.

2. **No per-dependency configuration.** Reusable library components that are parameterised by environment variables cannot be given different values when the same library is used by different parent components, or when a dependency needs different settings than its parent.

3. **Name collisions.** Two components that both read `DATABASE_URL` will see the same value, with no way to differentiate.

## Proposal

To address these issues, this SIP proposes to automatically isolate the environment variables of a component and all its dependencies from each other.

At a high level, the isolation works by adding prefixes based on the components' name / dependency IDs when loading an application manifest, and then filtering by those prefixes in a synthesized component that interposes imports of `wasi:cli/environment`, removing the prefix of allowed variables before forwarding them.

This process introduces a single very small (about 1.5KB to 2KB) core WebAssembly module to the composed graph, containing a 1-page (64KB) linear memory. Actual resource allocation should normally be a small fraction of those 64KB, unless the component graph has large amounts of data passed in as env vars.

### Manifest changes

A new `environment` field is added to each dependency specification within the `[component.<id>.dependencies]` section. This field is an inline table of string key-value pairs, identical in syntax to the existing component-level `environment` field:

```toml
spin_manifest_version = 2

[application]
name = "my-app"
version = "1.0.0"

[[trigger.http]]
route = "/..."
component = "main"

[component.main]
source = "main.wasm"
environment = { GREETING = "hello from main", MAIN_ONLY = "secret" }

[component.main.dependencies."acme:lib/helper"]
component = "helper"
environment = { GREETING = "hello from helper", HELPER_SETTING = "42" }

[component.helper]
source = "helper.wasm"
```

Additionally, components are now allowed to specify their own `environment` table and still be used as dependencies, instead of being rejected. When a dependency references another component in the same application (the `component = "..."` form), the referenced component may itself declare an `environment`. In that case, the two sets of variables are merged: the referenced component's `environment` provides the base values, and the dependency specification's `environment` provides overrides and additions. This merge happens during manifest normalisation, before any composition takes place.

For example, given:

```toml
[component.helper]
source = "helper.wasm"
environment = { GREETING = "default greeting", LOG_LEVEL = "info" }

[component.main.dependencies."acme:lib/helper"]
component = "helper"
environment = { GREETING = "overridden greeting" }
```

The effective environment for the `helper` dependency is `{ GREETING = "overridden greeting", LOG_LEVEL = "info" }`.

The `environment` field is available on all dependency forms (`component`, `path`, `url`, and registry version specs), consistent with the existing `export` field.

### How isolation works

Spin automatically activates environment variable isolation for compositions with at least one dependency. The mechanism works in two coordinated stages:

#### Stage 1: Prefixed environment variables (manifest load time)

At manifest load time, Spin's WASI factor prepends a deterministic prefix to every environment variable key before placing it in the WASI context. The prefix is derived from the component's ID (for the main component) or the dependency name (for dependencies):

- Convert the last segment of the name (after the final `/` if present) to uppercase.
- Replace non-alphanumeric characters with underscores.
- Append a trailing underscore.

Examples:
| Component / Dependency Name | Prefix |
|---|---|
| `main` | `MAIN_` |
| `my-service` | `MY_SERVICE_` |
| `acme:lib/helper` | `HELPER_` |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HELPER_ or ACME_LIB_HELPER_?

Also I believe we allow "import all" deps so the dependency could be acme:lib (check with Brian though).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ is correct: acme:lib is an acceptable form

| `hello:components/dependable` | `DEPENDABLE_` |

The resulting flat list of environment variables is used in the locked application, so except for stage 2 below, all further parts of the processing pipeline aren't changed in any way, which results in the WASI environment being populated with the prefixed variables.

Given the manifest example above, the actual WASI environment would contain:

```
MAIN_GREETING=hello from main
MAIN_MAIN_ONLY=secret
HELPER_GREETING=hello from helper
HELPER_HELPER_SETTING=42
```

**NOTE**: Dynamically provided environment variables, e.g. those added using `spin up -e SOME=var` aren't automatically prefixed, and as a result not visible to any component, unless they already have the right prefix. E.g. to expose `SOME` to the `main` component, it has to be changed to `MAIN_SOME`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a breaking change.

The current behaviour is that spin up -e SOME=var makes SOME appear in all components of the application. The proposed new behaviour would require separate THING_SOME for each component.

We should consider instead having Spin auto-munge -e variables so that they are applied to all components.

(If we do intentionally want to make -e selective - which should probably be a separate discussion - I'd look to decouple the external representation from the internal one. At the moment, we can change the internal representation as we see fit: I'd be very cautious about making it contractual.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another potential woe:

Imagine two components, alice and bob. Both import carol.

With the current scheme, the syntax for setting environment variables on carol would be spin up -e CAROL_WHATEVER=thingy.

But there are two carols in play. We don't want to couple them together. So we need further disambiguation at the CLI. (Of course this is not an issue for the internal representation, as it's scoped to a particular composition).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the isolator is only in play for components with at least one dependency, this suggests that SOME=var will work for components without dependencies? I'm not enthusiastic about having to guess which components have dependencies and customise syntax for them alone.


#### Stage 2: Isolator component (composition time)

At composition time, Spin generates and wires in a synthetic WebAssembly component — the **env-isolator** — that interposes on each component's `wasi:cli/environment` import. For each component in the composition, the isolator:

1. Intercepts calls to `get-environment`.
2. Filters the flat environment to only those keys matching the component's prefix.
3. Strips the prefix before returning the key-value pairs.

The result is that each component sees only its own environment variables, with unprefixed keys, as if those were the only variables that existed.

The isolator also passes through `get-arguments` and `initial-cwd` without modification, since these are process-level values that do not benefit from per-component filtering.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My first reaction is "why am I letting Semi-Trusted Steve scope out MY argument list" but I may be misunderstanding - is the idea that I can still block access via the normal "no inherit config" mechanism, but if I grant access then there is no point customising? If so then no worries.


Components that do not import `wasi:cli/environment` are skipped; no isolator is wired up for them.

### Activation rule

Environment variable isolation is activated **automatically** for components with at least one dependency. When active, **all** components in the composition are isolated — including the main component. A component with no declared `environment` will see an empty environment when isolation is active.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A component with no declared environment will see an empty environment when isolation is active.

This is the same as the current behaviour right? I am not quite sure what you are seeking to highlight here.


## Implementation

The implementation spans five crates:

### `spin-env-isolator` (new crate)

A new `crates/env-isolator/` crate containing the core Wasm generation logic. It has no dependency on Spin's application model and can be tested independently. It provides:

- **`compute_prefix(id: &str) -> String`** — The shared prefix derivation function used by both the composition pipeline and the runtime factor, guaranteeing agreement.

- **`isolator::generate_isolator(targets, wasi_env_version) -> Vec<u8>`** — Generates the isolator component. Each `IsolationTarget` specifies a name and prefix. The generated component imports `wasi:cli/environment@{version}` and exports flat functions (`environment-{name}-get-environment`, `environment-{name}-get-arguments`, `environment-{name}-get-cwd`) for each target.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we manage evolving this over time? That is, if a new WASI preview comes out, how are we reminded to update this?

How does it interact with version compatibility stuff? That is, do we need to recognise minor versions or only major versions? (or whatever WIT calls them, you know, the things where you can use 4.0.0 and 4.1.0 interchangeably apart from the since stuff)


- **`wrapper::build_env_wrapper_component(name, wasi_env_version) -> Vec<u8>`** — Generates a small adapter component per target that imports the flat functions from the isolator and re-exports them bundled as a `wasi:cli/environment` instance, which can be wired into the target component's import. Note that this wrapper component only contains component model instructions, no core WebAssembly bytecode, and is fully erased at link time without leaving overhead.

- **`core_module`** — Generates the core Wasm module that performs the actual byte-level prefix matching and stripping in linear memory, operating on the canonical ABI representation of `list<tuple<string, string>>`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gazed at this for a while trying to get my head around the relationship between isolator and wrapper and core_module and I think I understand why we need wrapper but I'm not getting the distinction between core_module and isolator. Perhaps expand on this or draw a diagram, or possibly drop this section as more detail than we need at this level?


### `spin-manifest`

- The `ComponentDependency` enum gains an `environment: Map<String, String>` field on its `Package`, `Local`, `HTTP`, and `AppComponent` variants.
- A `ComponentDependency::environment()` accessor method is added.
- In `normalize.rs`, `ensure_is_acceptable_dependency` is relaxed to allow dependency components to declare `environment` (other restrictions — files, networking, key-value, etc. — remain).
- During normalisation of `AppComponent` references, the referenced component's `environment` is merged with the dependency spec's `environment` (spec values take precedence), and the merged result is carried forward into the concrete dependency variant.

### `spin-loader`

- `load_component` is changed to apply prefixes to all components' environment variable names if at least one dependency exists.

### `spin-compose`

- For components with at least one dependency, it:
1. Detects the WASI `cli/environment` version from component imports.
2. Generates the isolator component via `spin_env_isolator::isolator::generate_isolator`.
3. For each target, generates a wrapper component via `spin_env_isolator::wrapper::build_env_wrapper_component`.
4. Wires isolator → wrapper → target in the composition graph, replacing each target's `wasi:cli/environment` import.

## Example

The following complete example demonstrates the feature. Given two components — `main` (an HTTP handler) and `dependable` (a library) — where each reads environment variables:

**`spin.toml`:**

```toml
spin_manifest_version = 2

[application]
name = "env-isolator-hello"
version = "0.1.0"

[[trigger.http]]
route = "/..."
component = "main"

[component.main]
source = "target/wasm32-wasip2/release/main.wasm"
environment = { GREETING = "hello from main", MAIN_ONLY = "only visible to main" }

[component.main.build]
command = "cargo build --target wasm32-wasip2 --release"

[component.main.dependencies."hello:components/dependable"]
component = "dependable"
environment = { GREETING = "hello from dependable", DEPENDABLE_ONLY = "only visible to dependable" }

[component.dependable]
source = "target/wasm32-wasip2/release/dependable.wasm"

[component.dependable.build]
command = "cargo build --target wasm32-wasip2 --release"
```

Both `main` and `dependable` call `std::env::vars()`. A request to `http://127.0.0.1:3000/` returns:

```
main's env vars: GREETING='hello from main', MAIN_ONLY='only visible to main'
dependable's env vars: GREETING='hello from dependable', DEPENDABLE_ONLY='only visible to dependable'
```

Each component sees only its own variables. The shared key `GREETING` has a different value for each, and `MAIN_ONLY` / `DEPENDABLE_ONLY` are invisible to the other component.

## Design decisions

### Automatic activation

Environment isolation is activated implicitly when any component in a composition imports `wasi:cli/environment`, without an opt-out. This choice follows from the goal to make safe behavior the default or even enforced, but could be weakened by providing an opt-out.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think care is needed here. Currently dependencies_inherit_configuration = true gives a dep access to all the composition's EVs. It's not clear to me what the semantics are after this change.

The combinations (and my guess at behaviour) are:

  1. "inherit = false" and no env section(s) - no EVs for dep (isolator in play but blocking everything)
  2. "inherit = false" and yes env section(s) - only specified EVs, isolator in play
  3. "inherit = true" and no env secition - ??? all inherited EVs as current ???
  4. "inherit = true" and yes env secition - ??? all inherited EVs and all dep EVs??? ???

But I am not quite sure what you envision for scenarios 3 and 4. If we change scenario 3 then it's breaking (which I'm not opposed to, we just need to plan and communicate).


### Prefix is not user-configurable

The prefix is derived deterministically from the component or dependency name. Exposing the prefix to users would add configuration surface for an implementation detail that has no user-visible effect (components never see the prefix). Keeping it internal simplifies the UX and prevents misconfiguration.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it should be user-configurable but the stuff about spin up -e gives me the impression that it does have a user-visible effect. (But as mentioned above I think we should avoid it having a user-visible effect.)


### Only environment variables are isolated

This proposal isolates only `wasi:cli/environment`. Other WASI capabilities (filesystem, networking, etc.) continue to be managed by the existing `dependencies_inherit_configuration` / deny-all adapter mechanism from SIP 020. Isolating those would require different interposition strategies and is out of scope.

### Backward compatibility

There are two compatibility-breaking aspects to this change:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, should have read ahead before leaving those comments!

1. The main component's `environment` isn't visible to dependencies anymore, so existing applications that assume it is won't work as expected.
2. Environment variables provided dynamically, e.g. using the `spin up -e` need to be prefixed to be exposed to specific components.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted above I do not love this one especially if the way we do it couples the internal representation to the UI.


At the purely syntactical manifest level the change is backwards-compatible: the `environment` table in a dependency spec is optional, as is the `env` table in the lock file.

## Future possibilities

- **Template expressions in environment.** The `environment` field currently accepts only static strings. A future enhancement could allow Spin template expressions (e.g. `{{ my_variable }}`), leveraging the existing variables/expressions system for dynamic configuration.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature is completely independent of isolation though isn't it?


- **Transitive dependency isolation.** The current implementation handles only direct dependencies. As the component model ecosystem matures and compositions become deeper, extending isolation to transitive dependencies may become necessary.
5 changes: 4 additions & 1 deletion docs/content/sips/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,7 @@ Here is a list of accepted SIPs:
- [Support for Inbound WebSockets](../sips/016-inbound-websockets.md)
- [Application-Internal Service Chaining](../sips/017-service-chaining.md)
- [Adding OTel tracing to Spin](../sips/018-adding-otel-tracing-to-spin.md)
- [Spin Governance](../sips/019-governance.md)
- [Spin Governance](../sips/019-governance.md)
- [Component Dependencies](../sips/020-component-dependencies.md)
- [Spin Factors](../sips/021-spin-factors.md)
- [Environment Variable Isolation for Composed Components](../sips/022-environment-variable-isolation.md)