Skip to content

plugins: zc_env_read allowlist — restrict plugin access to env vars #5919

@JordanTheJet

Description

@JordanTheJet

Context

The zc_env_read host function exposed to WASM plugins (added in #5913 for Phase 2 D2) lets any plugin with the env_read permission call std::env::var(name) for any variable by name. A plugin granted env_read to read its own API key (e.g. FAL_API_KEY) can, with the same permission, read:

  • OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, CARGO_REGISTRY_TOKEN, DATABASE_URL
  • Session cookies, SSH agent sockets, any other secret in the process environment

An env_read permission grant "I need my own API key" escalates to "read every secret in the process."

WASM plugins are third-party binaries. The permission grant in the manifest should describe which env vars the plugin can read, not just that it can read env vars.

Surfaced in PR #5913 review (advisory, not blocking).

Target

Three candidate designs, not mutually exclusive:

  1. Per-plugin manifest allowlist (preferred — most explicit):

    permissions = [\"env_read\"]
    env_read_vars = [\"FAL_API_KEY\"]

    Enforced in handle_env_read: if var_name not in the allowlist, return permission-denied.

  2. Namespace convention (simplest):
    Plugins can only read variables with a prefix like ZC_PLUGIN_<name>_* or ZC_PLUGIN_SHARED_*. Users set ZC_PLUGIN_IMAGE_GEN_FAL_API_KEY instead of FAL_API_KEY. Host rejects anything outside the prefix.

  3. Host-provided key store indirection (most robust, biggest scope):
    Plugins don't read env at all. Instead, the host exposes zc_secret_get(handle) where handle is pre-bound to a specific secret by the operator in [plugins.<name>] config. Plugins never see raw env var names.

Option 1 has the lowest scope-cost and the clearest migration story: existing manifests keep working (empty env_read_vars → deny-all if env_read permission is present), and plugins opt into specific names.

Why it matters

  • Secret exfiltration — a well-intentioned plugin can be modified by an attacker (pre-signature, or via a compromised build) to harvest every secret in the environment the moment it's installed.
  • Trust boundary alignment — the manifest is the operator's trust contract. Right now it lies by omission: "env_read" reads like "reads its own config"; it actually means "reads any process-global secret."
  • Signature verification default is disabled — until signature_mode = strict is the default (which it cannot reasonably be during bootstrap), the manifest is the operator's primary tool. It needs to be precise.

Current state

  • zc_env_read host function calls std::env::var(&var_name) unconditionally after checking the env_read permission bit.
  • PluginManifest has no field for describing which env vars a plugin needs.
  • ADR-003 §Threat model calls out this gap explicitly.
  • The only plugin that exists so far (image-gen-fal) reads exactly one var (FAL_API_KEY), so retrofitting option 1 is trivial.

Scope out

OAuth flows, secret rotation, encrypted secret storage — separate larger scope. This issue is just: bound the name space that env_read can read from.


Referenced from ADR-003. Parent RFC: #5574. Introduced by #5913.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority:p1High priorityrisk: highAuto risk: security/runtime/gateway/tools/workflows.securityAuto scope: src/security/** changed.status:acceptedRFC or work item accepted and ratified by the team.status:no-staleExempt from the 60-day stale auto-close policy.

    Type

    No type

    Projects

    Status

    Backlog

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions