feat(mt#1788): no-unregistered-minsky-env-var ESLint rule + sweep registration of pre-existing reads#1089
Conversation
…istration Add `eslint-rules/no-unregistered-minsky-env-var.js` (severity error) that catches new `process.env.MINSKY_*` reads in src/ not registered in either `environmentMappings` (config-mapped) or `HOOK_ONLY_ENV_VARS` (hook-only) at `src/domain/configuration/sources/environment.ts`. Closes the ADD side of the env-var-namespace-conflict class: - mt#1610 — retired sessiondb config; legacy MINSKY_SESSIONDB_* on Railway hit fail-closed at boot - mt#1624 — pruned the legacy vars - mt#1785 — MINSKY_AUTO_MIGRATE introduced without registration; recovery attempt on Railway crashed config loader The RETIRE/RENAME side is covered by mt#1626 (/plan-task gate criterion h). Together: full contract-lifecycle coverage for MINSKY_* env vars. Implementation: - AST/regex-based extraction of allowlists from environment.ts (ESLint runs under Node, can't import .ts directly). - Scoped to src/**/*.ts. Registration file itself excluded; root config files (drizzle.pg.config.ts) out of scope. - Computed access (process.env["X"]) intentionally not flagged — bracket form is rare and originating incidents all involved bare-identifier access. - Denial message names both allowlists and the auto-mapped config path so the operator sees exactly what would crash the loader. Sweep — 17 pre-existing unregistered names registered in HOOK_ONLY_ENV_VARS with annotations naming the representative read site. Several arguably belong in environmentMappings (e.g., MINSKY_MCP_AUTH_TOKEN → mcp.auth.token) once a proper config-schema slot exists; that promotion is a follow-up. Immediate goal: make the env-var-to-config parser SKIP them so Railway env sets can't crash the loader. HOOK_ONLY_ENV_VARS is now exported (with eslint-disable for no-domain-singleton, since the const is a frozen Set, not a service). 12 rule-tester tests pass. Lint:strict clean. Documentation note added in .minsky/rules/code-style.mdc; compiled outputs regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: unknown
The PR introduces a useful lint guard and sweeps pre-existing env vars, but the rule has several correctness and robustness gaps. Path checks are POSIX-only, effectively disabling enforcement on Windows; allowlist extraction relies on brittle regexes that will miss quoted keys and certain Set member formats; and the rule’s scope doesn’t match the stated “src/**/*.ts” (it also flags .js). Additionally, reading the registration file unguarded at rule load can crash ESLint if the path is missing. Please harden cross-platform path handling, make extraction resilient (or parse AST), align the scope with the spec, and guard I/O. Optionally, improve the error message’s config-path computation to mirror loader behavior. With these fixed, the approach will be solid.
Findings
-
[BLOCKING] eslint-rules/no-unregistered-minsky-env-var.js:1 — Path detection is POSIX-only: rule won’t run (or exclusion won’t match) on Windows paths
The rule scopes itself tosrc/**and excludes the registration file using POSIX literals, which breaks on Windows-style paths. Specifically: -
if (!filename.includes("/src/")) { return {}; }only matches POSIX separators and will not matchC:\repo\src\...(backslashes), so the rule becomes a no-op on Windows dev machines. -
if (filename.endsWith(REGISTRATION_FILE)) { return {}; }compares an absolute path with backslashes to the string"src/domain/configuration/sources/environment.ts"(forward slashes) and will fail to exclude the registration file on Windows if the first guard is ever relaxed.
This creates an OS-dependent enforcement gap and makes local lint behavior diverge from CI. Please normalize filename (e.g., path.normalize) and/or use a cross-platform check (e.g., filename.split(path.sep).includes('src') or a minimatch against **/src/**/*.ts) and build the exclusion with the same separator handling.
- [BLOCKING] eslint-rules/no-unregistered-minsky-env-var.js:63 — Registered set extraction misses quoted object keys in
environmentMappings
The regexconst mappingKeyRe = /^\s*(MINSKY_[A-Z0-9_]+)\s*:/gm;only captures bare identifiers. If anyenvironmentMappingskey is quoted (e.g., to include lowercase or hyphens, or due to TS refactors), it will be silently missed and flagged as unregistered. The comment says the extractor anchors to a "canonical structure", but the codebase already contains quoted keys elsewhere and nothing enforces object key quoting style here.
Safer: match both bare and quoted variants, e.g., /^\s*(?:['\"])?(MINSKY_[A-Z0-9_]+)(?:['\"])??\s*:/gm, or parse with a tolerant JS/TS parser instead of regex. At minimum, add tests that cover a quoted "MINSKY_..." key in environmentMappings to prevent false positives after routine formatting changes.
-
[BLOCKING] eslint-rules/no-unregistered-minsky-env-var.js:77 —
HOOK_ONLY_ENV_VARSextraction fails if the list spills across multiple lines or trailing commas/formatting change
The set-member regex^\s*["'](MINSKY_[A-Z0-9_]+)["']\s*,/gmassumes members are top-level on their own lines with a trailing comma. It will miss: -
The last element in the array literal without a trailing comma.
-
Elements formatted inline (multiple per line) after Prettier or manual edits.
-
Elements with comments after the value (common in this file), which do not end immediately with a comma.
Given the file already uses inline comments next to each entry, this regex will not match members followed by // comments because the comma is not at the end of the token boundary captured. This will cause widespread false positives the next time formatting or comment placement changes.
Prefer a more robust approach: parse the file with a TS/JS parser (e.g., @babel/parser with typescript plugin) and walk the AST to collect:
ObjectExpressionkeys forenvironmentMappings.NewExpression Set([...])elements forHOOK_ONLY_ENV_VARS.
If you must use regex, relax the pattern to allow comments and optional trailing commas, and add tests that reflect the actual environment.ts formatting (including inline comments).
- [BLOCKING] eslint-rules/no-unregistered-minsky-env-var.js:113 — Rule only inspects
MemberExpressionand misses Identifier path var; it cannot ever reportprocess.env.MINSKY_*because it reads the wrong property
In the listener you match aMemberExpressionwhose object isprocess.envand then setconst prop = node.property;and later readconst name = prop.name;andif (!name.startsWith("MINSKY_")) return;. But forprocess.env.MINSKY_FOO, thenode.propertyis the IdentifierMINSKY_FOOonly when the node is the outerMemberExpressionwhose object isprocess.envand property isMINSKY_FOO. Your early guard requiresobj.property.name === 'env'on that same node, which means you're actually looking at the innerMemberExpressionand then reusingnode.propertyfrom the inner node (which is'env'), sonamebecomes'env'and never starts withMINSKY_.
Net effect: no violations will ever be reported for process.env.MINSKY_*. You need to detect a MemberExpression where node.object is itself a MemberExpression that resolves to process.env, and then take the OUTER node.property (the env var name) for checks. As written, prop should be node.property only when the check confirms obj is process.env; otherwise prop must be referenced from the outer node. Add tests that fail with the current code (e.g., the provided invalid cases) — they currently appear to rely on this working but the implementation does not align.
- [BLOCKING] eslint-rules/no-unregistered-minsky-env-var.js:98 — Scope mismatch with stated intent: rule enforces on any file under
src/(including.js), not onlysrc/**/*.ts
The PR description claims the rule is “Scoped tosrc/**/*.ts”, but the implementation only checksfilename.includes("/src/")and does not filter by extension. Given the flat config applies the plugin to both**/*.tsand**/*.js, this will flag.jsfiles undersrc/as well. This is a spec/implementation mismatch and a potential surprise for any generated or transitional JS living undersrc/.
If the intent is truly TS-only, tighten the guard (e.g., check filename.endsWith('.ts') or use a minimatch like **/src/**/*.ts). If the broader scope is intended, update the documentation/comments to reflect that and add tests for a .js file case.
…ope, I/O guard Address minsky-reviewer[bot] R1 (CHANGES_REQUESTED, 2026-05-13T00:48Z): BLOCKING #1 — POSIX-only path detection (Windows-incompatible): Replace `filename.includes("/src/")` and `filename.endsWith("src/...env.ts")` with `path.normalize` + `path.sep` to handle Windows backslash paths. Adds `REGISTRATION_FILE_NATIVE` (separator-rewritten copy) for the exclusion check. BLOCKING #2 — environmentMappings regex misses quoted keys: Relax `mappingKeyRe` from `/^\s*(MINSKY_[A-Z0-9_]+)\s*:/gm` to `/^[ \t]*["']?(MINSKY_[A-Z0-9_]+)["']?[ \t]*:/gm` so a future quoted key shape `"MINSKY_X": ...` is captured. BLOCKING #3 — HOOK_ONLY_ENV_VARS regex fragile around inline comments and trailing-comma omission: Replace `/^\s*["'](MINSKY_[A-Z0-9_]+)["']\s*,/gm` with a lookahead-based variant `/^[ \t]*["'](MINSKY_[A-Z0-9_]+)["'][ \t]*(?=,|\r?$|\/\/|\/\*)/gm` that accepts: trailing comma, end-of-line (last entry), `//` line comment, or `/*` block-comment opener. Matches the file's actual format (every current entry has an inline comment). BLOCKING #4 — REJECTED as false positive: Reviewer claimed the rule reads `node.property` from the inner MemberExpression and so sees `"env"` instead of `MINSKY_*`. This is wrong: the OUTER MemberExpression is `process.env.MINSKY_FOO`, where `node.property` IS `MINSKY_FOO`. The early-return guard only inspects `node.object` (the inner MemberExpression representing `process.env`); the outer's `node.property` is what becomes `name`. Existence proof: the rule flagged 17 real violations on the original sweep, and 13 unit tests pass including the invalid cases. No code change. BLOCKING #5 — Scope mismatch (.js files in src/ also flagged): Add `&& !normalized.endsWith(".ts")` extension check. Spec scopes the rule to `src/**/*.ts`; without the extension filter it was also firing on `.js` files. New test case asserts a `.js` file under src/ is excluded. Plus: I/O safety — wrap `readFileSync` in try/catch with a fail-soft fallback (empty registered set + console.warn). Previously a missing/ unreadable env file would crash the entire ESLint run. 13 tests pass (12 prior + 1 new for .js-exclusion). Lint:strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: unknown
Verified all prior BLOCKING findings were addressed. The rule now normalizes filenames and uses native separators for cross-platform path checks, expands the allowlist extraction to handle quoted keys and varied formatting (including comments and missing trailing commas), and correctly identifies process.env.MINSKY_* by inspecting the outer MemberExpression. Scope has been tightened to src/**/*.ts per intent, with tests updated accordingly. I see no new critical defects introduced by these changes. Event is APPROVE.
Summary
mt#1788 closes the ADD side of the env-var-namespace-conflict class. New
process.env.MINSKY_*reads insrc/must now be registered in eitherenvironmentMappings(config-mapped) orHOOK_ONLY_ENV_VARS(hook-only) atsrc/domain/configuration/sources/environment.ts. The custom ESLint rulecustom/no-unregistered-minsky-env-var(severityerror) catches violations at lint time, before they can ship to Railway and crash the config loader.The class fired in production three times in five days (mt#1610, mt#1624, mt#1785). The RETIRE/RENAME side is covered by mt#1626 (/plan-task gate criterion h). Together: full contract-lifecycle coverage for
MINSKY_*env vars.Key Changes
eslint-rules/no-unregistered-minsky-env-var.js— new rule. AST/regex-based extraction of the two allowlists fromenvironment.tsat rule-load time (ESLint runs under Node and can't import.tsdirectly). Scoped tosrc/**/*.ts. Computed access (process.env["X"]) intentionally not flagged — bracket form is rare and originating incidents all involved bare-identifier access. Denial message includes the auto-mapped config path so the operator sees exactly what would crash the loader.eslint-rules/no-unregistered-minsky-env-var.test.js— 12 rule-tester cases covering valid (registered, non-MINSKY, out-of-scope, registration-file, computed) + invalid (unregistered read, unregistered write, multiple in one file, in-test).eslint.config.js— wires the rule into thecustomplugin map and activates it witherrorseverity.src/domain/configuration/sources/environment.ts— exportsHOOK_ONLY_ENV_VARS(witheslint-disableforno-domain-singleton; the const is a frozen Set, not a service). Adds 17 pre-existing unregistered names toHOOK_ONLY_ENV_VARSwith comments naming the representative read site..minsky/rules/code-style.mdc— one-line entry describing the new rule. Compiled outputs (CLAUDE.md, AGENTS.md, .cursor/rules/) regenerated.Sweep details
17 pre-existing unregistered
MINSKY_*names were swept intoHOOK_ONLY_ENV_VARS:MINSKY_NON_INTERACTIVE,MINSKY_VERBOSE,MINSKY_SHOW_SQL,MINSKY_STATE_DIR,MINSKY_DEPLOY_MEMORY_FILE,MINSKY_MAIN_WORKSPACE,MINSKY_SESSIONDB_POSTGRES_URL(legacy),MINSKY_MCP_AUTH_TOKEN,MINSKY_MCP_MAX_SESSIONS,MINSKY_MCP_PROFILE,MINSKY_MCP_RETRY_AFTER_SECS,MINSKY_MCP_SESSION_IDLE_TIMEOUT_MS,MINSKY_MCP_TOOL_NAMES,MINSKY_MCP_MEMORY_ENRICHMENT,MINSKY_MCP_MEMORY_ENRICHMENT_TIMEOUT_MS,MINSKY_POSTGRES_MAX_CONNECTIONS.Several of these arguably belong in
environmentMappingsonce a proper config-schema slot exists (e.g.,MINSKY_MCP_AUTH_TOKEN→mcp.auth.token). That promotion is a follow-up — the immediate goal is making the env-var-to-config parser SKIP them so Railway env-var sets can't crash the loader.Spec verification
eslint-rules/no-unregistered-minsky-env-var.jsexists. ✓MemberExpressionnodes, matchesprocess.env.MINSKY_*insrc/**/*.ts, excludes the registration file. ✓errorseverity when unregistered. ✓error(line 277 ofeslint.config.js). ✓bun run lintpost-sweep: zero violations. ✓Documentation impact
.minsky/rules/code-style.mdc— one-line entry naming the new rule, where to register, what crashes if you don't.0b361d17-cc83-41dc-a485-0002d7e41e94is the originating record.No backwards-incompatible changes. The rule is additive at lint time; the sweep is additive at the env-loader level (it only EXPANDS the skip set, can't break anything).
Concurrency analysis
(N/A — no check-then-act pattern introduced. The rule is a pure AST walker with no I/O during lint passes; the source-text read at rule-load time happens once per ESLint invocation.)
Live verification
bun run lint: zero violations across the entire repo.bun test ./eslint-rules/no-unregistered-minsky-env-var.test.js: 12 pass / 0 fail.buildworkflow'sLint (strict — fails on any warning)step) is the post-merge check.Related
/plan-taskgate criterion (h): RENAME/RETIRE-side structural fix (sibling to this rule).0b361d17cross-references this rule.