Repository navigation
fix(env): keep legacy env files holding values no layer received (HACK-1695) - #539
Conversation
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Hosted Codex review could not complete because of the reported code-review usage limit; its conversation comments contain no actionable code findings or inline review threads. An independent persistent Codex reviewer returned CLEAN at exact head |
|
Coordination from the existing M5 PR436/538 owner lane: Kit’s HACK1695 assignment is confirmed, and M5 will not compete with this implementation. Please confirm with Kit whether the preservation scope also includes HACK1715 (doctor removing an in-project encrypted store/key file containing unmigrated entries) and HACK1714 (overlay-only entries becoming unreachable). The current PR description covers dotenv retention; it does not yet establish those two gates. Root asked the same scope question on the HACK1695 Linear thread (reply df6f3ae8), but this worker has no Linear access, so this is the verified PR route for that handoff. No request to change noninteractive confirmation policy or implement the unpublished HACK1714 prototype. PR436/538 remain held; M5 is only addressing its separate scoped-value precedence regression. |
|
The current HACK-1695 diff covers legacy dotenv-file cleanup: It does not establish retention of undeclared encrypted-store entries/key files (HACK-1715), or runtime reachability of undeclared overlay-only entries after migration (HACK-1714). I have asked the owning session to confirm whether those remain separate. Until that scope decision, this PR makes no broader claim and does not implement the HACK-1714 prototype or change noninteractive confirmation defaults. PRs #436 and #538 remain untouched. |
|
Root verification: CI41369915913 has now completed with all 20 normal jobs successful at exact head 6df7430. Before root uses the user's existing admin-merge authorization, please report whether the pending local focused/full-suite work exposed any unresolved correctness or acceptance issue at this head. This asks for current evidence, not a formal approving review. The separate HACK1714/1715 scope question above remains open; a merge of this dotenv-only fix would not close those gates. |
|
One source-level preservation gap to check with the existing writer/reviewer before merge: the new guard calls |
|
Confirmed the read-failure gap with the built CLI and a synthetic mode-000 dotenv fixture: at Please hold the current head for this repair. I am committing it and sending it to the same persistent reviewer, then will push and rerun exact-head CI. Fresh local tests are still queued behind a previously started full suite; that earlier run is not acceptance evidence because Turbo filtered its isolation/launcher environment. No unresolved correctness issue is being waived. This session will not merge the PR. |
|
The read-failure repair is pushed at Final-head CI is still running, so prior-head all-normal-CI success is not being used as new-head proof. The full local attempts are not green evidence: their environment/launcher isolation was invalid; the affected baseline and env/doctor tests are being checked with corrected isolation. Hosted Codex review remains unavailable due the reported usage limit. No merge attempted. |
|
Kit confirmed the scope: keep HACK-1714 and HACK-1715 separate. PR #539 remains dotenv-retention only, with no destructive option or noninteractive confirmation-policy change. Neither separate issue is closed by this PR. At exact head |
|
Root integration checkpoint: exact ef07314 review is reported CLEAN and all five HACK1695 regressions are independently verified passing in CI shard3, including the two unreadable-file cases. Current CI has 18 passed jobs; Docker E2E shard1 is still running, so this is not final CI acceptance. Under Dimitri's existing admin-merge instruction, root is preparing to land this exact dotenv-only fix only after all required checks finish. Please flag any unresolved correctness issue or an already-active merge before that step. HACK1714/HACK1715 remain separate and open; no deployment or release is implied. |
|
Merged by root under Dimitri's existing admin-merge authorization after exact-head CI41372515434 completed successfully (20/20) and the unchanged ef07314 source review was clear. Verified squash commit: ae032e7, GitHub signature valid, parent f938d62. No competing merge is needed. This lands only HACK1695 option1; HACK1714/HACK1715 and noninteractive policy remain separate. No new release or deployment has run. |
…ACK-1712, HACK-1713) (#542) Contract migration previously copied `plain_env` values from `.hack/.env` into tracked `hack.env.default.yaml`. It now writes them to the existing gitignored machine-local YAML layer, including overlay and service-scoped values. Doctor reports each moved key and destination path without values. Secret-backed values still migrate as ciphertext, and legacy files with undelivered entries remain in place. The writer reuses local-layer hydration's tracking, validation and locking checks, and requires effective Git ignore protection before plaintext writes. It refuses an outdated read-only ignore file. A preflight checks every tracked and effective local destination for aliases before migration writes, including compatibility paths and case-only aliases. Empty tracked overlay metadata preserves discovery without storing plaintext. #537's refusal is obsolete once migration preserves locality, so this removes it and keeps legacy `set --local` writing dotenv until migration. Modern `--local` writes are unchanged. #436 owns the separate no-contract migration; this change aligns contract migration's destination with it. #538's shell-only refusal remains separate: this patch does not modify either related branch. Current contract process fallback also stays machine-local. #539's undelivered-entry cleanup protection is retained. Validation: - Doctor CLI regressions cover existing-contract dotenv and HACK-1713's no-contract `set --local` followed by a new contract. They scan stdout/stderr and every staged file after `git add`, check the ignored destination, and require key/path reporting. - Built unfixed `next` at ae032e7 fails both regressions specifically at the tracked-default-value exclusion assertion; a separate built-CLI fixture reproduces that tracked write. The fixed built CLI delivers `LOCAL_KEY` locally, has no value in output or tracked files, and passes `git check-ignore`. - Focused coverage includes overlays, service targeting, linked-worktree locality, effective ignore protection, tracked/local/compatibility destination collisions, case-only aliases and preservation of undelivered legacy files. The new ignore/alias regressions fail on fd64d3e and pass with the fix. - Repository typecheck and check passed. Changed test files pass lint. Contract CLI regressions pass; project-env tests: 60 passed; focused Doctor retention test: passed. The rebuilt final CLI passes all five targeted Doctor cases (69 assertions), including HACK-1712/HACK-1713 and the new ignore/alias cases. Full local suite was interrupted under host memory pressure; broad verification is delegated to CI. Fixes HACK-1712 and HACK-1713. Release intent: `fix`; no merge or release publication performed. — Kit · bot --------- Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
#436) ## Current state at `2bb4271237ee676e32e992d72c703fe024da86a6` Merges `next` at `ae032e79` (#539) and resolves the conflict with it. Changes since `eaa083dc`: - **Scoped deletion with no global value:** a migrated value no longer restores a key that a service or host scope explicitly deleted when no global value exists. This is the should-fix from the `eaa083dc` review. The two new table cases failed before the fix. - **Retention on the no-contract route:** cleanup now compares what each selected legacy file's env actually receives, in place of an empty map. A legacy file holding a value its env still wouldn't receive is kept, and only key names are reported. - **Unlistable primary:** the real permission test stays. CI gets a bounded Linux step for it, and an injected filesystem-failure test covers the branch on other platforms. - **Ordering:** `.hack/.gitignore` is created right after the migration, before the Docker gate. **Verified locally:** typecheck, and 405 focused tests pass at this head. Gitleaks passes over `eaa083dc..2bb4271`. **Not verified yet:** hosted CI at this head was still running when this was written, including whether the Linux step really runs its test. The full suite wasn't run locally. **Not reviewed yet:** the retention and scoped-deletion changes have had no independent review. Sections below describe earlier heads. --- ## Current state at `eaa083dc47f7ff03893bbf1a5e205fb53502f64b` **Held; don't merge.** Besides HACK-1694 below, this is held for the preservation risks in HACK-1695: this PR extends that deletion to Docker down. Don't release it without #538 either: on this PR alone, doctor with a malformed contract still writes an empty layer and removes `.env` and the contract, as it does on `next`. Merged with `next` at `f938d629`, which includes HACK-1710 (#537), HACK-1685 (#535), #533, #534 and the fixture daemon prevention (#536). The only conflict with #537 was additive, and both refusals are kept. The sections below this one describe the PR's earlier history; where they differ, this section is current. **Review coverage, one independent reviewer, reading only:** source through `d0376173`, docs and tests through `f980705d`, the merge `c07cc4ae`, and the `env-arguments.ts` and `env.ts` resolution in `b9ec8778`. Not read: the docs commit `11d5e8ff`, or the rest of the later merges. Automatic Codex review stopped on a quota notice at `91db1781` and wasn't retried; it isn't a gate. This is not an approval. **Added since `b9ec8778`: a migrated value no longer replaces a service or host value.** A later env layer beats an earlier one whatever their scopes, so a migrated machine-local global value silently replaced a service or host value from an earlier layer. Measured at `b9ec8778` with real files and synthetic values: `api` moved from its tracked value to the legacy one, and likewise a host value and a tracked overlay's service value. On `next` the same inputs don't migrate, so this was a regression in this PR. The migration now resolves each target with the planned layers, for the base, every overlay and any overlay a linked worktree inherits, and refuses before writing, naming keys, targets and envs only. Each new test failed on the source before its repair; the first control for the inherited case was invalid (its fixture hit an unrelated refusal), so it was fixed and rerun. Coverage added after that: a service and a host value together, a Compose service named `host`, a scoped secret (replaced, equal, and in the same local layer), and an unknown scope. Disabling the check fails 4 of those tests and removing the secret guard fails 1; the rest guard against false refusals. A layer with an invalid scope refuses earlier, as unreadable, and has its own test. A Linux-only test covers a primary directory that can't be listed, and CI is its first real run: check that it ran, not just that the suite passed. At this head: 395 pass, 0 fail across env-legacy-service-scope, project-env-config, symlink-boundary, doctor-fix-noninteractive, doctor-command, local-inheritance, project-env-metadata and write-refusal-gaps. Biome, typecheck and gitleaks pass. No local full suite. **Review of this fix, the same independent reviewer, reading only:** `b9ec8778..a62921a`, no blockers. The last commit `eaa083dc` (test-only: its two accuracy points) wasn't read. On macOS a primary directory that can't be listed turns inheritance off, for migration and runtime alike, so the listing refusal is reached only on Linux. Still open: HACK-1709 items 9 and 10. This is not an approval. **Added since `ca5ef35f`:** - A generated `.hack/.env` isn't migrated as legacy values, and Hack checks its text to tell. A hand-edited one refuses. - These now refuse and write nothing: - a state entry that exists but can't be read, including a dangling link or a link into a directory without search permission; - an env layer that can't be read or resolved, including a missing project env key on any overlay; - a Compose file that can't be read; - another `env_file` that sets a migrated key as `export K=`, `K:`, a bare `K` or after a closing quote. - The project boundary from HACK-1685 now also applies when a layer is a symlink. Without that, `hack doctor` rewrote a Compose file outside the project through a linked `.hack`. **Local verification at `91db1781`** (the merge to `b9ec8778` had its own focused run and scan; Bun 1.4.2, one file at a time, empty environment, stubs first on PATH, private HOME and TMPDIR): - `env-legacy-service-scope` 193, `project-env-metadata` 9, `project-env-write-refusal-gaps` 28 with the ExFAT case skipped, `doctor-fix-noninteractive` 22, `doctor-fix-adopted-project` 4, `doctor-command` 28, `project-env-config` 58, `daemon-autostart-fixture-guard` 7, `project-env-symlink-boundary` 28, `project-env-local-inheritance` 9, `project-env-stable-ciphertext` 18, `cli-spawn-env-isolation` 7, `launchd-isolation` 14, `e2e-launchd-guard` 5. - Compose merge through `docker compose config` with a socket that doesn't exist, Compose v5.5.0: 38 inputs, 29 refuse, 9 migrate with the same rendered values, none differ. - Controls: the dangling-link case fails on `ce5c770c` source, and the no-search state link migrated generated values on that source. - Typecheck, Biome on changed files and gitleaks on `ca5ef35f..91db178` pass. **Not verified locally:** the full suite, and `scripts/docs/check.ts`, which needs generated `site/content/docs` and fails the same way on `next`. CI at the exact head is the evidence for both. All 20 checks passed at `91db1781`; read `b9ec8778` from its checks. **Open decisions, not waived:** - **HACK-1694:** with a symlinked layer, `hack doctor` can remove `env_file: .env`, and a service loses a value only that file set. Measured on this head and on `next` with Docker up; this PR also makes it happen with Docker down. Documented, not fixed. - A missing project env key on any overlay blocks the whole migration. It stays fail-closed until decided. - `hack env set` refuses an in-project dangling layer link, where `next` replaces it. `hack env add` still replaces it. Documented until decided. **Release signal:** squash title `fix(env): refuse --service and unusable layers in legacy env set/unset`. Non-blocking follow-ups are in HACK-1709. --- Refs HACK-1370. Unit G60. ## Root cause 1. **`--service` dropped in legacy mode.** `handleEnvSet` (`src/commands/env.ts`) sends a project with env layers to `env add`, which reads `--service`. Its legacy `.hack/.env` branch never read `args.options.service`, so `hack env set NEW --service api --value-stdin` wrote a global `NEW` to `.hack/.env` and exited 0. An invalid scope such as `'Bad Scope!'` was accepted the same way. `handleEnvUnset` had the same gap in its legacy branch, so `unset KEY --service api` removed the **global** value. 2. **A dangling layer switched formats.** `projectEnvConfigExists` checks the default layer with `pathExists`, which follows symlinks, and checks overlays with `Dirent.isFile()`. A dangling `.hack/hack.env.default.yaml`, or any symlinked overlay, therefore read as "no env layers". `env set` then wrote `.hack/.env` and exited 0. 3. **Read paths printed a stack.** `readProjectEnvLayers` threw a plain `Error("Failed to parse …")` for an invalid layer. The top-level handler treats a plain `Error` as unexpected, so `hack env list` / `explain` / `materialize` printed the full stack. ## Fix - `env set` validates `--service` up front with `assertValidProjectEnvScopeName`, in both formats, before the project, stdin or any file is touched. This is the same closed one-line refusal that `env add` gives. `env unset` does the same in its legacy branch. - In legacy mode, a non-global scope is refused with a `CliUsageError` (`LEGACY_ENV_SERVICE_SCOPE_MESSAGE` in `src/cli/env-arguments.ts`). The message names the remedy: migrate with `hack doctor --migrate-env-config`, or omit `--service`. The legacy `.hack/.env` holds one value per key, so the scope can't be honoured. Omitting the scope, or passing `--service global`, still works. The refusal happens before stdin is read or a secret store is resolved. - New `projectEnvConfigExistsForMutation` (`src/lib/project-env-config.ts`) is used by `env set` / `env unset` to choose the env format. Every `.hack/hack.env.*.yaml` entry must be a regular file or a symlink that resolves to one, and either kind counts as an env layer. Anything else (a dangling link, a link to a directory, a directory) is refused with the new `ProjectEnvLayerPathConflictError` (`E_PATH_CONFLICT`, one `CODE: message` line naming the path), and nothing is written. - `readProjectEnvLayers` now throws `ClosedCliError` with the same text as the write path's parse refusal. The layer parser no longer echoes an invalid scope name: it is file content and could contain a value or a line break, which would break the one-line output. - `docs/env.md` ("Legacy repos and migration") documents the legacy `--service` refusal and the layer-entry rule. **Decision for review:** a symlink that resolves to a regular layer file is **accepted** and counts as an env layer. It is not refused. The atomic writer already writes through such links by design, and refusing them would break projects that use them today. The only change here is that a symlinked overlay no longer silently selects legacy mode. If you want every symlinked layer refused, that is a one-line change in `projectEnvConfigExistsForMutation`. ## Tests `tests/env-legacy-service-scope.test.ts` (new, 14 tests). Every CLI child gets a private HOME, HACK_HOME, global config and Docker client config, plus an `encrypted_file` secret backend inside the temp dir with an env-provided key and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`. That keeps the keychain unreachable even when a guard is mutated away. Values are synthetic and assembled at runtime, and every test asserts that no value appears in the output. - legacy `set --service api` (with and without `--secret`) refuses, and nothing is written to `.hack/` or the secret store - legacy `set --service '<invalid>'` gives the closed scope message, without echoing the argument - positive control: legacy `set` without `--service`, and with `--service global`, still writes `.hack/.env` and creates no layer - legacy `unset --service api` / invalid scope refuses, and `.hack/.env` is unchanged - `set` and `unset` refuse a dangling default layer, a dangling overlay, a local layer linked to a directory, and a directory default layer with `E_PATH_CONFLICT` on one line; the entry and `.hack/` are unchanged - a symlinked overlay that resolves to a regular file selects the env layers (writes `hack.env.default.yaml`, no `.hack/.env`) - a unit test of `projectEnvConfigExistsForMutation` and the error class and code - `env list` / `env explain` on invalid YAML, and on a layer with a line-break scope name, print one line with no stack under `HACK_LOGGER=console|clack|gum` - an invalid layer read is a `ClosedCliError` whose reason does not echo the scope **Mutation proof** (each mutation applied alone, then reverted; each was caught): | Mutation | Caught by | |---|---| | drop `assertLegacyEnvScope` in set | legacy set scope refusal | | drop it in unset | legacy unset scope refusal | | no scope validation in set | invalid-scope refusal | | no scope validation in unset | legacy unset refusal | | set uses old `projectEnvConfigExists` | 4 layer-entry tests + symlinked-overlay test | | unset uses old `projectEnvConfigExists` | 4 layer-entry tests | | linked layer not counted | symlinked-overlay test | | read path back to plain `Error` | 3 logger tests + closed-error unit test | | scope name echoed again | 3 logger tests + closed-error unit test | | conflict code `E_UNEXPECTED` | 4 layer-entry tests + unit test | ## Evidence - Bun 1.4.2 (`mise x bun@1.4.2`), `bun install --frozen-lockfile`, no `bun.lock` change. - Local (macOS, this checkout): - `bun run check` passes, including `privacy:check: ok` (warnings only, all pre-existing) - `bun run --cwd packages/cli typecheck` and root `bun run typecheck` pass with 0 errors - focused tests pass: the new file, `project-env-write-refusals`, `env-value-stdin-cli`, `env-key-argument-redaction-cli`, plus 13 other env/project-env files - `env-selection-parity` needs the config compiler, which is not built locally; it runs in the M3 suite below - `gitleaks` 8.30.1 `dir` scan of the `git log -p origin/next..HEAD` export: no leaks. - `bun run docs:cli-reference`: no change (the CLI surface is unchanged). - Full suite on the M3 at the exact head: pending (queued on the shared M3 lock behind other units; this PR will be updated with the result) ## Gaps - Read paths still fall back to legacy for a dangling or unreadable default layer: `env list` shows "base (.hack/.env only)" and exits 0. Only the `set`/`unset` format decision refuses here. Reported below as a follow-up finding. - `hack doctor --migrate-env-config` migrates only projects that have `.hack/hack.env.json`. A legacy project with only `.hack/.env` has no automated migration, and the refusal message points to the docs section for that case. **(Fixed in round 2, below.)** ## Further findings (not fixed here) 1. **Read paths silently pick legacy for a dangling or unreadable default layer** (Medium). - Repro: `.hack/hack.env.default.yaml` is a dangling symlink, or mode 000; run `hack env list --path <p>`. - Expected: a closed refusal, as `set`/`unset` now give. - Actual: exit 0 with "env_selection base (.hack/.env only)". `readProjectEnvLayers`' non-strict read treats both as absent, so runtime injection probably falls back the same way (not verified). 2. **Legacy `env set --local` is ignored** (Low). - Repro: in a legacy project, `printf v | hack env set NEW --local --value-stdin`. - Expected: a refusal, or a documented no-op. - Actual: "Updated .hack/.env", exit 0. `.hack/.env` is machine-local, so the effect matches, but the flag is silently dropped. ## Round 2: a working remedy for `.env`-only legacy projects **Verifier defect (Medium, blocking).** The legacy `--service` refusal points to `hack doctor --migrate-env-config`, but that command did nothing for a project with `.hack/docker-compose.yml`, `hack.config.json` and `.hack/.env` but no `.hack/hack.env.json`. `maybeMigrateProjectEnvConfig` returned silently without the contract, and `migrateLegacyProjectEnv` returned `legacyDetected: false`. The user looped: refused, doctor did nothing, refused again. A second cause made the loop worse: doctor ran the env migration only after the Docker gate, so with Docker unreachable it skipped the migration even for a project with a contract. **Fix** (head `456bdad5`; `next` 5208ee5 merged in first, with no conflicts): - `migrateLegacyProjectEnv` takes a new `migrateUndeclaredDotenv` option. Without a contract it now migrates `.hack/.env` and its overlays: - each `.hack/.env` entry becomes a plaintext global value in `hack.env.default.yaml` - each `.hack/.env.<overlay>` entry that differs from the base becomes a global value in `hack.env.<overlay>.yaml` - a project with no legacy env file gets an empty default layer, so the refusal's remedy works for every legacy shape - Without a contract, nothing names secret-backend values, so none are read or migrated. `hack.config.json`, and any secret backend it configures, is left unchanged, and the backend's store is never offered for cleanup. Values go only to the layer files; they are never returned or printed. - Only doctor sets the option. The implicit migrations that other commands run (for example `env add`, `env hydrate`, `env materialize`, and the project and session flows) still need the contract, so no command starts migrating `.env`-only projects on its own. - `hack doctor --migrate-env-config` passes the option. It now runs the env migration, which changes project files only, before the Docker gate. Its plan line names the `.env`-only case. - `docs/env.md` ("Legacy repos and migration") documents the `.env`-only migration: secrets are not migrated and must be set again, the config is unchanged, the migration runs without Docker, and the cleanup prompt follows. The `--service` paragraph now names the remedy and says it also covers `.env`-only projects. The refusal text (`LEGACY_ENV_SERVICE_SCOPE_MESSAGE`) is unchanged, because the command it names now works. **Tests added** - `tests/env-legacy-service-scope.test.ts`, 3 new tests (17 in total): - `.env` plus `.env.staging`, no contract: `set --service api` is refused; the explicit migration writes the expected default and overlay layers (the overlay keeps only the changed key); the config file is byte-identical; the cleanup candidates are exactly the two dotenv files; then the same `set --service api` exits 0 and writes the `api` scope; no secret store is created and no value appears in the output. - a project with no legacy env file: the migration writes an empty default layer, and `set --service api` then works. - without the option, a contract-less project is not migrated and `.hack/` is unchanged. - `tests/doctor-fix-noninteractive.test.ts`, 2 new cases: `doctor --migrate-env-config --no-interactive` on a `.env`-only project writes `hack.env.default.yaml` with the legacy value, once with Docker reachable and once with it unreachable. No value appears in the notes. The mocked `docker info` exit code is now set per test. **Mutation proof** (each mutation applied alone by `scratchpad/G60/mutate.sh`, then reverted; the source was confirmed restored): | Mutation | Failing tests | |---|---| | doctor does not pass `migrateUndeclaredDotenv` | doctor 2 | | doctor requires the contract again | doctor 2 | | env migration moved back after the Docker gate | doctor 1 (the unreachable case) | | migrator ignores the option | doctor 2, env 2 | | migrator migrates without the option | env 1 | | no overlay-vs-base filter | env 1 | | contract-less path rewrites `hack.config.json` | env 1 | **End-to-end repro** (`scratchpad/G60/remedy-loop.sh`, private HOME, HACK_HOME and DOCKER_CONFIG, fake Docker host, stub `security`, `launchctl` and `docker` on PATH, keychain fallback and hackd autostart disabled): 1. `env set NEW_SETTING --service api --value-stdin` is refused (exit 1). 2. `hack doctor --migrate-env-config --no-interactive` writes `hack.env.default.yaml`, removes `.hack/.env`, then reports Docker unreachable. Exit 1 comes from the failed Docker check; no value is printed. 3. The same `env set` exits 0 with `scope=api`. The layer holds `global.KEPT_SETTING` and `api.NEW_SETTING`, and no secret store is created. **Gates at `456bdad5`** - `bun run --cwd packages/cli typecheck`: 0 errors. - Root `bun run check`: exit 0, `privacy-check: ok`. No diagnostics in the changed files. - `ultracite check` on the changed source and test files: clean. - gitleaks 8.30.1 `dir` over the `git log -p origin/next..HEAD` export: no leaks. (`gitleaks git` is refused by the worktree guard.) - Focused tests, private env: `tests/doctor-*`, `tests/env-*`, `tests/project-env-*` and `tests/compose-env-*` gave 527 pass and 2 fail across 42 files. The two failures do not involve this PR: - `env-selection-parity` needs the config-compiler binary, which is not built in this checkout. - `project-env-layer-lock`'s "concurrent legacy dotenv sets and removals" fails intermittently on `next` 5208ee5 as well (2 of 8 runs on a clean `next` worktree, 1 of 5 here). This is reported below. - `doctor-command`'s native-input test needed `--timeout 60000` locally; it spawns 4 CLI children in a 5 s default. With that timeout it passes 28/28. - The CLI surface and consumer agent guidance did not change, so `docs:cli-reference` and `generate:agent-plugins` were not needed. - Hosted CI at `456bdad5`: every check passes on run 40060725798 (head SHA `456bdad5`, conclusion success). The `test` job (one job, not sharded) reports 8112 pass, 0 fail, 8269 tests across 575 files, and both new test files ran, including the 5 new tests. All four docker-e2e shards, Candidate core (macOS and Ubuntu), Config compiler (4 platforms), Runtime state models, linux-process-lifetime, runtime-images and secret-scan pass. **Round 1 gap now closed:** the second bullet under "Gaps" above (doctor migrated only projects with `.hack/hack.env.json`) is fixed by this round. The earlier "Full suite on the M3: pending" line is replaced by hosted CI's `test` job: round 1 at `f87ee54c` passed (7913 pass, 0 fail), and round 2 is reported above. **Set aside, not in this PR:** a local draft that refused *every* symlinked layer, including one that resolves to a regular file, was not included. It would reverse the documented "Decision for review" above and the atomic writer's documented symlink behaviour, and no verifier asked for it. **Further finding (not fixed here)** 3. **`project-env-layer-lock` legacy dotenv concurrency test is flaky on `next`** (Low; test or race). - Repro: run `bun test tests/project-env-layer-lock.test.ts` repeatedly on a clean `next` 5208ee5. - Expected: 9/9 pass every run. - Actual: about 1 run in 4 fails "concurrent legacy dotenv sets and removals of different keys all apply". One worker exits 1 with the `readReplaceablePrivateFile` refusal "the env file … is a symlink or not a regular file", which means the inode changed between `lstat` and `open`. The legacy `.hack/.env` read in `upsertDotEnvValue` seems to race with another worker's rename, outside the serialized section. The failing worker's stderr also carries a stack, not one closed line. ## Round 3: a safe `.env`-only migration (verifier defects A, B and C) Head `78b72762`. It merges `origin/next` `7dac4a03` (no conflicts) and adds one commit. This section replaces the round 2 behaviour described above. Round 2 copied `.hack/.env` values into the tracked `hack.env.default.yaml`. The round 2 test descriptions and repro lines that name that file now describe the old behaviour. **What changed** - **C (blocking): values stay out of tracked files.** `.hack/.env` is gitignored and can hold passwords or API keys, so the migration no longer copies its values into a tracked layer. Each `.hack/.env` entry becomes a plaintext global value in the gitignored machine-local layer `hack.env.local.yaml`. Each overlay entry that differs from `.hack/.env` goes to `hack.env.<overlay>.local.yaml`. The tracked `hack.env.default.yaml`, and `hack.env.<overlay>.yaml` for each migrated overlay, are written with no values, which switches the project to env layers. The `.hack/.gitignore` managed block is ensured first, as `hack env set --local` does. A machine-local target that git tracks is refused. So is one inside a git checkout where git cannot report its status (fail closed, as `hack env hydrate` does). The resolved env still holds every value. - **A: invalid keys refuse closed.** Before anything is written, every key in `.hack/.env` and its overlays must match `^[A-Z_][A-Z0-9_]*$`. Otherwise the migration exits with `E_CONFIG_INVALID` on one line, for example "2 keys in .env, 1 key in .env.staging do not". The keys themselves are not shown, because a malformed line (`export X=…`, base64 padding) may hold a value; this matches `ENV_KEY_INVALID_MESSAGE`. Nothing is written, the legacy files are kept, and no cleanup is offered. - **B: doctor uses the mutation format check.** `maybeMigrateProjectEnvConfig`, and the `--plan` step, now decide the format with `projectEnvConfigExistsForMutation`. A symlinked layer counts as an env layer: doctor says the project already uses env layers and writes nothing through the link. A dangling or non-file layer refuses with `E_PATH_CONFLICT`, and the entry is left in place. The migration also runs the same check itself, so a direct caller cannot write through or replace such an entry either. - **Related guards in the same path.** A legacy `.env.default`, `.env.local` or `.env.<name>.local` would land on the default layer or a machine-local layer rather than an overlay, so it is refused with `E_CONFIG_INVALID`. `.env.state.json` is Hack's own state file and is no longer read as an overlay. Every layer is created with a compare-and-swap against an absent file. If a later write fails, the layers this call created are removed, so the project stays on the legacy files. - `docs/env.md` "Legacy repos and migration" documents where the values go, that teammates do not get them through git, and each refusal. **Tests** (`tests/env-legacy-service-scope.test.ts`, `tests/doctor-fix-noninteractive.test.ts`) - The `.env`-only migration test now asserts that values are in `hack.env.local.yaml` and `hack.env.staging.local.yaml`, that the tracked layers hold `global: {}`, and that `resolveProjectEnvConfig` still returns every value. A service-scoped `env set` still works after migration. - New, C: in a `git init` repo with `DATABASE_PASSWORD` and an `sk_test_…`-shaped `STRIPE_SECRET_KEY` (synthetic), no file that `git ls-files --cached --others --exclude-standard` lists contains either value, and the local layers are ignored. - New, A: `GOOD_KEY`, `node_env` and `export OTHER` (plus `lower_case` in `.env.staging`) give a closed `E_CONFIG_INVALID` refusal with per-file counts. No key or value appears, and the `.hack` entries and legacy file bytes are unchanged. - New: `.env.default`, `.env.local` and `.env.staging.local` are each refused, and nothing is written. - New: a machine-local target that is in the git index (but not on disk) is refused with `E_PATH_CONFLICT`. - New, B (library): a symlinked `hack.env.staging.yaml` is refused, and its outside target keeps its content. A dangling `hack.env.default.yaml` gives `ProjectEnvLayerPathConflictError` and is still a symlink afterwards. - New, B (doctor): `doctor --migrate-env-config --no-interactive` with a symlinked overlay exits 0, reports "already uses the new env config files", and leaves the link, its target, `.env` and `.env.staging` unchanged. With a dangling default layer it exits 1 (`E_PATH_CONFLICT`), and the link and `.env` are kept. - The round 2 doctor migration test now expects the value in `hack.env.local.yaml` and an empty tracked default layer. **Mutation proof** (each change made alone, then the named test run, then the source restored): | Mutation | Killed by | | --- | --- | | invalid-key refusal disabled (A) | invalid-keys test | | doctor back to `projectEnvConfigExists` (B) | doctor symlinked-overlay test | | migration's own format guard removed (B) | library existing-entries test | | values written to the tracked default layer (C) | secret-shaped git test | | reserved overlay check disabled | all 3 reserved-name tests | | git-tracked check disabled | git-tracks test | **Evidence** - `bun run --cwd packages/cli typecheck`: 0 errors. Root `bun run check` (includes `privacy-check: ok`): passes, with warnings only from existing files. - gitleaks 8.30.1 `dir` over the `git log -p origin/next..HEAD` export: no leaks. - Focused tests, run locally with load average around 83: `env-legacy-service-scope` passes 24/24 and `doctor-fix-noninteractive` passes 19/19 (each file run on its own). - The CLI surface and consumer agent guidance did not change, so `docs:cli-reference` and `generate:agent-plugins` were not needed. - Hosted CI at `78b72762`: every check passes on run 40129035737 (head SHA `78b72762`, conclusion success). That includes `test` shards 1-4 (the full suite, now sharded), docker-e2e shards 1-4, secret-scan, Candidate core (macOS and Ubuntu), Config compiler (4 platforms), Runtime state models, linux-process-lifetime and runtime-images. **Gaps** - The rollback of created layers after a failed write has no test, because a write failure after the preflight is hard to inject. Its logic is short and only removes paths that did not exist before the call. - The doctor dangling-layer test survives when only the doctor check is reverted, because the migration's own guard still refuses. It fails only when both guards are reverted; each guard is covered by its own test. - Behaviour change for review: migrated values now stay on the machine that ran the migration. Teammates need `hack env set` or `hack env set --secret` for the values they share. This is documented. **Further findings (not fixed here)** 4. **Legacy `.hack/.env.<overlay>` files are outside the managed `.hack/.gitignore` block** (Medium; pre-existing). - Repro: in a git repo with the managed block, create `.hack/.env.staging`, then run `git ls-files --others --exclude-standard`. - Expected: ignored, like `.hack/.env`. - Actual: listed as committable. `HACK_DIR_GITIGNORE_ENTRIES` covers `.env` and `.env.state.json` only, so legacy overlay values can be committed. This PR routes overlay values to ignored local layers and does not touch the input files. 5. **The contract migration (`hack.env.json` present) still writes contract-declared plain values to tracked layers** (Low; by design, but worth confirming). - Repro: a contract that marks `DB_PASSWORD` as non-secret, with the value in `.hack/.env`; run `doctor --migrate-env-config`. - Expected: confirm whether plain values should also go to the local layer. - Actual: the value lands in tracked `hack.env.default.yaml` (`buildMigratedValues`). Only keychain-sourced values are encrypted. Refs HACK-1370 ## Round 4 (2026-10-31): reads refuse too, review findings, Codex P1s This round merges `origin/next` (`e42bcf08`, no conflicts) and adds these commits: - `23c8e7ea`: **reads refuse a broken layer** (finding 1 above, now fixed). - Only ENOENT counts as an absent layer. - A selected layer entry that is not a regular file (dangling link, directory, link to a directory) refuses with `E_PATH_CONFLICT`. An unreadable file refuses with one closed line. - This covers `env list`, `env explain` and runtime env resolution, which previously exited 0 and showed "base (.hack/.env only)". - Two tests that pinned the old null fallback were updated: `project-env-metadata` and the #468 directory case. - `69fb8ea0`: reviewer round 1 (one P2, four P3s). - The contract migration decides the format with the mutation check and writes create-only, so it never replaces a dangling layer. - Doctor `env mode` and project metadata report a broken layer instead of "legacy". - The `.env`-only migration refuses non-overlay `.env.<name>` names, skips template and backup copies, and never offers a git-tracked file for cleanup. - Doctor offers files Compose references for cleanup only after the Compose repair removed those references. - Adds a test for an unreadable layer. - `0e732928`: Codex P1 threads. - Machine-local layers are created 0600 and create-only. - Inside a git checkout, each local layer must be git-ignored, so a read-only `.gitignore` refuses instead of only warning. - An unreadable legacy file refuses with `E_PERMISSION` instead of migrating as empty. - `69def4f0` (another session): symlinked legacy overlays are migrated, and cleanup removes only the link. - `ede4cb9b`: refusals name the files, JSON-quoted, and say to select a renamed overlay with `--env`. - `1b1806d1` (another session): refuse a migration whose Compose `env_file` points at a template or backup copy that the migration leaves out. **Mutation proof:** each new guard was disabled on its own and its test failed, then the source was restored and confirmed. That covers the read-path refusal (4 tests), the contract guard, the doctor and metadata classifiers, the unreadable branch, the name refusal, the template skip, the tracked-cleanup filter, the Compose-repair cleanup helper, the 0600 publish, the ignore check, and the unreadable legacy file. **Real-target repro:** `./dist/hack` built from this branch, with a private HOME and synthetic values. Exit codes: | Command | Exit | Result | |---|---|---| | legacy `env set NEW --service api --value-stdin` | 1 | refuses and names the remedy; `.hack/.env` unchanged | | legacy `env set NEW --service 'Bad Scope!'` | 1 | closed scope message | | legacy `env unset KEPT --service api` | 1 | `.hack/.env` keeps `KEPT` | | legacy `env set` with no scope (control) | 0 | still works | | dangling `hack.env.default.yaml`, `env set` | 1 | one-line `E_PATH_CONFLICT` | | dangling `hack.env.default.yaml`, `env list` | 1 | one-line `E_PATH_CONFLICT` (exited 0 with legacy fallback before this round) | | invalid YAML layer, `env list` / `explain` | 1 | one line, no stack | No value appeared in any output. **Review:** one persistent Claude Opus 5.5 reviewer. Round 1 at `23c8e7ea`: CHANGES. Round 2 at `69fb8ea0`: CLEAN. Round 3 at `ede4cb9b`: CLEAN. Codex's four P1 threads are answered and resolved. **Gates:** `bun run typecheck` and `bun run check` pass on Bun 1.4.2. The focused env, doctor, project-env, compose-env and project-meta suites pass, except `doctor-fix-adopted-project`, which fails locally with or without this PR (native adoption state), and `env-selection-parity`, which needs the config compiler. Hosted CI covers both. **Follow-up (not fixed here; already true before this PR):** the contract branch of `migrateLegacyProjectEnv` does not apply the `.env`-only overlay-name rules. With `.hack/hack.env.json`, a `.hack/.env.local`, `.env.example` or `.env.state.json` file is treated as an overlay, so its values can land in the wrong layer. No value is lost. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- codesmith:footer --> --- <a href="https://app.blacksmith.sh/hack-dance/codesmith/hack/pr/436?autoLogin=true&ref=codesmith_pr_footer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v3.svg"><img alt="View with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png" width="162" height="28"></picture></a> <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1795309313&installation_model_id=17053&pr_number=436&ref=codesmith_pr_footer&repository=hack-dance%2Fhack&return_to=https%3A%2F%2Fgithub.com%2Fhack-dance%2Fhack%2Fpull%2F436&signature=d4db006104551d52ac8f82bada2e90e9bce1aa20066501a5bca05ca148ac30ce"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img alt="Autofix with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png" width="190" height="28"></picture></a> <sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you need.</sup> <!-- codesmith:autofix:disabled --> <!-- /codesmith:footer --> ## Migration refusals added through review (head 49cf488) The `.env`-only migration now refuses, writing nothing, when: - only some Compose services list a legacy file, a service would gain base keys, or a default overlay is not listed while another legacy file is - Compose uses top-level `include`, service `extends` or a variable `env_file` path - a Compose `environment` entry (including `${KEY}`) or another `env_file` sets a migrated key - the Compose file interpolates a key the default overlay changes - an entry is read differently by Compose: unquoted ` #`, `$` outside single quotes, `\` in a quoted value, text after a closing quote, or a line without `=` - `.hack/.env` or an overlay links outside `.hack` The contract migration reads regular overlay files only and refuses `.hack/.env` or a default overlay linked outside `.hack`. Known behaviour: if no service lists a legacy file, the migration proceeds and every service receives its keys once that env is selected, because those keys may only feed interpolation. Doctor names them. Validation at 49cf488: focused env, gitignore, doctor and pre-commit suites pass (env-legacy-service-scope 90, 0 fail); mutation runs fail as expected; independent exact-head review reports no blocking defects, using `docker compose config` on Compose v5.5.0 and not running containers. No native, timing or TLC tests were run. Hosted CI is the check for the full suite. Follow-ups: HACK-1682, HACK-1685, HACK-1688, HACK-1689. ## Further changes through review (head b3f8f00) These supersede the 49cf488 validation above. In addition to the refusals listed there, the `.env`-only migration now refuses: - an `env_file` order that gives a key a different value from the layers - a repeated `env_file` entry, a `format`, a `~` path, a `!reset` or `!override` tag, or `include` with no `services` - a Compose file that isn't one YAML document - any key the default overlay changes whose name appears in the Compose file or another `env_file`, or a `COMPOSE_*` setting - `DOCKER_*`, `BUILDKIT_*`, `BUILDX_*`, proxy settings, `SOURCE_DATE_EPOCH`, `HOME` or `PATH` in a legacy file The contract migration refuses any symlinked `.hack/.env` or default overlay, wherever it points. **Other checkouts.** Once a migration is committed, a teammate's own `.hack/.env` and overlays may stop reaching services. Hack compares those files with what the layers deliver. `hack up` warns, doctor reports it, `hack env materialize` refuses, and `hack doctor --migrate-env-config` moves the values to their local layers. Messages name valid keys only, count malformed entries, and name unreadable files. **Behaviour changes to note:** - `hack env materialize` can now refuse where it used to overwrite. - `hack up` can print a new warning. - `replacePrivateFileAtomic` has a new optional `beforeRename` hook. **Validation at b3f8f00:** - Focused suites: 0 fail (env-legacy-service-scope 141; hack-gitignore, project-env-metadata, project-env-write-refusal-gaps, doctor-fix-noninteractive, doctor-fix-adopted-project, doctor-command, pre-commit-hook, project-env-config, private-env-file, project-env-write-refusals, missing-project-error-code). - Same-reviewer review of this exact head: no blocking defects, using `docker compose config` on Compose v5.5.0 and direct calls to the migration functions. It didn't use running containers, a real `git pull` between checkouts, or the interactive prompts. - Concurrency evidence is deterministic steps through a test seam or a git shim. No timed race was run. As with the shared atomic writer, the last check narrows the window and can't close it. - No native, timing or TLC tests were run. **Hooks:** commits 581da64 through ef9147f were made with `HUSKY=0`, so the repository hooks didn't run on them. commitlint on each message and Biome on the final tree passed separately, which isn't the same as the hooks having run. 190c66b onward used the normal hooks. Added follow-ups: HACK-1686, HACK-1690, HACK-1691, HACK-1692, HACK-1693. ## Final behaviour change (head ca5ef35) This supersedes the "Known behaviour" and b3f8f00 validation above. **Global delivery now needs consent.** Migrated values are global and reach every service, including third-party images. The `.env`-only migration refuses unless every service lists each legacy file with values to migrate. This includes a file no service lists, whose values today only feed Compose interpolation, and a non-default overlay, which must become `env.defaultOverlay` or have its values moved. Listing the file is how a developer accepts this. A value Compose interpolates can't be set per service: keep it in the shell, or list the file. **Also refused:** a migrated base value that would replace a value a tracked overlay sets, when the legacy overlay file doesn't set that key. **Remaining exposure, documented and noted by doctor but not refused:** a service added after migrating receives the global values, and host lifecycle hooks gain them. Tracked in HACK-1692. **Validation at ca5ef35:** - Focused suites: 0 fail (env-legacy-service-scope 150; the other 11 suites as listed above). - `bun run typecheck` and `bun run check`: exit 0. - Same-reviewer review of this exact head: no blocking defects, using `docker compose config` on Compose v5.5.0 and the real `hack doctor` and `hack env` CLI. It didn't use containers, a real `git pull` between checkouts, or interactive prompts. Nothing is a timed race. - No native, timing or TLC tests were run. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
…#538) Head: `93460bcbf30f6b7c46796114991d0d735b521e37`. Merged with #436 at `2bb42712`, which includes `next` at `ae032e79` (#539). **At this head:** `.env.default` refuses even when it adds no value of its own, kept from `next`. `deliveredValues` comes from this PR's `overlaysResolved` values. **Verified locally:** typecheck, and 501 focused tests pass on this combination. Gitleaks passes over `10e55e2f..93460bc`. **Not verified yet:** hosted CI at this head was still running when this was written. **Not reviewed yet:** the merge forward and the #436 changes it brings in have had no independent review. Text below describes earlier heads. --- **Draft, stacked on #436, and held for preservation risks. Don't merge before #436; retarget to `next` and rebuild once #436 lands.** Head: `10e55e2f4fad0002b62a9358871f8787c242d12b`. Merged with #436 at `eaa083dc`, which includes `next` at `f938d629` (#537). ## Summary - HACK-1688. A contract migration could write a `plain_env` value that came only from your shell into a tracked layer. It now refuses, naming the key and never the value, before Hack provisions any secret-store key, and prints scope-preserving recovery commands. - If plaintext is mirrored to a backend whose key isn't available, Hack can't tell whether the shell value is also in the backend, so it refuses that key the same way. - `SecretStoreKeyUnavailableError` lets that case be told apart from other backend failures, which still stop the migration. - The contract migration now refuses, before writing any layer: a `hack.env.json` it can't open or parse; an overlay named `default`, `local` or `<name>.local` in any case that sets its own value; and a service that isn't a valid env scope when a value would be written for it. On `next`, these overwrote the base value, wrote the machine-local path, wrote an unparseable layer, or, for a malformed contract, wrote an empty layer and let doctor delete `.env` and the contract. - The refusal prints a command only for a name the CLI accepts. `--env base` or `--env default` would select no overlay and replace the base value, so those names, and others `--env` or `--service` can't take, are reported instead. ## Verification Local, Bun 1.4.2, one file at a time, empty environment, stubs first on PATH, private HOME and TMPDIR. `Bun.secrets` is stubbed where the keychain branch runs; no real keychain or credential by design. All values are synthetic. - At `15ecf62d`: `env-contract-no-key-provisioning` 4, `env-contract-shell-values` 21, `secret-store` 32, `env-backend-command` 19, `env-legacy-service-scope` 193, `cli-spawn-env-isolation` 7, `project-env-symlink-boundary` 28, `doctor-fix-noninteractive` 22. At `5ebbf6b2`, which only changes a comment: the first two again. - Biome on changed files, typecheck and gitleaks pass. **Mutation control.** Each mutant is one source line in its own checkout, with exports kept, so tests load normally: | Mutant | Existing tests | New no-provisioning tests | |---|---|---| | The read asks for provisioning | not detected | fail 4 of 4 | | The store ignores the flag | not detected | fail 4 of 4 | | Plain `Error` at the no-provisioning site | not detected | fail 2 | | Plain `Error` at the disable-flag site | fail 2 | pass (flag is off here) | | Caller rethrows key-unavailable | fail 2 | fail 3 | The existing tests set `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK`, which stops before any provisioning code, so they couldn't show this. That's why the new tests exist. At `9dfd095e`: `env-contract-shell-values` and `env-contract-no-key-provisioning` 54 together, `secret-store` 32, `env-backend-command` 19, `env-legacy-service-scope` 193, `project-env-config` 58, `project-env-symlink-boundary` 28, `doctor-fix-noninteractive` 22, `project-env-write-refusal-gaps` 28 with 1 skip, `cli-spawn-env-isolation` 7, `daemon-autostart-fixture-guard` 7. Each new set of refusal tests was first run against the source before its repair and failed. The recovery controls passed before any repair, so that behavior was already correct and only untested. At `2b2352fa`: tests for a contract that can't be opened, through the real CLI for all three forms, with controls. They name `env add` only; `exec`, `hydrate`, session and `set` without `--local` rest on a scratch harness, not a named test. HACK-1716, `2b2352fa..99ee84f`: for a contract that can't be read or parsed, doctor now says it can't be read, the plan step and `env mode` check say the migration refuses, and doctor goes straight to the refusal without asking. **Behaviour change:** an interactive user can no longer decline at that prompt and carry on to the later fix steps. Non-interactive runs already refused. Each new test failed on the source before its repair. At `ad6f0590`: 333 pass, 0 fail across shell-values, no-provisioning, env-backend-command, daemon-env, doctor-fix-noninteractive, doctor-command and env-legacy-service-scope. At `99ee84f4`: shell-values and doctor-command, 81 pass, 0 fail. Biome, typecheck and gitleaks pass. Combination with #436's scoped-value refusal: git auto-merged with no conflicts. Typecheck passed before each merge commit. Exact combination `10e55e2f`: 462 pass, 0 fail across env-set-local-contract, env-exec-command, project-env-metadata, write-refusal-gaps, write-refusals, shell-values, no-provisioning, env-backend-command, doctor-command, env-legacy-service-scope and local-inheritance. The previous merge `31ded153`: 480 pass, 0 fail across its focused list. An unopenable contract refuses before the no-contract route, so the two sides' checks don't meet. Independent source review (one reviewer, reading only) covered through `ad6f0590` and the merge `31ded153`, with no blocking finding. The later merge `10e55e2f` and `99ee84f4` weren't read; the first only brings in #436's test-only `eaa083dc`. `99ee84f4` only renames a test, drops an assertion that could no longer fire and removes a comment, and it wasn't read. This is not an approval. Not run locally: the full suite. CI at this head is the evidence for it. CI at earlier heads: `2b2352fa` run 41365434498 succeeded. `99ee84f4` failed once on `Candidate core (macos-latest)` (HACK-1718, open, cause not established), and its retry succeeded per root. ## Release Signal - Commit / squash title: `fix(env): refuse a contract migration that would commit a shell value` - Release intent: `fix` - Should this PR trigger a release signal? `yes` - If `yes`, include the required changeset or equivalent release artifact in this PR. Follows the repository's release workflow from the squash title. ## Semantic Surfaces - Does this change affect `hack run`, `hack exec`, env resolution, runtime-state reconciliation, or lifecycle shell/process behavior? `yes`. Commands that migrate implicitly, such as `hack env exec`, now refuse with a shell-only value. - Targeted tests: `tests/env-contract-shell-values.test.ts`, `tests/env-contract-no-key-provisioning.test.ts`. - Docs: `docs/env.md`, `docs/security/env-secrets-threat-model.md`. ## Risks / Follow-up **Why this is a draft, open items:** - **HACK-1695, why this is held:** `hack doctor --migrate-env-config --no-interactive` deletes `.env` and untracked `.env.bak` and `.env.example` holding values no layer received. It reproduces on `next` with Docker up (synthetic values, not evidence of real loss). #436 extends it to Docker down. Owner: Kit; not fixed here. Handoff posted on the ticket, not yet accepted. - **HACK-1714:** an overlay secret with no overlay file is silently not delivered after the migration. Through doctor, with the store in the project, it's deleted. Baseline, not fixed here. A refusal is proposed on the ticket, undecided and unpublished. - **HACK-1715:** doctor deletes an in-project encrypted store and key file, including entries nothing migrated. Baseline, not fixed here. Same family and owner question as HACK-1695. - **HACK-1694, HACK-1712, HACK-1711's unusual-name row:** open decisions, unchanged. - **Gates with no test yet:** the cloud key snapshot, the printed prefix for the new class, the cooldown path, a reserved name combined with a store, and a mid-loop I/O failure, which has no rollback on this route. **Product contracts, undecided and unchanged here:** - A refused exported or unavailable mirrored key, including in CI. - Overlay recovery pinning a local value. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- codesmith:footer --> --- <a href="https://app.blacksmith.sh/hack-dance/codesmith/hack/pr/538?autoLogin=true&ref=codesmith_pr_footer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v3.svg"><img alt="View with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png" width="162" height="28"></picture></a> <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1796167408&installation_model_id=17053&pr_number=538&ref=codesmith_pr_footer&repository=hack-dance%2Fhack&return_to=https%3A%2F%2Fgithub.com%2Fhack-dance%2Fhack%2Fpull%2F538&signature=688cf54c93349464d864ae5536a86bbc1df3b2bcab9680f784b723993aa8ea9f"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img alt="Autofix with [code]smith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png" width="190" height="28"></picture></a> <sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you need.</sup> <!-- codesmith:autofix:disabled --> <!-- /codesmith:footer --> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Contract-based
hack doctor --migrate-env-config --no-interactivecould delete.hack/.envand untracked.env.bakor.env.exampleafter migrating only declared keys, losing entries no env layer received.Keep each legacy dotenv file unless every parsed entry matches a declared value delivered to the base or overlay layers, including inherited base values. Report retained key names only. Unreadable contents refuse cleanup;
.env.defaultrefuses migration before it can overwrite the base YAML layer. Run missing.hack/.gitignorerepair after migration so a refusal leaves it unwritten. Fully delivered files retain the existing cleanup defaults; option 2 is not implemented.Scope is dotenv cleanup. This does not establish the separate HACK-1715 encrypted-store/key retention or HACK-1714 overlay-only reachability guarantees. #436 widens this path by moving migration before the Docker gate and needs the same preservation rule. #436 and #538 were not modified.
Validation:
doctor-fix-noninteractive,project-env-config,agent-onboard-commandandcli-passthrough-argv; no skips/failures. The five HACK-1695 regressions also passed independently..hack/.envis deleted; restoring it passes.nextat f938d62 deletes all three files; final head preserves them, reports key names only, and exits 0. A synthetic unreadable file is deleted by the earlier built CLI, while final head refuses cleanup, preserves the legacy files and contract, and exits 1. No live Docker acceptance is claimed.ef07314a57be35d8a692937df64ce5ca1f69ec4aafter three rounds; the output-collision and unreadable-file findings were fixed.Pending gates: final-head hosted CI is running. Entire's trail approval needs a human. Hosted Codex review could not run because of its reported usage limit; all posted comments were read and no inline findings were produced.
Verification limits: initial local full-suite attempts were interrupted because their invocation environment was invalid (Turbo filtering and a blanket daemon-disable setting). The corrected isolated focused run passed; final-head hosted test shards are the full-suite gate, not a local full-suite green claim.
Release intent: patch correction to local env migration data preservation. No merge or release requested.