NEP-0005
The environment permit: a child's environment is composed, never inherited
Draft · Standards Track · opened 2026-07-23 · 1491 words
NEP-0005 · The environment permit: a child's environment is composed, never inherited #
- NEP: 0005 (next free integer · 0001 reserved for the v1 surface · 0002 the trifecta gate · 0003 absent permits · 0004 the parameterization taint)
- Title:
env:is a permit category: the child process environment is composed from a published floor plus the declared passthrough, never inherited - Author: Thibaut Melen (SuperNovae Studio)
- Status: Draft
- Type: Standards Track
- Created: 2026-07-23
Abstract #
NEP-0003 made the absent boundary fail-closed and NEP-0004 made the
present boundary honest against attacker-controlled values. This NEP
closes the one capability plane the permits: block still does not name:
the process environment. Cloud and cluster credentials live in
environment variables, and today a spawned subprocess inherits the
engine's ENTIRE environment minus a dangerous-name denylist, so one
allowlisted program can read AWS_SECRET_ACCESS_KEY without a single
declared grant. env: becomes a permit category. A child process
environment is COMPOSED: the runner env floor (a fixed, spec-published
name list) ∪ the declared env: passthrough (exact names, resolved from
the engine's environment at spawn) ∪ the task's explicit env: map
(authored values) − the dangerous-name floor. Inheritance is dead: a
workflow that declares no env: spawns children that see the floor and
nothing else.
Motivation #
HF incident stage 3: the pivot from one compromised process to the
platform's cloud account rode ambient credentials in the environment.
A denylist scrub (the engine's dangerous-name floor) kills the INJECTION
class (LD_PRELOAD, BASH_ENV, interpreter hooks) and can never kill
the DISCLOSURE class: no list enumerates every secret-bearing name an
operator exports. Deny-by-default with a declared passthrough is the only
shape that contains the credential plane, and it is the exact posture the
engine's MCP stdio spawn already runs as an implementation choice
(env_clear plus a curated 8-name passthrough). This NEP promotes that
posture from an engine detail to the LAW of every child, governed by the
file: the permits: block finally names the reach that credentials ride.
Specification #
Definitions (normative · closed):
- CHILD PROCESS · any OS process an engine spawns on behalf of a
workflow: the
execverb's subprocess (argv and shell forms) and a stdio tool server (mcp:*). The engine process itself is not a child:inferprovider calls, first-party builtins, and the in-engine composition of a child WORKFLOW (14-composition) spawn nothing here. - RUNNER ENV FLOOR · the fixed name list
PATH,HOME,TMPDIR,LANG,LC_ALL,TZ,USER,LOGNAME. A loader path, a home for caches, scratch, locale and timezone: what a child needs to RUN, and nothing that names a credential. The list is a normative MAXIMUM: an engine MUST NOT pass any undeclared name beyond it and MAY pass fewer. - DANGEROUS-NAME FLOOR · the injection-vector names an engine strips
unconditionally and LAST: dynamic-linker injection, shell startup
sourcing, tool command hooks, interpreter pre-exec hooks, and
field-splitting (
IFS). The engine's canonical list (DANGEROUS_ENV_VARS) is the reference; no grant overrides it. - `env:` CATEGORY · a list of exact environment variable names. A
name MUST match the POSIX shape
[A-Za-z_][A-Za-z0-9_]*. No globs. A name is a permit BOUND: LAW-AUTH-0325 literality applies, so an interpolation in the list is a hard refusal (NIKA-AUTH-007). - COMPOSED ENVIRONMENT · floor-resolved ∪ passthrough-resolved ∪ the
task-level
env:map, minus the dangerous-name floor. Resolution reads the ENGINE's environment at spawn time; a declared name absent from the engine environment passes nothing (no error, no empty-string synthesis).
The law (MUST/SHOULD):
- A child process environment MUST be composed, never inherited. With no
env:category (or nopermits:block at all · NEP-0003), the child sees at most the runner env floor plus the task's explicitenv:map. - A declared
env:list passes exactly the named engine variables to every child process of the workflow. Presence in the list grants REACH; the engine environment supplies the value or nothing. - A name on the dangerous-name floor is never passable: an
env:entry naming one is an inert dead grant and MUST be flagged at check (NIKA-AUTH-009· security_error), and a task-map entry naming one is stripped last (the floor wins, today's behavior kept). - An
env:bound MUST be a literal exact POSIX name: interpolation in the list isNIKA-AUTH-007(LAW-AUTH-0325 covers every bound, this category included); a non-name string is a parse-level refusal. - Under composition (14) the effective category is the meet: child ∩ parent, exact-name intersection. An absent parent block still means zero (NEP-0003): no child obtains a passthrough its parent could not grant.
- The task-level
env:map (02-verbs §exec) stays authored data: values the file carries, visible at review distance, applied AFTER the passthrough (an authored entry wins over an inherited one on the same name), still under the dangerous-name floor. Nothing about its grammar changes. env:is not inferable. Permit-inference tooling MUST NOT invent the list: a subprocess's environment reads are opaque to static analysis. The undeclared-read failure mode is the child tool's own missing-variable error; an engine SHOULD document the one-line repair (env: [NAME]) next to that story.
Registry and prose touched:
- LAW-AUTH-0326 is added to
canon/laws/authority.yaml(status active) · the machine row of this law. spec/01-envelope.md§permits gains theenvcategory row, the sample line, and « The environment category (normative) » ·spec/02-verbs.md§execenv:gains the composed-never-inherited pointer ·spec/10-authority.md§the permit-parameterization taint counts env names among the literal bounds.canon/diagnostics/registry.yamlgainsNIKA-AUTH-009(the AUTH family carries the defects of the boundary block itself: 006 absent · 007 non-literal bound · 008 re-gate escape · 009 dead grant; the SEC family stays the run-time floor and flow classes); theNIKA-AUTH-007teaching names env alongside host/glob/program.- Implementation surfaces: the schema parser (the strict
permits:grammar) · the check (dead-grant flag, bound literality, composition containment) · the spawn sites (the exec runner's clean slate, the MCP stdio scrub's declared allowlist) · the permits algebra (the meet).
Conformance test #
Four static pairs ship in conformance/tests/core/authority/ (014-017,
continuing the NEP-0004 suite) plus two behavioral contracts
(runtime/permits/005-006 · the reserved tier: the executable proof
lands engine-side, one canary flipped by one declared line).
Engine/reference differential per LAW-AUTH-0319.
Compatibility impact #
NOT additive at run for a workflow whose subprocess reads an undeclared
ambient variable · additive for every file in the reference corpora
(census at engine 042aeaf95 and spec 38614d2: zero engine, spec, or
example workflow reads ambient env in a command). The break surfaces as
the child tool's own missing-variable error, and the repair is one
declared line. The grammar change is additive (env: is a new optional
key under permits:; the nika: v1 marker is untouched).
Migration plan #
What changes for you. A subprocess no longer sees the engine's
environment. It sees the runner env floor (PATH, HOME, TMPDIR,
LANG, LC_ALL, TZ, USER, LOGNAME), what your task's env: map
sets, and the variables your permits.env: names, nothing else:
permits: exec: ["terraform"] env: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_DEFAULT_REGION"]Each passed credential is one greppable line in the file that IS the
blast radius. A tool failing with « VARIABLE not set » after this change
is the law working: add the name to permits.env:, or set the value in
the task's env: map when it is authored data. Two check errors teach
the block's own defects:
error[NIKA-AUTH-007]: permit bound `env[0]` is interpolated, not literal · a bound is the wall itself: write the exact variable nameerror[NIKA-AUTH-009]: permit entry `env: ["LD_PRELOAD"]` is an inert dead grant · the dangerous-name floor strips this name unconditionally: the grant can never take effect · remove itNothing else changes: the verbs, the other categories, the task env:
map, and the nika: v1 marker are untouched.
Rejected alternatives #
- Deny-list only (the status quo) · rejected: a denylist kills the injection class and structurally cannot kill the disclosure class; no list enumerates every secret-bearing name.
- Glob passthrough (`AWS_*`) · rejected for v1: the glob that matches the harmless id also matches the secret; exact names keep one line = one credential at review distance. A glob arm can only enter by NEP.
- Values inside the permit (`env: {KEY: value}`) · rejected: values are the task map's job (02-verbs); a permit names REACH, never data.
- Per-task `env:` permits · rejected for v1: the block is the workflow's blast radius (01 §permits posture); task-grain scoping arrives with the narrowing story, not here.
- Synthesizing empty strings for absent names · rejected: inventing a value masks a deployment defect; absence propagates honestly.
- Refusing the run when a child reads an undeclared variable · rejected: environment reads happen inside the child, unobservable in general; composition is enforceable, interception is not. Composing the variable OUT is the enforceable truth, and the child's own error is the honest signal.
- Exec-only scope (leaving `mcp:*` servers on the curated list) · rejected: one law for every child, or the hole migrates from the verb to the tool server.