From fd64d3e97965ca6f9d9312673e3cd887a83b9c68 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:37:39 -0500 Subject: [PATCH 1/2] fix(env): keep contract migration plaintext machine-local --- docs/env.md | 22 ++- docs/security/env-secrets-threat-model.md | 2 +- src/cli/env-arguments.ts | 8 - src/commands/doctor.ts | 7 +- src/commands/env.ts | 15 +- src/lib/project-env-config.ts | 76 +++++++- tests/doctor-fix-noninteractive.test.ts | 6 +- tests/env-set-local-contract.test.ts | 207 +++++++++++++++++++--- tests/project-env-config.test.ts | 60 +++++++ 9 files changed, 338 insertions(+), 65 deletions(-) diff --git a/docs/env.md b/docs/env.md index ce271e187..fd061300d 100644 --- a/docs/env.md +++ b/docs/env.md @@ -592,14 +592,15 @@ input and without writing anything: `.hack/hack.env..local.yaml`) instead of the shared repo env file — use it for per-checkout values you don't want to commit. -In a project with `.hack/hack.env.json` and no env layers yet, `hack env set --local` refuses and -writes nothing for a plaintext value, because that project's migration copies `.hack/.env` values to -tracked layers. Use `hack env add` with the same flags, which migrates first and then writes the -machine-local layer. That migration still copies values already in `.hack/.env`, and a `plain_env` -value exported in your shell, to tracked layers. -With `--secret`, the value goes to the secret backend, and a key the contract declares as `keychain` -reaches a tracked layer as ciphertext when the project migrates. Without `hack.env.json`, `--local` -still writes `.hack/.env`. +With no env layers yet, `hack env set --local` writes legacy `.hack/.env` (or +`.hack/.env.`), including when a `hack.env.json` contract exists. +Use `hack env add --local` to migrate first and then write the machine-local layer. +Contract migration writes `plain_env` values from legacy dotenv files (or the shell) +to the gitignored machine-local layer, never a tracked layer. It reports each moved +key and destination path, without values. It refuses a local destination already +tracked by Git. Values declared as `keychain` migrate to tracked layers as ciphertext. +Without `hack.env.json`, `set --local` still writes `.hack/.env`; if a contract is +added later, migration keeps those values machine-local too. Repeating `hack env add` or `hack env set` with the value already stored is a no-op: when the existing secret decrypts to the same value under the current key, its ciphertext is @@ -1044,8 +1045,11 @@ That migrates the repo into: - `.hack/hack.env.default.yaml` - `.hack/hack.env..yaml` +- `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml` for plain values - `.hack.secret.key` when needed +Tracked layers receive secret-backed ciphertext, not legacy plaintext. + Doctor offers legacy dotenv files for cleanup only when every parsed entry was delivered to an env layer. Files containing undeclared or undelivered entries stay on disk; the cleanup report lists their key names and omits their values. This @@ -1053,6 +1057,8 @@ also applies to untracked overlays such as `.env.bak` and `.env.example`. Migration refuses a legacy `.env.default` overlay before writing env layers, because it would overwrite the base `hack.env.default.yaml` layer. Rename that legacy overlay and update its project references before retrying. +It also refuses `.env.local`, whose tracked overlay path would collide with the +machine-local base layer. Rename that legacy overlay before migration. If a legacy dotenv file cannot be read, migration refuses cleanup and leaves legacy artifacts in place. Env layers may already have been written; check read access and preserve the remaining entries before cleaning up manually. diff --git a/docs/security/env-secrets-threat-model.md b/docs/security/env-secrets-threat-model.md index 93e9ff539..d6adf6525 100644 --- a/docs/security/env-secrets-threat-model.md +++ b/docs/security/env-secrets-threat-model.md @@ -95,7 +95,7 @@ refer to `hack-dance/hack`. | The key is committed. | The root `.gitignore` gets a `.hack.secret.key` entry before the key is created, so a failed entry write leaves no key; an existing checkout-local key git does not ignore is repaired or refused before a `--secret` write uses it, and `hack doctor` reports it as `env-key-unignored` (HACK-1375). Encryption refuses a git-tracked key, including an inherited primary key, before it is copied (#288). `hack doctor` reports `env-key-tracked` at any depth (#309) and the "generated files" check can untrack a root key with `--fix`. | | A key copy ships in build output or a static site. | `hack doctor` reports `env-key-public-path` for copies under `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` and `.output/` (#309). | | A weak, short or repetitive key. | `env-key-wrong-length` and `env-key-low-entropy` (#309). Generated keys are 32 random bytes in base64url. | -| Materialized `.hack/.env` or local overrides are committed. | Hack owns a committed `.hack/.gitignore` block, and `hack doctor` flags tracked generated files (see [ignore rules](../env.md#ignore-rules-committed-hackgitignore)). | +| Materialized `.hack/.env` or local overrides are committed. | Hack owns a committed `.hack/.gitignore` block, and `hack doctor` flags tracked generated files (see [ignore rules](../env.md#ignore-rules-committed-hackgitignore)). Contract migration puts legacy `plain_env` values in the ignored machine-local layer, refuses a tracked destination, and reports only keys and destination paths (HACK-1712/1713). | | A secret leaks into a public artifact through a path nobody listed. | `tests/env-secret-sentinel-scan.test.ts` runs in one of the hosted CI `test shard i/4` jobs; the required `test` check passes only when every shard passes. It gives a primary checkout and a linked worktree random synthetic secrets through `env add --secret --value-stdin` (shared and `--local`), runs materialize, exec, up (Compose generation), env apply, logs, list, explain and doctor, then commits everything with `git add -A`. It requires zero occurrences of any value or the project key, raw or hex/base64/base64url encoded, in CLI output, recorded child argv, tracked files, every Git object, HACK_HOME, the private HOME and its caches, and generated Compose and runtime files. Plaintext may exist only in `.hack/.env`, and key text only in key files; each must be git-ignored, untracked and mode 0600. `.hack/.internal/compose.env.override.yml` must hold `${HACK_ENV_...}` references and no value, and must also be private and ignored. Values may reach a child's environment only for the exec'd command and `docker compose`. Positive controls prove delivery and that the scanner finds planted encodings in files and Git objects. Docker is a recording shim; no engine, keychain or installed Hack is reached. | | A dependency vulnerability ships in Hack. | `bun install --frozen-lockfile`, and the read-only `scripts/dependabot-inventory.ts` lists open alerts (#309). Hack does not sandbox project dependencies that run under `hack host exec`; they receive the injected env by design. | diff --git a/src/cli/env-arguments.ts b/src/cli/env-arguments.ts index 316ac47a4..1423e2fd0 100644 --- a/src/cli/env-arguments.ts +++ b/src/cli/env-arguments.ts @@ -34,14 +34,6 @@ export function parseEnvScopeOption(input: { } } -/** - * `hack env set --local` in a project with a `hack.env.json` contract and no env - * layers. The plaintext path writes `.hack/.env`, and the contract migration copies - * that file's values to tracked layers, so `--local` would not stay local. - */ -export const LEGACY_ENV_LOCAL_CONTRACT_MESSAGE = - "--local needs env layers, but this project has no env layers yet, and its hack.env.json migration copies .hack/.env values to tracked layers. Nothing was written. Use `hack env add` with the same flags, which migrates first and then writes the machine-local layer."; - /** * Resolve the `--scope`/`--service` pair of host execution commands. * Returns `null` when neither names a scope (the global scope). diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index ed380174a..c7f68a0f1 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,6 +1,6 @@ import { lookup } from "node:dns/promises"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { note, spinner } from "@clack/prompts"; import { YAML } from "bun"; import { HACK_AGENT_INTEGRATION_CONTENT_REVISION } from "../agents/integration-revision.ts"; @@ -3728,6 +3728,11 @@ async function maybeMigrateProjectEnvConfig(opts: { return; } const migrationNotes = [`Wrote ${migrated.wroteFiles.join(", ")}`]; + for (const moved of migrated.movedPlainValues) { + migrationNotes.push( + `Moved ${moved.key} to ${relative(project.projectRoot, moved.path)} (values omitted).` + ); + } if (migrated.updatedProjectConfig) { migrationNotes.push( "Updated .hack/hack.config.json for the modern env format." diff --git a/src/commands/env.ts b/src/commands/env.ts index b69b1bae8..d39887a10 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -10,7 +10,6 @@ import { withHandler, } from "../cli/command.ts"; import { - LEGACY_ENV_LOCAL_CONTRACT_MESSAGE, parseApplyServiceOption, parseEnvGetArguments, parseEnvScopeOption, @@ -20,7 +19,6 @@ import { import { resolveRequestedEnvName } from "../cli/env-selection.ts"; import { optEnv, optJson, optPath, optProject } from "../cli/options.ts"; import { resolveRegisteredProjectOption } from "../cli/project-selector.ts"; -import { PROJECT_ENV_CONTRACT_FILENAME } from "../constants.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { updateGlobalConfig } from "../lib/config.ts"; import { resolveGlobalConfigPath } from "../lib/config-paths.ts"; @@ -32,7 +30,7 @@ import { assertEnvValueStdinUsage, readEnvValueFromStdin, } from "../lib/env-value-stdin.ts"; -import { pathExists, readTextFile } from "../lib/fs.ts"; +import { readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; import type { @@ -1912,17 +1910,6 @@ const handleEnvSet: CommandHandlerFor = async ({ } const storeSecret = args.options.secret === true; - // Before stdin is read or anything is written. A secret goes to the store, not - // to .hack/.env, so the contract migration can't copy its plaintext to a tracked layer. - if ( - args.options.local === true && - !storeSecret && - (await pathExists( - resolve(project.projectDir, PROJECT_ENV_CONTRACT_FILENAME) - )) - ) { - throw new CliUsageError(LEGACY_ENV_LOCAL_CONTRACT_MESSAGE); - } const runtimeConfig = await readHackEnvRuntimeConfig({ projectDir: project.projectDir, }); diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index efe53b23f..33d0f96fe 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -4894,6 +4894,10 @@ export async function migrateLegacyProjectEnv(opts: { }): Promise<{ readonly wroteFiles: readonly string[]; readonly migratedOverlays: readonly string[]; + readonly movedPlainValues: readonly { + readonly key: string; + readonly path: string; + }[]; readonly legacyDetected: boolean; readonly updatedProjectConfig: boolean; readonly cleanupCandidates: readonly string[]; @@ -4911,6 +4915,7 @@ export async function migrateLegacyProjectEnv(opts: { return { wroteFiles: [], migratedOverlays: [], + movedPlainValues: [], legacyDetected: false, updatedProjectConfig: false, cleanupCandidates: [], @@ -4934,6 +4939,11 @@ export async function migrateLegacyProjectEnv(opts: { "Refusing legacy env migration: .env.default would overwrite the base hack.env.default.yaml layer. Rename the legacy default overlay and update its project references before retrying. No env migration files were written." ); } + if (overlayNames.includes("local")) { + throw new ClosedCliError( + "Refusing legacy env migration: .env.local would collide with the machine-local hack.env.local.yaml layer. Rename the legacy local overlay and update its project references before retrying. No env migration files were written." + ); + } const keyInfo = baseResolved.values.some( (value) => value.source === "keychain" ) @@ -4948,10 +4958,60 @@ export async function migrateLegacyProjectEnv(opts: { new Map(baseResolved.values.map((entry) => [entry.key, entry.value])) ); const wroteFiles: string[] = []; + const movedPlainValues: { key: string; path: string }[] = []; + /** Keep plaintext in ignored layers; hydration preserves other entries and refuses unsafe targets. */ + const migratePlainValues = async (migration: { + readonly resolvedValues: typeof baseResolved.values; + readonly envName: string | null; + readonly baseValues: ReadonlyMap | null; + }) => { + const values = buildMigratedValues({ + resolvedValues: migration.resolvedValues.filter( + (entry) => entry.source === "plain_env" + ), + baseValues: migration.baseValues, + keyText: null, + }); + let hasEntries = false; + for (const [scope, entries] of Object.entries(values)) { + const plainEntries = new Map(); + for (const [key, value] of Object.entries(entries)) { + if (typeof value === "string") { + plainEntries.set(key, value); + } + } + if (plainEntries.size === 0) { + continue; + } + hasEntries = true; + const result = await hydrateProjectEnvLocalLayer({ + projectRoot: opts.projectRoot, + projectDir: opts.projectDir, + envName: migration.envName, + scope, + entries: plainEntries, + secret: false, + }); + if (result.changed && !wroteFiles.includes(result.filePath)) { + wroteFiles.push(result.filePath); + } + for (const key of plainEntries.keys()) { + movedPlainValues.push({ key, path: result.filePath }); + } + } + return hasEntries; + }; + await migratePlainValues({ + resolvedValues: baseResolved.values, + envName: null, + baseValues: null, + }); const defaultConfig = buildMigratedProjectEnvConfig({ environment: "default", values: buildMigratedValues({ - resolvedValues: baseResolved.values, + resolvedValues: baseResolved.values.filter( + (entry) => entry.source === "keychain" + ), baseValues: null, keyText: keyInfo?.keyText ?? null, }), @@ -4980,10 +5040,19 @@ export async function migrateLegacyProjectEnv(opts: { resolve(opts.projectDir, `${PROJECT_ENV_FILENAME}.${overlayName}`), new Map(overlayResolved.values.map((entry) => [entry.key, entry.value])) ); + const hasPlainOverlayEntries = await migratePlainValues({ + resolvedValues: overlayResolved.values, + envName: overlayName, + baseValues: new Map( + baseResolved.values.map((value) => [value.key, value.value]) + ), + }); const overlayConfig = buildMigratedProjectEnvConfig({ environment: overlayName, values: buildMigratedValues({ - resolvedValues: overlayResolved.values, + resolvedValues: overlayResolved.values.filter( + (entry) => entry.source === "keychain" + ), baseValues: new Map( baseResolved.values.map((value) => [value.key, value.value]) ), @@ -4993,7 +5062,7 @@ export async function migrateLegacyProjectEnv(opts: { const hasOverlayEntries = Object.entries(overlayConfig.values).some( ([scope, values]) => scope !== "global" || Object.keys(values).length > 0 ); - if (!hasOverlayEntries) { + if (!(hasOverlayEntries || hasPlainOverlayEntries)) { continue; } @@ -5042,6 +5111,7 @@ export async function migrateLegacyProjectEnv(opts: { return { wroteFiles, migratedOverlays: overlayNames, + movedPlainValues, legacyDetected: true, updatedProjectConfig: configCleanup.changed, cleanupCandidates: cleanupCandidates.allowed, diff --git a/tests/doctor-fix-noninteractive.test.ts b/tests/doctor-fix-noninteractive.test.ts index 668fe08e9..60cbff02a 100644 --- a/tests/doctor-fix-noninteractive.test.ts +++ b/tests/doctor-fix-noninteractive.test.ts @@ -681,13 +681,13 @@ test("doctor contract migration keeps base and untracked overlays with undeliver await lstat(resolve(projectDir, ".env.same")).catch(() => null) ).toBeNull(); expect( - await readFile(resolve(projectDir, "hack.env.default.yaml"), "utf8") + await readFile(resolve(projectDir, "hack.env.local.yaml"), "utf8") ).toContain("synthetic-delivered"); expect( - await readFile(resolve(projectDir, "hack.env.bak.yaml"), "utf8") + await readFile(resolve(projectDir, "hack.env.bak.local.yaml"), "utf8") ).toContain("synthetic-backup"); expect( - await readFile(resolve(projectDir, "hack.env.example.yaml"), "utf8") + await readFile(resolve(projectDir, "hack.env.example.local.yaml"), "utf8") ).toContain("synthetic-example"); }); diff --git a/tests/env-set-local-contract.test.ts b/tests/env-set-local-contract.test.ts index a626b4ab9..7a740a118 100644 --- a/tests/env-set-local-contract.test.ts +++ b/tests/env-set-local-contract.test.ts @@ -10,14 +10,9 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative, resolve } from "node:path"; -import { LEGACY_ENV_LOCAL_CONTRACT_MESSAGE } from "../src/cli/env-arguments.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; -/* - * HACK-1710: in a project with a `hack.env.json` contract and no env layers, - * `hack env set --local` wrote `.hack/.env`, and the contract migration then - * copied the value to tracked `hack.env.default.yaml`. - */ +/** Contract migration must keep legacy plaintext machine-local (HACK-1712/1713). */ const ENTRYPOINT = resolve(import.meta.dir, "..", "index.ts"); const VALUE = ["synthetic", "local", "1710"].join("-"); @@ -43,7 +38,7 @@ afterEach(async () => { for (const root of roots.splice(0)) { await rm(root, { recursive: true, force: true }); } -}); +}, 30_000); async function createProject(opts: { readonly contract: boolean; @@ -151,26 +146,25 @@ const setLocal = (project: Project) => runEnv(project, ["set", "LOCAL_KEY", "--local"]); test( - "with a contract and no layers, set --local refuses and writes nothing", + "with a contract and no layers, set --local writes legacy dotenv", async () => { const project = await createProject({ contract: true }); - const before = await tree(project); const run = await setLocal(project); - expect(run.exitCode).not.toBe(0); - expect(run.output).toContain(LEGACY_ENV_LOCAL_CONTRACT_MESSAGE); + expect(run.exitCode).toBe(0); + const files = await tree(project); + expect(files[".hack/hack.env.default.yaml"]).toBeUndefined(); + expect(files[".hack/.env"]).toContain(VALUE); expect(run.output).not.toContain(VALUE); - expect(await tree(project)).toEqual(before); }, CLI_TEST_TIMEOUT_MS ); test( - "set --env staging --local refuses the same way", + "set --env staging --local writes the legacy overlay", async () => { const project = await createProject({ contract: true }); - const before = await tree(project); const run = await runEnv(project, [ "set", @@ -180,30 +174,35 @@ test( "--local", ]); - expect(run.exitCode).not.toBe(0); - expect(run.output).toContain(LEGACY_ENV_LOCAL_CONTRACT_MESSAGE); - expect(await tree(project)).toEqual(before); + expect(run.exitCode).toBe(0); + const files = await tree(project); + expect(files[".hack/hack.env.default.yaml"]).toBeUndefined(); + expect(files[".hack/.env.staging"]).toContain(VALUE); + expect(run.output).not.toContain(VALUE); }, CLI_TEST_TIMEOUT_MS ); test( - "with a mirrored backend, the refusal writes neither .env nor the store", + "with a mirrored backend, set --local writes legacy dotenv", async () => { const project = await createProject({ contract: true, mirrored: true }); - const before = await tree(project); - const run = await setLocal(project); + const run = await runEnv(project, ["set", "LOCAL_KEY", "--local"], { + HACK_SECRETS_FILE_KEY: ["synthetic", "file", "1712"].join("-"), + }); - expect(run.exitCode).not.toBe(0); - expect(run.output).toContain(LEGACY_ENV_LOCAL_CONTRACT_MESSAGE); - expect(await tree(project)).toEqual(before); + expect(run.exitCode).toBe(0); + const files = await tree(project); + expect(files[".hack/hack.env.default.yaml"]).toBeUndefined(); + expect(files[".hack/.env"]).toContain(VALUE); + expect(run.output).not.toContain(VALUE); }, CLI_TEST_TIMEOUT_MS ); test( - "the remedy the refusal names keeps the value out of tracked layers", + "env add keeps the value out of tracked layers", async () => { const project = await createProject({ contract: true }); @@ -225,7 +224,7 @@ test( ); test( - "the remedy with --env writes that overlay's local layer, not the base", + "env add with --env writes that overlay's local layer, not the base", async () => { const project = await createProject({ contract: true }); @@ -247,7 +246,7 @@ test( ); test( - "--secret --local isn't refused, because the value goes to the store and not .env", + "--secret --local writes the store and not .env", async () => { const project = await createProject({ contract: true, mirrored: true }); @@ -257,7 +256,6 @@ test( { HACK_SECRETS_FILE_KEY: ["synthetic", "file", "1710"].join("-") } ); - expect(run.output).not.toContain(LEGACY_ENV_LOCAL_CONTRACT_MESSAGE); expect(run.exitCode).toBe(0); const files = await tree(project); expect(files[".hack/.env"]).toBeUndefined(); @@ -278,3 +276,158 @@ test( }, CLI_TEST_TIMEOUT_MS ); + +async function runDoctor(project: Project) { + const tools = join(project.root, "tools"); + await mkdir(tools, { recursive: true }); + await writeFile(join(tools, "bun"), "#!/bin/sh\necho 1.4.2\n", { + mode: 0o700, + }); + await writeFile( + join(tools, "docker"), + '#!/bin/sh\ncase "$1" in\ninfo|version) exit 0;;\nnetwork) echo "[]"; exit 0;;\nps) echo "[]"; exit 0;;\n*) exit 0;;\nesac\n', + { mode: 0o700 } + ); + await writeFile(join(tools, "mutagen"), "#!/bin/sh\nexit 0\n", { + mode: 0o700, + }); + const proc = Bun.spawn( + [ + process.env.HACK_TEST_CLI_BINARY ?? process.execPath, + ...(process.env.HACK_TEST_CLI_BINARY ? [] : [ENTRYPOINT]), + "doctor", + "--migrate-env-config", + "--no-interactive", + "--path", + project.projectRoot, + ], + { + cwd: project.projectRoot, + env: privateCliEnv({ + root: project.root, + env: { + PATH: `${tools}:/usr/bin:/bin`, + HACK_MUTAGEN_PATH: join(tools, "mutagen"), + HACK_HOME: join(project.root, "hack-home"), + HACK_GLOBAL_CONFIG_PATH: join(project.root, "hack-config.json"), + HACK_LOGGER: "console", + HACK_NO_INTERACTIVE: "1", + NO_COLOR: "1", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + running.add(proc); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + running.delete(proc); + return { exitCode, output: stdout + stderr }; +} + +async function git(project: Project, args: string[]) { + const proc = Bun.spawn(["git", ...args], { + cwd: project.projectRoot, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(code, stderr).toBe(0); + return stdout; +} + +for (const lateContract of [false, true]) { + test( + lateContract + ? "HACK-1713: no-contract set --local then doctor with a new contract keeps plaintext local" + : "HACK-1712: doctor moves a contract dotenv value to an ignored local layer without output or tracked leakage", + async () => { + const project = await createProject({ contract: !lateContract }); + await git(project, ["init", "--quiet"]); + if (lateContract) { + expect((await setLocal(project)).exitCode).toBe(0); + expect( + await readFile(join(project.projectDir, ".env"), "utf8") + ).toContain(VALUE); + await writeFile(join(project.projectDir, "hack.env.json"), CONTRACT); + } + await git(project, [ + "add", + ".hack/hack.env.json", + ".hack/hack.config.json", + ".hack/docker-compose.yml", + ]); + if (!lateContract) { + await writeFile( + join(project.projectDir, ".env"), + `LOCAL_KEY=${VALUE}\n` + ); + } + const run = await runDoctor(project); + expect(run.output).not.toContain(VALUE); + expect(run.exitCode, run.output).toBe(0); + const local = join(project.projectDir, "hack.env.local.yaml"); + expect( + await readFile( + join(project.projectDir, "hack.env.default.yaml"), + "utf8" + ) + ).not.toContain(VALUE); + expect(await readFile(local, "utf8")).toContain(VALUE); + expect(run.output).toContain( + "Moved LOCAL_KEY to .hack/hack.env.local.yaml" + ); + expect(await git(project, ["check-ignore", "--", local])).toContain( + "hack.env.local.yaml" + ); + await git(project, ["add", "."]); + const tracked = (await git(project, ["ls-files", "-z"])) + .split("\0") + .filter(Boolean); + expect(tracked).not.toContain(".hack/hack.env.local.yaml"); + for (const path of tracked) { + expect( + await readFile(join(project.projectRoot, path), "utf8") + ).not.toContain(VALUE); + } + }, + CLI_TEST_TIMEOUT_MS + ); +} + +test( + "doctor refuses the reserved local overlay without exposing or losing plaintext", + async () => { + const project = await createProject({ contract: true }); + await git(project, ["init", "--quiet"]); + await writeFile(join(project.projectDir, ".env"), `LOCAL_KEY=${VALUE}\n`); + await writeFile( + join(project.projectDir, ".env.local"), + `LOCAL_KEY=${VALUE}-local\n` + ); + const run = await runDoctor(project); + expect(run.exitCode).toBe(1); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain(".env.local would collide"); + expect(await readFile(join(project.projectDir, ".env"), "utf8")).toContain( + VALUE + ); + expect( + await readFile(join(project.projectDir, "hack.env.json"), "utf8") + ).toBe(CONTRACT); + await expect( + readFile(join(project.projectDir, "hack.env.default.yaml"), "utf8") + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + CLI_TEST_TIMEOUT_MS +); diff --git a/tests/project-env-config.test.ts b/tests/project-env-config.test.ts index d780e0c85..c5a4042e0 100644 --- a/tests/project-env-config.test.ts +++ b/tests/project-env-config.test.ts @@ -1575,6 +1575,18 @@ test("migrateLegacyProjectEnv blocks cleanup for compose-referenced legacy env f resolvedPath: resolve(repo.projectDir, ".env.production"), }, ]); + expect( + await listProjectEnvOverlayNames({ projectDir: repo.projectDir }) + ).toContain("production"); + expect( + await readFile(resolve(repo.projectDir, "hack.env.production.yaml"), "utf8") + ).not.toContain("https://prod.example.com"); + expect( + await readFile( + resolve(repo.projectDir, "hack.env.production.local.yaml"), + "utf8" + ) + ).toContain("https://prod.example.com"); expect(migrated.cleanupCandidates).toEqual([ resolve(repo.projectDir, PROJECT_ENV_CONTRACT_FILENAME), ]); @@ -1613,6 +1625,54 @@ async function createGitWorktree(opts: { ); } +test("contract migration in a linked worktree keeps plaintext in that worktree's local layer", async () => { + const repo = await createRepo(); + await writeFile( + resolve(repo.projectDir, PROJECT_ENV_CONTRACT_FILENAME), + JSON.stringify({ + version: 1, + vars: [{ key: "LOCAL_FIXTURE", source: "plain_env", services: ["api"] }], + }) + ); + await initializeGitRepo({ projectRoot: repo.projectRoot }); + const worktreeRoot = resolve(repo.tempRoot, "linked"); + await createGitWorktree({ + projectRoot: repo.projectRoot, + worktreeRoot, + branch: "migration-local-fixture", + }); + const projectDir = resolve(worktreeRoot, ".hack"); + await writeFile( + resolve(projectDir, ".env"), + "LOCAL_FIXTURE=synthetic-linked-local\n" + ); + await migrateLegacyProjectEnv({ + projectRoot: worktreeRoot, + projectDir, + projectName: "project-env-test", + serviceNames: ["api", "web"], + materialize: false, + }); + expect( + await readFile(resolve(projectDir, "hack.env.local.yaml"), "utf8") + ).toContain("synthetic-linked-local"); + expect( + await readFile(resolve(projectDir, "hack.env.default.yaml"), "utf8") + ).not.toContain("synthetic-linked-local"); + await expect( + readFile(resolve(repo.projectDir, "hack.env.local.yaml"), "utf8") + ).rejects.toMatchObject({ code: "ENOENT" }); + const resolved = await resolveProjectEnvConfig({ + projectRoot: worktreeRoot, + projectDir, + serviceNames: ["api", "web"], + }); + expect(resolved?.serviceEnv.api?.LOCAL_FIXTURE).toBe( + "synthetic-linked-local" + ); + expect(resolved?.serviceEnv.web?.LOCAL_FIXTURE).toBeUndefined(); +}); + async function initializeGitRepo(opts: { readonly projectRoot: string; }): Promise { From 637bb8deb315aebeed7febf5c4abf0b74c90d39e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:42:57 -0500 Subject: [PATCH 2/2] fix(env): preflight migration ignore protection and layer aliases --- docs/env.md | 6 ++ src/lib/project-env-config.ts | 110 +++++++++++++++++++++++++-- tests/env-set-local-contract.test.ts | 70 +++++++++++++++++ tests/project-env-config.test.ts | 39 ++++++++++ 4 files changed, 218 insertions(+), 7 deletions(-) diff --git a/docs/env.md b/docs/env.md index fd061300d..b0160f1a4 100644 --- a/docs/env.md +++ b/docs/env.md @@ -599,6 +599,8 @@ Contract migration writes `plain_env` values from legacy dotenv files (or the sh to the gitignored machine-local layer, never a tracked layer. It reports each moved key and destination path, without values. It refuses a local destination already tracked by Git. Values declared as `keychain` migrate to tracked layers as ciphertext. +Migration requires effective ignore protection before writing plaintext. It refuses +an outdated read-only `.hack/.gitignore` or a local destination that Git does not ignore. Without `hack.env.json`, `set --local` still writes `.hack/.env`; if a contract is added later, migration keeps those values machine-local too. @@ -1059,6 +1061,10 @@ because it would overwrite the base `hack.env.default.yaml` layer. Rename that legacy overlay and update its project references before retrying. It also refuses `.env.local`, whose tracked overlay path would collide with the machine-local base layer. Rename that legacy overlay before migration. +All tracked and effective local destinations are checked for collisions before any +writes, including compatibility paths. For example, `.env.staging` together with +`.env.staging.local` is refused because both would write `hack.env.staging.local.yaml`. +Collision checks include case-only aliases for checkouts with case-insensitive filenames. If a legacy dotenv file cannot be read, migration refuses cleanup and leaves legacy artifacts in place. Env layers may already have been written; check read access and preserve the remaining entries before cleaning up manually. diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 33d0f96fe..daa230e38 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -52,7 +52,11 @@ import { resolveGitWorktreeDir, } from "./git-worktree.ts"; import { getRecord, getString, isRecord } from "./guards.ts"; -import { readHackEnvContract, resolveHackEnv } from "./hack-env.ts"; +import { + type HackEnvResolveResult, + readHackEnvContract, + resolveHackEnv, +} from "./hack-env.ts"; import { isOwnedLegacyAdoptionManagedEnvAdmission, type LegacyAdoptionManagedEnvAdmission, @@ -4877,6 +4881,69 @@ function removeLegacyComposeEnvFileEntries(opts: { return kept.length > 0 ? kept : undefined; } +/** Reject aliases between tracked and effective local destinations before any writes. */ +async function preflightLegacyEnvMigrationPaths(opts: { + readonly projectRoot: string; + readonly projectDir: string; + readonly overlayNames: readonly string[]; +}): Promise { + const destinations = new Set(); + const localPaths: string[] = []; + for (const envName of [null, ...opts.overlayNames]) { + const trackedPath = resolveProjectEnvConfigPath({ + projectDir: opts.projectDir, + envName, + }); + const localPath = await resolveProjectEnvEffectiveLocalConfigPath({ + projectRoot: opts.projectRoot, + projectDir: opts.projectDir, + envName, + }); + for (const path of [trackedPath, localPath]) { + await assertProjectEnvLayerPath({ projectRoot: opts.projectRoot, path }); + // Refuse portable aliases too: macOS checkouts may fold filename case. + const identity = path.toLowerCase(); + if (destinations.has(identity)) { + throw new ClosedCliError( + "Refusing legacy env migration: env layer destination paths collide. Rename conflicting legacy overlays and update their project references before retrying. No env migration files were written (values omitted)." + ); + } + destinations.add(identity); + } + localPaths.push(localPath); + } + return localPaths; +} + +/** Migration must establish effective ignore protection before publishing any plaintext. */ +async function ensureLegacyMigrationLocalLayersIgnored(opts: { + readonly projectRoot: string; + readonly projectDir: string; + readonly localPaths: readonly string[]; +}): Promise { + const ignored = await ensureHackDirGitignore({ projectDir: opts.projectDir }); + if (ignored.readOnly) { + throw new ClosedCliError( + "Refusing legacy env migration: .hack/.gitignore is read-only and cannot protect machine-local values. Make it writable and retry. No env migration values were written (values omitted)." + ); + } + if (!(await isInsideGitCheckoutMarker({ startDir: opts.projectRoot }))) { + return; + } + for (const path of opts.localPaths) { + if ( + (await isGitIgnoredProjectEnvPath({ + projectRoot: opts.projectRoot, + path, + })) !== true + ) { + throw new ClosedCliError( + "Refusing legacy env migration: a machine-local destination is not ignored by Git or its ignore status cannot be checked. Repair ignore rules and retry. No env migration values were written (values omitted)." + ); + } + } +} + /** * Migrates a legacy v2 `.env`-based project env layout to the v3 * `hack.env.*.yaml` config system. @@ -4944,11 +5011,42 @@ export async function migrateLegacyProjectEnv(opts: { "Refusing legacy env migration: .env.local would collide with the machine-local hack.env.local.yaml layer. Rename the legacy local overlay and update its project references before retrying. No env migration files were written." ); } + const localPaths = await preflightLegacyEnvMigrationPaths({ + projectRoot: opts.projectRoot, + projectDir: opts.projectDir, + overlayNames, + }); + const resolvedOverlays: { name: string; resolved: HackEnvResolveResult }[] = + []; + for (const name of overlayNames) { + resolvedOverlays.push({ + name, + resolved: await resolveHackEnv({ + projectDir: opts.projectDir, + projectName: opts.projectName, + envName: name, + }), + }); + } const keyInfo = baseResolved.values.some( (value) => value.source === "keychain" ) ? await ensureProjectEnvSecretKey({ projectRoot: opts.projectRoot }) : null; + if ( + [baseResolved, ...resolvedOverlays.map((overlay) => overlay.resolved)].some( + (resolved) => + resolved.values.some( + (value) => value.source === "plain_env" && value.value !== null + ) + ) + ) { + await ensureLegacyMigrationLocalLayersIgnored({ + projectRoot: opts.projectRoot, + projectDir: opts.projectDir, + localPaths, + }); + } // An overlay may inherit an equal base value without writing its own entry. // Keep the full resolved delivery per source file, including inherited values. @@ -5030,12 +5128,10 @@ export async function migrateLegacyProjectEnv(opts: { wroteFiles.push(defaultPath); } - for (const overlayName of overlayNames) { - const overlayResolved = await resolveHackEnv({ - projectDir: opts.projectDir, - projectName: opts.projectName, - envName: overlayName, - }); + for (const { + name: overlayName, + resolved: overlayResolved, + } of resolvedOverlays) { deliveredValues.set( resolve(opts.projectDir, `${PROJECT_ENV_FILENAME}.${overlayName}`), new Map(overlayResolved.values.map((entry) => [entry.key, entry.value])) diff --git a/tests/env-set-local-contract.test.ts b/tests/env-set-local-contract.test.ts index 7a740a118..26a5905ba 100644 --- a/tests/env-set-local-contract.test.ts +++ b/tests/env-set-local-contract.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { + chmod, mkdir, mkdtemp, readdir, @@ -431,3 +432,72 @@ test( }, CLI_TEST_TIMEOUT_MS ); + +test( + "doctor migration refuses an outdated read-only ignore file before writing plaintext", + async () => { + const project = await createProject({ contract: true }); + await git(project, ["init", "--quiet"]); + const envPath = join(project.projectDir, ".env"); + const ignorePath = join(project.projectDir, ".gitignore"); + const original = `LOCAL_KEY=${VALUE}\n`; + await writeFile(envPath, original); + await writeFile(ignorePath, "# outdated fixture rules\n"); + await chmod(ignorePath, 0o444); + const run = await runDoctor(project); + expect(run.exitCode).toBe(1); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain(".hack/.gitignore is read-only"); + expect(await readFile(envPath, "utf8")).toBe(original); + expect(await readFile(ignorePath, "utf8")).toBe( + "# outdated fixture rules\n" + ); + expect( + await readFile(join(project.projectDir, "hack.env.json"), "utf8") + ).toBe(CONTRACT); + for (const name of ["hack.env.local.yaml", "hack.env.default.yaml"]) { + await expect( + readFile(join(project.projectDir, name), "utf8") + ).rejects.toMatchObject({ code: "ENOENT" }); + } + }, + CLI_TEST_TIMEOUT_MS +); + +test.each(["staging.local", "staging.LOCAL"])( + "doctor migration refuses aliased overlay %s and keeps every legacy source", + async (overlayName) => { + const project = await createProject({ contract: true }); + await git(project, ["init", "--quiet"]); + const sources = { + ".env": `LOCAL_KEY=${VALUE}\n`, + ".env.staging": `LOCAL_KEY=${VALUE}-staging\n`, + [`.env.${overlayName}`]: `LOCAL_KEY=${VALUE}-other\n`, + }; + for (const [name, text] of Object.entries(sources)) { + await writeFile(join(project.projectDir, name), text); + } + const run = await runDoctor(project); + expect(run.exitCode).toBe(1); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain("env layer destination paths collide"); + for (const [name, text] of Object.entries(sources)) { + expect(await readFile(join(project.projectDir, name), "utf8")).toBe(text); + } + expect( + await readFile(join(project.projectDir, "hack.env.json"), "utf8") + ).toBe(CONTRACT); + for (const name of [ + "hack.env.default.yaml", + "hack.env.local.yaml", + "hack.env.staging.yaml", + "hack.env.staging.local.yaml", + "hack.env.staging.local.local.yaml", + ]) { + await expect( + readFile(join(project.projectDir, name), "utf8") + ).rejects.toMatchObject({ code: "ENOENT" }); + } + }, + CLI_TEST_TIMEOUT_MS +); diff --git a/tests/project-env-config.test.ts b/tests/project-env-config.test.ts index c5a4042e0..69fbe8bbf 100644 --- a/tests/project-env-config.test.ts +++ b/tests/project-env-config.test.ts @@ -1673,6 +1673,45 @@ test("contract migration in a linked worktree keeps plaintext in that worktree's expect(resolved?.serviceEnv.web?.LOCAL_FIXTURE).toBeUndefined(); }); +test("contract migration refuses aliases with the compatibility local base before writes", async () => { + const repo = await createRepo(); + const legacyLocal = "version: 1\nenvironment: local\nvalues:\n global: {}\n"; + await writeFile(resolve(repo.projectDir, "hack.env.local.yaml"), legacyLocal); + await writeFile( + resolve(repo.projectDir, PROJECT_ENV_CONTRACT_FILENAME), + JSON.stringify({ + version: 1, + vars: [{ key: "LOCAL_FIXTURE", source: "plain_env" }], + }) + ); + await initializeGitRepo({ projectRoot: repo.projectRoot }); + const base = "LOCAL_FIXTURE=synthetic-base\n"; + const overlay = "LOCAL_FIXTURE=synthetic-overlay\n"; + await writeFile(resolve(repo.projectDir, ".env"), base); + await writeFile(resolve(repo.projectDir, ".env.default.local"), overlay); + await expect( + migrateLegacyProjectEnv({ + projectRoot: repo.projectRoot, + projectDir: repo.projectDir, + projectName: "project-env-test", + serviceNames: ["api", "web"], + materialize: false, + }) + ).rejects.toThrow("env layer destination paths collide"); + expect(await readFile(resolve(repo.projectDir, ".env"), "utf8")).toBe(base); + expect( + await readFile(resolve(repo.projectDir, ".env.default.local"), "utf8") + ).toBe(overlay); + expect( + await readFile(resolve(repo.projectDir, "hack.env.local.yaml"), "utf8") + ).toBe(legacyLocal); + for (const name of ["hack.env.default.yaml", "hack.env.default.local.yaml"]) { + await expect( + readFile(resolve(repo.projectDir, name), "utf8") + ).rejects.toMatchObject({ code: "ENOENT" }); + } +}); + async function initializeGitRepo(opts: { readonly projectRoot: string; }): Promise {