Alias a value held from process.env to the live environment

`const env = process.env` compiled the hold to a snapshot record copy of
the environment, so `env.X = "1"` wrote the copy and was lost while Node's
write reached the real environment table (undefined natively, "1" in Node;
`repros/d3-env-write.ts`). Node's held value IS the environment object.

A hold whose type is the env record now aliases the live table for every
member access: dotted and computed reads lower to process.envGet, writes
to process.envSet, `delete` to process.envUnset, and the absence probe
(`env[k] ?? fallback`, `||`, `if`) reads getenv instead of reconstructing
presence over the snapshot. A BARE use of the hold (spread, Object.keys,
argument, return) takes a fresh %env.snapshot at that site, so it observes
the environment as it is there rather than as it was when the binding was
initialized.

Two holds keep copy semantics because they cannot alias safely, and a
write through either is refused with SC2014 rather than lost: a hold that
escapes into another function, and a hold whose declared type is not the
env record. Compound and update writes through a hold (`env.X += v`,
`env.X++`) are refused the same way.

tests/corpus/held-env-value-writes.ts covers writes, computed writes,
deletes, child inheritance, direct writes seen through the hold, a copy
staying a copy, and bare-use re-snapshotting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:39:20 -07:00
co-authored by Claude Opus 5.5
parent 02d7230c66
commit 3fac2e5766
12 changed files with 314 additions and 9 deletions
@@ -57,6 +57,7 @@ export const DIAGNOSTIC_CODES = {
SC2011: "'any'-typed values and their operations",
SC2012: "standard-library form without native lowering",
SC2013: "npm package import or value without native lowering",
SC2014: "write through a value held from process.env",
SC2020: "standard-library or @types/node surface with no lowering",
SC3001: "LLVM IR tier refusal",
SC3002: "target or capability refusal",
@@ -768,6 +768,10 @@ export const FENCE_CODES: Record<string, { name: string; status: "unsupported" }
name: "npm package imports and values without native lowering",
status: "unsupported",
},
SC2014: {
name: "writes through a value held from process.env (a snapshot, not the live table)",
status: "unsupported",
},
SC2020: {
name: "standard-library or @types/node surface with no lowering",
status: "unsupported",
@@ -1114,6 +1118,22 @@ export function computedRequireDiag(surface: string, loc: SrcLoc): ScrDiagnostic
};
}
/** A write through a value HELD from `process.env` (`const env =
* process.env; env.X = "1"`, `delete env[k]`): the held value is a snapshot
* record of the environment, not the live table, so the write would land on
* the copy and be lost while Node's write reaches setenv(3) through the same
* object. Refuse at the write instead of letting it disappear. */
export function heldEnvWriteDiag(loc: SrcLoc): ScrDiagnostic {
return {
code: "SC2014",
message:
"a write through a value held from process.env is not supported: the held value is a snapshot copy of the environment, so the write would be lost while Node writes the real environment",
loc,
milestone: "later",
hint: "read and write the live variable directly (process.env.NAME or process.env[key]), or write into a fresh copy: const env = { ...process.env, NAME: value }",
};
}
/** Records are monomorphic structs, so a value's shape must match the
* expected shape exactly OR narrow through the width-copy family
* (recordWidthPlan / the overflow capture: target fields copied off the
@@ -1,7 +1,8 @@
import { dynUndefinedExpr } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { type Lowerer, PoisonError } from "../lowerer.js";
import { locOf } from "../../program.js";
import { heldEnvWriteDiag } from "../../../diagnostics/diagnostic.js";
import { pairsSnapshotHelper } from "../pairs-snapshot.js";
import {
DYN,
@@ -49,6 +50,111 @@ export function isProcessEnv(lowerer: Lowerer, node: ts.Expression): boolean {
);
}
/** Whether `node` is the ambient `process.env` object, or a value HELD from
* it through a chain of bindings: `const env = process.env`, `const alias =
* env`, or the destructured `const { env } = process`. Node's held value IS
* the environment object. Here the binding holds a snapshot record of it:
* MEMBER accesses (`env.X`, `env[k]`, their writes, `delete env.X`) reroute
* to the live table (see heldEnvValueType), and every BARE use re-snapshots
* the environment at that use (see lowerIdentifierExpr), so both observe
* the environment as Node's single object would. */
export function holdsEnvSnapshot(
lowerer: Lowerer,
node: ts.Expression | undefined,
depth = 0,
): boolean {
if (node === undefined) return false;
let inner: ts.Expression = node;
for (;;) {
if (
ts.isParenthesizedExpression(inner) ||
ts.isAsExpression(inner) ||
ts.isNonNullExpression(inner) ||
ts.isSatisfiesExpression(inner)
) {
inner = inner.expression;
continue;
}
break;
}
if (isProcessEnv(lowerer, inner)) return true;
if (!ts.isIdentifier(inner) || depth >= 4) return false;
const symbol = lowerer.checker.getSymbolAtLocation(inner);
const decl = symbol?.valueDeclaration ?? symbol?.declarations[0];
if (decl === undefined) return false;
// `const env = process.env` (or an identifier alias of one). The symbol's
// value declaration is the `const` itself; the written identifier here is
// a different node than the declared name, so no node identity check.
if (ts.isVariableDeclaration(decl)) {
return holdsEnvSnapshot(lowerer, decl.initializer, depth + 1);
}
// `const { env } = process` (also `{ env: held }`).
if (ts.isBindingElement(decl) && ts.isObjectBindingPattern(decl.parent)) {
const declaration = decl.parent.parent;
const named = decl.propertyName ?? decl.name;
if (
ts.isVariableDeclaration(declaration) &&
ts.isIdentifier(named) &&
named.text === "env" &&
declaration.initializer !== undefined
) {
return lowerer.isStdlibGlobal(declaration.initializer, "process");
}
}
return false;
}
/** The receiver expression of a write target — the assignment's or delete's
* access (`env.X`, `env[k]`), through any compound/update wrapping. Null for
* targets that write no entry of a receiver. */
function writeTargetReceiver(node: ts.Node): ts.Expression | null {
if (ts.isBinaryExpression(node)) return writeTargetReceiver(node.left);
if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) {
return writeTargetReceiver(node.operand);
}
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
return node.expression;
}
return null;
}
/** Whether a MEMBER access (`env.X`, `env[k]`, and their writes) through a
* value HELD from `process.env` can go to the live environment table: the
* held binding's type must be exactly the env snapshot record (a pure
* index-signature shape whose value slot is `string | undefined`, what
* `process.env` maps to). Returns that value type, or null — a hold whose
* type differs keeps the snapshot record for reads and refuses writes (see
* refuseHeldEnvWrite). Member accesses alias the live table the way Node's
* do. A BARE use of the held value (a spread source, Object.keys, an
* argument, a return) is not a member access and takes a fresh snapshot of
* the environment at that use site (lowerIdentifierExpr). */
export function heldEnvValueType(lowerer: Lowerer, receiver: ts.Expression): IrType | null {
if (!holdsEnvSnapshot(lowerer, receiver)) return null;
const mapped = lowerer.mapTypeOf(lowerer.typeOf(receiver));
if (mapped?.kind !== "record") return null;
const shape = lowerer.shapes.get(mapped.shapeId);
if (!shape || shape.tuple || shape.fields.length > 0 || shape.indexValue === undefined)
return null;
const value = lowerer.envValueType();
return typeEquals(shape.indexValue, value) ? value : null;
}
/** Refuse a write through a value HELD from `process.env` that cannot take
* the live path: the compound and update forms (`env.X += v`, `env.X++`)
* reach this wrapper, and so does any write through a hold whose type is
* not the env record. The held value is a snapshot record, so such a write
* would land on the copy while the real environment kept its old value;
* Node writes setenv(3) through the very same object, so a program relying
* on it would silently diverge. Plain `=`, computed-key and `delete` writes
* through the env record alias the live table instead (lower-stmts.ts,
* element-access.ts); `target` is the write's or delete's access node. */
export function refuseHeldEnvWrite(lowerer: Lowerer, target: ts.Node): void {
const receiver = writeTargetReceiver(target);
if (receiver === null || !holdsEnvSnapshot(lowerer, receiver)) return;
lowerer.pushDiag(heldEnvWriteDiag(locOf(target)));
throw new PoisonError();
}
/** The interned `string | undefined` union — the type every env read
* produces (withUndefinedArm interns [string, undefined] in canonical
* order, so the string arm's tag is 0 and the undefined arm's is 1,
@@ -32,6 +32,7 @@ import {
unionContainerArmsOk,
} from "../../../ir/ir.js";
import { importMetaField, isImportMeta } from "../import-meta.js";
import { heldEnvValueType } from "../builtins/environment.js";
import { hasOptionalChainGuard, isOptionalChainTail } from "./optional-chains.js";
import { lowerEnumAccess } from "../lower-enums.js";
import { fenceNodeModuleMutation, lowerRequireCacheElement } from "../lower-node-module.js";
@@ -234,6 +235,25 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
};
return lowerer.maybeNarrow(get, expr);
}
// `held[expr]` for a value HELD from process.env (`const env =
// process.env`): the hold aliases the live table, so the read is the same
// getenv the direct spelling uses.
{
const live = heldEnvValueType(lowerer, expr.expression);
if (live !== null) {
const key = lowerEnvironmentKey(lowerer, expr.argumentExpression);
if (key.type.kind === "string") {
const get: IrExpr = {
kind: "libCall",
fn: "process.envGet",
args: [key],
type: live,
loc: locOf(expr),
};
return lowerer.maybeNarrow(get, expr);
}
}
}
// Expando function members in element clothing (`foo[strMem]`,
// `foo[_private]` where foo is a module-level function/callable const
// and the key folds or is a unique-symbol const): the member's module
@@ -2217,8 +2237,14 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression):
};
}
// `process.env[key] = v` — the computed twin of the dotted env write:
// setenv(3), string keys and string values only.
if (lowerer.isProcessEnv(target.expression) && !target.questionDotToken) {
// setenv(3), string keys and string values only. A key write through a
// value HELD from process.env takes the same path: the hold aliases the
// live table.
if (
!target.questionDotToken &&
(lowerer.isProcessEnv(target.expression) ||
heldEnvValueType(lowerer, target.expression) !== null)
) {
const loc = locOf(expr);
const key = lowerEnvironmentKey(lowerer, target.argumentExpression);
if (key.type.kind !== "string") {
@@ -95,6 +95,33 @@ import { fenceEnumObjectValue } from "../lower-enums.js";
import { mixinFnOfCallee } from "../lower-mixins.js";
import { isNavigatorGlobal, navigatorValue } from "../builtins/navigator.js";
import { moduleFileName } from "../import-meta.js";
import { envSnapshotHelper, heldEnvValueType } from "../builtins/environment.js";
/** Whether this identifier is the RECEIVER of a member access — the dotted
* or bracketed `held.X` / `held[k]`, read or written, through any erasure
* wrapper. Those uses answer through the live environment table themselves
* (heldEnvValueType); every other use of a held value is a bare one and
* reads the environment as it is at that site. */
function memberReceiverUse(expr: ts.Identifier): boolean {
let node: ts.Node = expr;
for (;;) {
const parent: ts.Node | undefined = node.parent;
if (parent === undefined) return false;
if (
ts.isParenthesizedExpression(parent) ||
ts.isAsExpression(parent) ||
ts.isNonNullExpression(parent) ||
ts.isSatisfiesExpression(parent)
) {
node = parent;
continue;
}
return (
(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) &&
parent.expression === node
);
}
}
export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: SrcLoc): IrExpr {
if (lowerer.isStdlibGlobal(expr, "console")) {
@@ -183,6 +210,24 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
// when the checker types this USE as a single arm, maybeNarrow
// bridges the tagged representation with a unionNarrow.
const local = lowerer.resolveLocal(expr);
// A BARE use of a value HELD from process.env (`{ ...env }`, Object.keys,
// an argument, a return) reads the environment as it is HERE: Node's hold
// IS the environment object, so its contents at use time are the live
// table's. Member accesses (`env.X`, `env[k]`, their writes) reroute to
// getenv/setenv directly (heldEnvValueType) and never come through a bare
// read, so they materialize no snapshot; every other use takes a fresh
// one at this site instead of the copy the hold was given.
{
const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr));
if (
mapped?.kind === "record" &&
!memberReceiverUse(expr) &&
heldEnvValueType(lowerer, expr) !== null
) {
const helper = envSnapshotHelper(lowerer, mapped.shapeId, loc);
if (helper !== null) return { kind: "call", callee: helper, args: [], type: mapped, loc };
}
}
// A Date local holding its time value reads as that Date: only uses
// that read the time value reach here (date-values.ts).
if (local && lowerer.timeValueDateLocals.has(local)) {
@@ -209,6 +209,7 @@ import { isZlibStreamMember, lowerZlibStreamBytesWritten } from "../builtins/com
import { isWebCryptoReceiver } from "../builtins/crypto.js";
import { isFsStreamMember, lowerFsStreamProperty } from "../builtins/fs-streams.js";
import { keyedFieldRead } from "./keyed-field-targets.js";
import { heldEnvValueType } from "../builtins/environment.js";
import { promisePrototypeValue } from "../thenables.js";
import { lowerChildPropertyRead } from "../handle-properties.js";
@@ -4441,6 +4442,21 @@ export function fieldGetExpr(
// construction), typed as the index value armed with undefined
// (mirroring lowerRecordKeyRead).
if (target.container === "recordOvf") {
// A dot read through a value HELD from process.env (`const env =
// process.env; env.X`): the hold aliases the live table, so the read is
// the same getenv the direct spelling lowers to.
if (ts.isPropertyAccessExpression(blame)) {
const live = heldEnvValueType(lowerer, blame.expression);
if (live !== null) {
return {
kind: "libCall",
fn: "process.envGet",
args: [{ kind: "strLit", value: target.field, type: STRING, loc }],
type: live,
loc,
};
}
}
let obj = target.obj;
if (obj.type.kind === "union" && ts.isPropertyAccessExpression(blame)) {
const present = lowerer.stripUndefinedArm(obj.type);
@@ -110,6 +110,7 @@ import {
} from "./expressions/property-access.js";
import { lowerNullishPair } from "./expressions/binary.js";
import { lowerElementCompoundParts, recordKeyLiteralText } from "./expressions/element-access.js";
import { heldEnvValueType } from "./builtins/environment.js";
import { OBJECT_PROTOTYPE_MEMBERS } from "../shape-inference/domain.js";
export {
DEFERRED_READ_MESSAGE,
@@ -2517,6 +2518,12 @@ export function lowerAbsenceProbe(lowerer: Lowerer, node: ts.Expression): IrExpr
const header = lowerer.lowerHttpHeadersElement(expr);
if (header) return header;
// A keyed read through a value HELD from process.env (`const env =
// process.env; env[k] ?? fallback`) aliases the live table: the ordinary
// lowering is getenv, whose `string | undefined` result already carries
// the missing arm this probe reconstructs for record keys.
if (heldEnvValueType(lowerer, expr.expression) !== null) return null;
const receiverT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression));
if (receiverT?.kind === "record") {
const shape = lowerer.shapes.get(receiverT.shapeId);
@@ -45,6 +45,7 @@ import { isWebStreamValueInJavaScript, typeInvolvesWebStream } from "./builtins/
import * as ts from "../ts7/adapter.js";
import { lowerOptionalIndex } from "./undefined-index.js";
import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js";
import { heldEnvValueType } from "./builtins/environment.js";
import { requireInitFlagStmt } from "./require-initialization.js";
import { isJsonRequireDecl } from "./json-require.js";
import { isTimeValueDateDeclaration } from "./date-values.js";
@@ -7764,9 +7765,11 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
return { kind: "strLit", value: target.name.text, type: STRING, loc: locOf(target.name) };
}
const keyNode = (target as ts.ElementAccessExpression).argumentExpression;
let key = lowerer.isProcessEnv(target.expression)
? lowerEnvironmentKey(lowerer, keyNode)
: lowerer.lowerExpr(keyNode);
let key =
lowerer.isProcessEnv(target.expression) ||
heldEnvValueType(lowerer, target.expression) !== null
? lowerEnvironmentKey(lowerer, keyNode)
: lowerer.lowerExpr(keyNode);
if (key.type.kind === "dyn") {
key = { kind: "libCall", fn: "dyn.toStringCoerce", args: [key], type: STRING, loc: key.loc };
}
@@ -7782,7 +7785,10 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
}
return key;
};
if (lowerer.isProcessEnv(target.expression)) {
if (
lowerer.isProcessEnv(target.expression) ||
heldEnvValueType(lowerer, target.expression) !== null
) {
return {
kind: "exprStmt",
expr: { kind: "libCall", fn: "process.envUnset", args: [lowerKey()], type: VOID, loc },
@@ -8710,7 +8716,14 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm
// (Node stringifies everything; a non-string RHS fences instead
// of diverging from Node). The computed-key form lands in
// lowerElementWrite.
if (lowerer.isProcessEnv(expr.left.expression) && !expr.left.questionDotToken) {
// The same setenv for a value HELD from process.env (`const env =
// process.env; env.X = v`): the hold aliases the live table, as
// Node's does.
if (
!expr.left.questionDotToken &&
(lowerer.isProcessEnv(expr.left.expression) ||
heldEnvValueType(lowerer, expr.left.expression) !== null)
) {
const loc = locOf(expr);
const key: IrExpr = {
kind: "strLit",
@@ -583,7 +583,12 @@ import {
} from "./builtins/filesystem.js";
import { lowerSpawnSyncCall, lowerSpawnCall, lowerExecFileCall } from "./builtins/child-process.js";
import { lowerExecSyncCall } from "./builtins/child-process-sync.js";
import { isProcessEnv, envValueType, lowerProcessEnvGet } from "./builtins/environment.js";
import {
isProcessEnv,
envValueType,
lowerProcessEnvGet,
refuseHeldEnvWrite,
} from "./builtins/environment.js";
import { lowerJsonMethodCall, lowerJsonProperty } from "./builtins/json.js";
import { lowerCryptoComposedCall, lowerCryptoModuleCall } from "./builtins/crypto.js";
import { lowerUrlMethodCall, lowerSearchParamsMethodCall } from "./builtins/url.js";
@@ -16460,6 +16465,7 @@ export class Lowerer {
}
fieldSetStmt(target: FieldTarget, value: IrExpr, loc: SrcLoc, blame: ts.Node): IrStmt {
refuseHeldEnvWrite(this, blame);
return markSloppyRecordWrites(this, fieldSetStmt(this, target, value, loc, blame), blame);
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/held-env-value-writes.ts"
],
"diags": []
}
+58
View File
@@ -0,0 +1,58 @@
// A value HELD from process.env aliases the live environment, exactly as
// Node's one process.env object does: writes, deletes and reads through the
// hold reach the real environment table (setenv/unsetenv/getenv), so a
// later direct read, `in` test, and a spawned child's inherited environment
// all observe them. A COPY made from the hold stays a copy.
import { spawnSync } from "node:child_process";
const env: Record<string, string | undefined> = process.env;
env.SCRIPTC_HELD_WRITE = "one";
console.log("direct:", process.env.SCRIPTC_HELD_WRITE ?? "(unset)");
console.log("held:", env.SCRIPTC_HELD_WRITE ?? "(unset)");
// A computed key writes the same variable.
const key = "SCRIPTC_HELD_" + "computed";
env[key] = "two";
console.log("computed:", process.env[key] ?? "(unset)", env[key] ?? "(unset)");
// The write reaches children through the inherited environment.
const child = spawnSync("sh", ["-c", 'printf %s "$SCRIPTC_HELD_WRITE"'], { encoding: "utf8" });
console.log("child sees:", child.stdout);
// A write made directly is visible through the hold (one object, not a copy).
process.env.SCRIPTC_HELD_LATE = "late";
console.log("late via hold:", env.SCRIPTC_HELD_LATE ?? "(unset)");
// Deletes through the hold unset the real variable.
delete env.SCRIPTC_HELD_WRITE;
console.log(
"deleted:",
process.env.SCRIPTC_HELD_WRITE === undefined,
env.SCRIPTC_HELD_WRITE === undefined,
"SCRIPTC_HELD_WRITE" in process.env,
);
delete env[key];
console.log("computed deleted:", process.env[key] === undefined, key in process.env);
// An absent key deletes as a no-op, like JS.
delete env.SCRIPTC_HELD_NEVER;
console.log("absent delete:", process.env.SCRIPTC_HELD_NEVER === undefined);
// A COPY of the hold keeps copy semantics: its writes never touch the
// environment, and its own entries stay. The copy sees the environment as
// it is at the spread, including writes made after the hold.
const copy = { ...env };
copy.SCRIPTC_HELD_COPY = "copy-only";
console.log(
"copy:",
copy.SCRIPTC_HELD_COPY,
process.env.SCRIPTC_HELD_COPY === undefined,
copy.SCRIPTC_HELD_LATE ?? "(unset)",
);
// Every other bare use of the hold reads the environment at that point too.
console.log(
"bare:",
Object.keys(env).includes("SCRIPTC_HELD_LATE"),
JSON.stringify({ ...env }).includes('"SCRIPTC_HELD_LATE":"late"'),
);
console.log("done");
@@ -0,0 +1 @@
{ "plain": 1700, "san": 3300 }