Skip to content

fix(env): refuse --service and unusable layers in legacy env set/unset - #436

Merged
roodboi merged 68 commits into
nextfrom
wf/g60-legacy-env-service-scope
Nov 2, 2026
Merged

roodboi merged 68 commits into
nextfrom
wf/g60-legacy-env-service-scope

Conversation

@roodboi

@roodboi roodboi commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

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..2bb42712.

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..a62921a8, 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..91db1781 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)

  1. 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)

  1. 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.
  2. 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 fix(env): close the remaining raw env write refusals and publish without hard links #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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

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.

In a legacy .hack/.env project, `hack env set` and `hack env unset`
ignored --service and changed the global value. They now refuse an
invalid scope with the same closed message as env-layer projects, and
refuse any other non-global scope with a usage error that names the
remedy (migrate to env layers, or omit --service).

The env-format decision of set/unset no longer treats a layer entry that
is not a usable file (a dangling symlink, a link to a directory, a
directory) as absent: it refuses with E_PATH_CONFLICT before stdin is
read or anything is written, instead of falling back to .hack/.env. A
symlink to a regular layer counts as an env layer.

Reads of an invalid layer (env list, explain, materialize) now raise a
closed one-line error instead of a plain Error with a stack, and an
invalid scope name in a layer is no longer echoed into that line.

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 23, 2026

Copy link
Copy Markdown

HACK-1370

roodboi and others added 6 commits October 23, 2026 23:30
…projects

`hack env set`/`unset --service` in a legacy project points to
`hack doctor --migrate-env-config`, but that command did nothing for a
project with only `.hack/.env` (no `.hack/hack.env.json`), so the user
looped between the refusal and doctor.

- `migrateLegacyProjectEnv` takes `migrateUndeclaredDotenv`: without a
  contract, each `.hack/.env` entry becomes a plaintext global value in
  `hack.env.default.yaml` and each differing overlay entry a global value
  in `hack.env.<overlay>.yaml` (an empty default layer when no legacy file
  exists). Secret-backend values are not migrated, and hack.config.json and
  its secret store are left unchanged and never offered for cleanup. Only
  doctor sets it; implicit migrations by other commands still need the
  contract.
- `hack doctor --migrate-env-config` uses it, and runs the file-only env
  migration before the Docker gate so an unreachable Docker no longer
  skips it.
- docs/env.md documents the .env-only migration and the remedy.

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly migration

hack doctor --migrate-env-config, for a project with no hack.env.json, now
writes the .hack/.env and overlay values to the gitignored machine-local
layers (hack.env.local.yaml, hack.env.<overlay>.local.yaml) and the tracked
hack.env.default.yaml/overlay layers with no values, so secret-shaped
plaintext never lands in a tracked file.

Before writing anything it refuses closed, keeping the legacy files: keys
that do not match ^[A-Z_][A-Z0-9_]*$ (E_CONFIG_INVALID, counted per file,
never shown), legacy .env.default/.env.local/.env.<name>.local overlays,
existing or unusable layer entries (E_PATH_CONFLICT), and a machine-local
target git tracks or cannot report on. Layers are created only where
nothing exists, and created layers are removed if a later write fails.

Doctor now decides the env format with projectEnvConfigExistsForMutation,
so a symlinked layer is never written through and a dangling layer refuses
with E_PATH_CONFLICT instead of being replaced.

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…of falling back to .hack/.env

env list, env explain and runtime env resolution read a selected layer with
readTextFile, which returns null for a dangling symlink, a directory or an
unreadable file. The read then looked like "no env layers" and fell back to
the legacy .hack/.env format with exit 0. Only ENOENT is now an absent layer;
a non-file entry refuses with E_PATH_CONFLICT and an unreadable one with a
closed one-line error.

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-11-02T00:34:02.737805Z
We couldn’t complete this review. Comment @codex review to try again.
b9ec877 Manual request
🔒 Security Review ✅ Completed 2026-11-01T06:45:14.404057Z ca5ef35 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

This security review couldn’t start because the paying account has insufficient available credits. The payer can check their balance and add credits in the Codex usage dashboard, then try again. If you do not manage the paying account, contact this repository's admins.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 23c8e7ea9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/project-env-config.ts Resolved
Comment thread src/lib/project-env-config.ts Resolved
Comment thread src/lib/project-env-config.ts Resolved
Comment thread src/commands/env.ts Resolved
roodboi and others added 2 commits October 31, 2026 22:34
… checks, and keep tracked or compose-referenced legacy files out of cleanup

Review round 1 (HACK-1370):
- the contract migration decides the format with the mutation check, so a
  dangling default layer refuses (E_PATH_CONFLICT) instead of being replaced;
  its layer writes are create-only
- doctor's env checks and project metadata report an unusable layer entry
  instead of the legacy format
- the .env-only migration refuses .env.<name> files whose name is not an
  overlay name, skips template and backup copies, and never offers a
  git-tracked legacy file for cleanup
- doctor offers compose-referenced legacy files for cleanup only after the
  compose repair removed those references
- a test for an unreadable default layer

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e .env-only migration

Codex review on #436:
- machine-local layers are published create-only as 0600 files
- in a git checkout, each machine-local layer must be ignored after the
  .hack/.gitignore step; a read-only .gitignore without the managed block
  refuses instead of only warning
- an unreadable legacy .env or overlay refuses (E_PERMISSION) instead of
  migrating as empty and then being offered for deletion

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

This security review couldn’t start because the paying account has insufficient available credits. The payer can check their balance and add credits in the Codex usage dashboard, then try again. If you do not manage the paying account, contact this repository's admins.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e73292835

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/project-env-config.ts Resolved
roodboi and others added 2 commits October 31, 2026 18:59
discoverLegacyOverlayNames skipped symlinks, so a symlinked .hack/.env.<overlay>
was left out of the migration and its values were lost once the project
switched formats. A link to a readable file now migrates like a regular overlay,
and cleanup removes only the link. A dangling link or a link to a directory
refuses with E_PERMISSION before anything is written.

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n one line

Review round 2 (optional P3s): the non-overlay-name refusal lists the files
instead of counting them and says to select a renamed overlay with --env. Both
name refusals JSON-quote each file name, so a control character cannot break
the one-line message.

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

This security review couldn’t start because the paying account has insufficient available credits. The payer can check their balance and add credits in the Codex usage dashboard, then try again. If you do not manage the paying account, contact this repository's admins.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ede4cb9bf4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/project-env-config.ts Resolved
Comment thread docs/env.md Resolved
…ate files

A Compose env_file that uses a template or backup copy such as .env.sample
reached no migrated layer, and the Compose repair then removed the reference,
so the next launch lost those values. The .env-only migration now refuses first.

Docs: overriding a migrated key for one service needs --local, because the
migrated global value in hack.env.local.yaml wins over a tracked service value.
A test pins both halves of that behaviour.

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review November 1, 2026 00:55
@roodboi

roodboi commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1b1806d15f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/project-env-config.ts Resolved
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 1b1806d15f

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Compose loads env_file: .env.staging on every launch, but the .env-only
migration put those values only in the staging layers and the Compose repair
removed the reference. With no default overlay, the next launch without --env
lost them although the migration reported success. It now refuses first, unless
the overlay is env.defaultOverlay or adds nothing beyond the base.

Refs HACK-1370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 36fab6c79c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/project-env-config.ts Resolved
@roodboi

roodboi commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Brings in #537. The conflicts are additive: this branch's --service refusal and next's
--local refusal both stay, ahead of stdin and any write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

A later env layer beats an earlier one whatever their scopes, so a migrated
local global value silently replaced a service or host value in a tracked
layer. The migration now resolves every target's value with the planned
layers, in the base and every overlay, and refuses before writing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi and others added 4 commits November 1, 2026 20:05
A migrated local value outranks a primary checkout's local overlay layer too,
and those overlays weren't listed here. Document the new refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reserved overlay name in a primary checkout isn't an overlay, and the base
refusal now leads with the remedy that always works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…check

Also refuse a primary env directory that can't be listed on Linux, and say
that Compose checks apply to a created legacy overlay file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi added a commit that referenced this pull request Nov 2, 2026
…539)

Contract-based `hack doctor --migrate-env-config --no-interactive` could
delete `.hack/.env` and untracked `.env.bak` or `.env.example` after
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.default` refuses migration before it can
overwrite the base YAML layer. Run missing `.hack/.gitignore` repair
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:
- Final-head local focused verification: 147 tests passed across
`doctor-fix-noninteractive`, `project-env-config`,
`agent-onboard-command` and `cli-passthrough-argv`; no skips/failures.
The five HACK-1695 regressions also passed independently.
- Doctor regressions cover base and untracked backup/example overlays,
empty undelivered values, inherited values, Compose-reference repair,
fully delivered cleanup, refusal ordering, the output collision, and
unreadable base/backup files.
- Disabling the retention guard makes the doctor regression fail because
`.hack/.env` is deleted; restoring it passes.
- Built CLI, private HOME and Docker stubs: unfixed `next` at 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.
- Pinned Bun 1.4.2: build, root typecheck, root check/privacy and
changed-test lint pass. Lint has 34 existing warnings and no errors.
- The one persistent codex-dio reviewer is CLEAN at exact head
`ef07314a57be35d8a692937df64ce5ca1f69ec4a` after 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.

---------

Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
roodboi and others added 4 commits November 1, 2026 20:50
CI's test shards run on macOS, so this test always skipped. Run it in the
existing Config compiler job's Linux legs, failing if it skips, and add a
separate injected case for the refusal branch on every platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cleanup assumed every selected legacy file's entries had reached a layer. A
legacy local layer breaks that, so compare with what each env now receives and
keep the file, naming the keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al value

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi added a commit that referenced this pull request Nov 2, 2026
…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>
roodboi and others added 5 commits November 2, 2026 14:11
Keeps PR 542's machine-local contract migration and destination preflights,
removes the --local refusal it made obsolete, and keeps PR 436's --service
refusal and no-contract migration. Doctor's note now lists moved keys.

With a compatibility local base, the migration's env-layer check now returns
before the alias preflight. It writes nothing and leaves every file in place,
so that test now pins this result. The CLI alias tests still cover the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Outside git, a hack.env.local.yaml with `environment: local` is the `local`
overlay. Base values written there reach `--env local` and not the default env,
past the scoped-value check, so the migration now refuses and writes nothing.

Also corrects the symlink refusal's reason after the machine-local contract
migration, and makes the cleanup test assert delivery and its candidate lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inside git, tracking decides what hack.env.local.yaml is, so a tracked layer got
a refusal whose remedy didn't work. It now gets the tracked-layer refusal again.
Adds tests for that case, for the remedy, and for cleanup keeping a file whose
values its env doesn't receive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi merged commit 94bde3c into next Nov 2, 2026
23 checks passed
@roodboi
roodboi deleted the wf/g60-legacy-env-service-scope branch November 2, 2026 21:53
roodboi added a commit that referenced this pull request Nov 2, 2026
…-security-pins

Brings in #436. Conflict in the doctor env materialization check: keep its
refusal for an unusable layer entry, and keep inspecting a legacy-format
project that has recorded materialization state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi added a commit that referenced this pull request Nov 3, 2026
…#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>
roodboi added a commit that referenced this pull request Nov 3, 2026
)

**Update at 7bd4fa0:** merged `next` with #538 (conflicts in
`doctor.ts`, `project-env-config.ts`, `secret-store.ts` and #538's
shell-value test). The keychain-disabled refusal is now a
`SecretStoreKeyUnavailableError`, which #538's check depends on. One
behaviour change: with the keychain disabled, no key and no store file,
a run counts as first use and creates a key file, as on `main` (#423).
On `next` before this merge it stopped with a refusal, so I removed the
one step of #538's test that asserted that stop. The #538 test also
restores a preloaded `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK` instead of
deleting it. Focused env, secret-store and doctor tests pass on the
pinned Bun 1.4.4. Details on HACK-1758.

**Update at ede96a8:** merged `next` with #543 (conflicts in
`secrets.ts` and `secret-store.ts`); the shared keychain now gates first
and then uses #543's deadline-bounded calls, with a new test for that
order.

**Update at 03a0c75:** merged `next` with #436 (one conflict, in
doctor's env materialization check, kept both sides); focused env,
doctor and MCP tests show no new failures on Bun 1.4.4.

## Update at b2ca3cd: review findings at 30f2b1b

This note replaces the older text below where they disagree. `next` had
not moved, so there was nothing new to merge.

- **Relative overrides in MCP sessions:** a session now refuses only a
relative `HACK_HOME`. A relative `HACK_GLOBAL_CONFIG_PATH` resolves
against the captured cwd again, which matches child commands because
they run in that cwd. The previous note's refusal of that setting was a
mistake and is reverted: it made the server refuse what the CLI and its
children accept.
- **Guard seam:** the fixture case now says it uses the seam. The seam
answers only the next read, so it can't stay set for later files, and a
new nested test checks that a later file gets the real reader back. The
outer test now says it shows detection and reporting, not that the
repair (`disableNativeProxies`) works.

**Verified here** (private HOME and HACK_HOME,
`HACK_DAEMON_AUTOSTART_DISABLE=1`): `bun run typecheck`, `bun run
check`, `bun run docs:check`, gitleaks, and focused tests: 264 pass, 61
skip, 0 fail across 31 files, all `*mcp*` files included.

| Mutation | Result |
| --- | --- |
| Refuse a relative `HACK_GLOBAL_CONFIG_PATH` again | 2 tests fail |
| Resolve a relative `HACK_GLOBAL_CONFIG_PATH` against the process cwd |
2 tests fail |
| Resolve a relative `HACK_HOME` against the session cwd | 3 tests fail
|
| Seam stays set | The guard test fails |

**Not rerun:** the whole `bun run test` suite on this head; hosted CI is
the check.

---

## Update at 30f2b1b: review findings at 676bf9b

This note replaces the older text below where they disagree. Merged
current `next` (`f94b17ff`, including #547) with a merge, not a rebase.

- **`ffc49b33` Relative `HACK_HOME`:** `captureMcpSessionContext` now
refuses a relative `HACK_HOME` or `HACK_GLOBAL_CONFIG_PATH` with the
CLI's error wording. The earlier "MCP sessions still resolve it" note no
longer applies. A socket client with such a context loses only its own
session. The CLI itself still doesn't check a relative
`HACK_GLOBAL_CONFIG_PATH`; only MCP sessions refuse it.
- **`71975bb6`:** dropped `logs` from the commands that record branch
usage, in `docs/cli.md` and `src/lib/branch-usage.ts`.
- **`30f2b1bf` Guard positive case:** this is a seam, not a real leak.
Under Bun 1.4.4, these routes left no native change that `process.env`
hides: set and delete, `loadEnvFile`, a Worker, libc `setenv`, and a
replaced env object. So the fixture hands the guard's native reader a
changed environment. It tests the boundary check and its report, not a
real Bun leak.
- **`30f2b1bf` SDK comment:** reworded in
`tests/mcp-tool-result-size.test.ts` for the pinned SDK 1.32.
- **`RELEASE_PAT`:** stays on `actions/checkout` in `release.yml` and
`release-prepare.yml` (`676bf9b7`). The earlier note about
`useblacksmith/checkout` receiving it and the 9-step count no longer
apply.

**Verified here** (private HOME and HACK_HOME,
`HACK_DAEMON_AUTOSTART_DISABLE=1`):
- `bun run typecheck`, `bun run check`, `bun run docs:check` and
gitleaks over `origin/next..HEAD` pass.
- Focused tests: 325 pass, 61 skip, 0 fail across 35 files, all `*mcp*`
files included. `tests/mcp-tool-result-size.test.ts` passes (13).
- A real `hack mcp serve` start with either relative override exits 1
with the error.
- Mutation, session rule restored to cwd-relative: 4 tests fail.
Mutation, guard's native comparison disabled: the guard test fails.

**Read, not run:** `checkByteCutoff` in
`tests/e2e/scenarios/mcp-logs-compose.ts` should still hold. Events are
parsed from the capture, so they total about 8 MiB and fit under the 9.5
MiB result limit. No event is dropped, so the reported cutoff stays
`{bytes, 8 MiB}`, and #547 only trims `stdout` on a code point boundary.
Docker wasn't used; hosted CI runs this scenario.

**Not rerun:** the whole `bun run test` suite on this head; hosted CI is
the check.

---

**Current head: `676bf9b7`.** It merges `next` at `ddc73a0d` and keeps
`RELEASE_PAT` on `actions/checkout@v4` in `release.yml` and
`release-prepare.yml`. `main` passes that credential to
`useblacksmith/checkout@v1`, a third-party action on a floating tag.
Doing the same on `next` is a security decision of its own, so it isn't
part of this port. All other checkout steps ported here stay on
Blacksmith. This changes the "`RELEASE_PAT` now goes through
`useblacksmith/checkout`" point below.

---

Refs HACK-1758. Forwards the security pins and fixes from `main` to
`next`, one topic per commit. `main` is not merged. Each commit was
checked by reading `next` and, where `main` has a regression suite, by
running that suite against unmodified `next`.

## Classification

| `main` commit | What it does | Result | Evidence on this branch |
| --- | --- | --- | --- |
| `69ed70e9` #457 | Bun 1.4.4 and Node 24.22.1 pins | **Ported**, with
the exceptions below | `2c84aac3`: all workflows including
`prerelease.yml`, `.tool-versions`, `mise.toml`, `packageManager`,
Dockerfiles, candidate build guards, e2e and pattern fixture tags |
| `b5d3212a` HACK-1404 | Delete unset env vars instead of assigning
`undefined` | **Already present** | `tests/helpers/env.ts:12`
(`restoreEnv`, used by 109 test files). No `process.env.X = undefined`
remains, and every restore of a value that may be undefined is guarded.
Bun 1.4.4 did change proxy deletes, handled in `2852393c` |
| `395351bc` #454 | Dependabot `bun` ecosystem | **Ported** |
`f97ca462`: `.github/dependabot.yml`, same as `main`. GitHub reads this
file from the default branch, so it has no effect until `next` lands |
| `739c4e57` #456 | next, MCP SDK, drizzle-orm bumps | **Ported** |
`dd92fae0`: MCP SDK 1.26.0 to 1.32.0, drizzle-orm ^0.44.5 to 0.45.2,
next 16.2.6 to 16.4.1 in `examples/next-app`; `site/` already has next
16.4.3 |
| `75225e00` #513 | zod 4.6.7 (GHSA-mrhx-c7xc-m3w5) | **Ported** |
`dd92fae0`: zod 4.3.5 to 4.6.7; `site/` already has it |
| `4c194a12` #423 | Hermetic keychain tests | **Ported** | `80abd5fc`:
`src/lib/keychain.ts`, `tests/preload.ts`,
`tests/keychain-guard.test.ts`. The fake is not frozen, because `next`'s
tests spy on it |
| `3312658f` #526 | LogQL escaping and empty Loki follow hint |
**Already present** | `src/lib/logs.ts:62`, `:91` and
`src/ui/loki-logs.ts:442`, from #417 and #478. `main`'s
`tests/loki-tail.test.ts` passes unchanged on unmodified `next` |
| `98ab4e70` #527 | No global state under the cwd for an empty or
relative HOME/HACK_HOME | **Ported** and extended | `ac12697c`:
`src/lib/home-dir.ts`, plus the same bug in `next`-only code: MCP
session capture, launchd access, native AWS environment, setup migrate |
| `65e4d97b` #528 | Fail and sweep on leaked e2e Docker networks |
**Ported** | `8a79897a`, `32cd2edf`: `tests/e2e/docker-ownership.ts`,
harness and `run.ts` |
| `736544ef` #529 | Inherit primary local env overlays in linked
worktrees | **Partly present; the gaps are ported** | `next` already
inherited (`resolvePrimaryLocalProjectDir`). 24 of the 36 cases in
`main`'s test failed on unmodified `next`. `40d0df6f`, `e4837dca` add
worktree-family verification, the env opt-out, fail-closed reads, YAML
routing and doctor's obsolete report |
| `83d71ac7` #530 | Keep branch usage out of the tracked tree |
**Ported** | `eb30178f`, `d925c5ef`: `src/lib/branch-usage.ts`;
`touchBranchUsage` is gone |
| `541c611b` #520 | `useblacksmith/checkout` on Blacksmith Ubuntu
runners | **Ported** | `f97ca462`: 9 steps across `ci`, `release`,
`release-prepare`, `release-node-runtime-image`. Hosted and macOS jobs
keep `actions/checkout`, as on `main` |
| `1c1cc983` #521 | Resize three runners | **Left out: needs a
decision** | The numbers come from `main`'s single `docker-e2e` job, and
`next` shards that job. The original PR itself marks the sizes as
unbenchmarked, and it adds cost |
| `08e0412a`, `2e8dc61d`, `91b14bf0`, `60f3c0b9`, `010880c7`, `5e03d47e`
| 4.2.x releases | **Not applicable** | Excluded by the issue; `next`
has its own release line |
| `2dd3853b` #512 | Remove private names from a sample digest |
**Ported, not on the issue's list** | `25dfed3e`: `next` is public and
still carried these names. Drop this commit if you want it handled
separately |

## Pins that stay on the old version

- **Native file and storage helper, `oven/bun:1.4.2-slim`**
(`src/lib/native-compose-vm-file-protocol.ts:6`,
`src/lib/native-compose-storage-witness-docker-artifact.ts:19`, `:66`).
The catalog pins exact image, Bun and libc bytes per platform, and
`scripts/generate-native-storage-witness-helper.ts` says changed
artifacts need separate runtime qualification. Moving it is a product
decision. Evidence: the helper bundle reproduces byte for byte under Bun
1.4.4, and its source has no Brotli, zlib or fetch use. Bun 1.4.2 is
still in that image. CI now pulls both images, and
`docs/reference/native-config-files.md` still names 1.4.2-slim
correctly.
- **Frozen benchmark workload**:
`scripts/application-benchmark-protocol.json` and
`scripts/check-application-benchmark-protocol.py` freeze this pin as
part of the protocol, and `scripts/benchmark-native-compose.py` defaults
to the same image. No Bun from these ships.
- **Records of past runs**: the observed client version in
`src/agents/client-capabilities.ts:501`, the `docs/versions/` snapshots,
`scripts/test-shard-durations.json`, and comments describing 1.4.2
behaviour. The capability record still says MCP SDK 1.26.0 and needs a
new observation.
- **Test fixtures** that use `1.4.2` only as sample data.

## Behaviour to review

- A relative `HACK_HOME` is now an error in the CLI. MCP sessions still
resolve it against the captured checkout, because
`tests/mcp-session-context.test.ts` pins that as `next`'s contract.
- `HACK_WORKTREE_INHERIT_LOCAL=0` applies to the Compose path. The graph
runner follows only `worktree.inherit_local`, because its adoption
proofs record that setting. `docs/cli.md` says so.
- A `.git` link that cannot be verified, such as a bare-repo worktree,
now reads no primary overlays instead of failing the command.
- Two expectations in `main`'s `project-env-worktree-local` test keep
`next`'s behaviour: the layer guard's refusal for a symlink leaving the
project, and the tombstone from `env unset --local`, which hides an
inherited value.
- `tests/global-state-guard.test.ts` changed because Bun 1.4.4 now
carries a `process.env` delete into its native environment. That test
was written to fail on this upgrade. It now asserts the new behaviour
and fails if the old one returns. The guard's native check has no
reachable leak under 1.4.4 and stays as a regression check.
- `useblacksmith/checkout` now receives `RELEASE_PAT` in
`release-prepare.yml` and `release.yml`, as on `main`. No permissions or
secrets changed.
- The private names removed by `25dfed3e` remain in git history. This PR
does not rewrite history.

## Verified

Local, on macOS arm64 with Bun 1.4.4 from mise. All tests ran with a
private temporary HOME and HACK_HOME, `HACK_DAEMON_AUTOSTART_DISABLE=1`,
and a `DOCKER_HOST` pointing at a socket that does not exist.

- `bun install --frozen-lockfile`: pass.
- `bun run typecheck`, `bun run check`, `bun run docs:check`: pass on
the head.
- gitleaks 8.30.1 over `origin/next..HEAD`: no leaks.
- `.hack/tasks.json`: the method first reproduced the old `mise.toml`
pin and all three `package.json` script pins. Only `mise.toml` needed
re-pinning, and `tests/project-task-catalog.test.ts` passes.
`tests/integration-ownership.test.ts` passes with no fingerprint change.
- Focused tests on the head: 446 pass and 5 fail across 24 files. All 5
are in `tests/doctor-fix-noninteractive.test.ts` and are described
below.

`bun run test` ran once, before the last three fix commits and before
rebasing onto #542: **8505 pass, 127 skip, 27 fail** across 598 files.

| Failures | Cause | Also on unmodified `next`? | Now |
| --- | --- | --- | --- |
| 20 in `native-compose-ingress`, `native-compose-proxy-probe` | My
private `DOCKER_HOST` | Yes, on Bun 1.4.2 and 1.4.4 | All 25 tests in
those files pass without that variable |
| 3 in `global-state-guard` | Bun 1.4.4 proxy delete change | No: they
fail on `next` with 1.4.4, pass with 1.4.2 | Fixed in `2852393c`,
passing |
| 1 each in `project-env-local-inheritance`, `cli-spawn-env-isolation`,
`e2e-docker-ownership` | This PR's ports | No | Fixed in `e4837dca`,
`32cd2edf`, passing |
| 1 in `native-compose-route-claims` (`Unexpected end of JSON input`) |
Not established. The host was under heavy load and this PR does not
touch that code | Not reproduced there | Passed 5 of 5 focused reruns on
this branch |

The 5 `doctor-fix-noninteractive` failures appear only when that file
runs alone under plain `bun test` with this isolation. Unmodified `next`
fails the same 5 the same way, and they passed in the full run.

## Not verified

- Hosted CI, including the Linux jobs and the sharded `docker-e2e`. No
Docker scenario ran locally, so the 1.4.4 and 24.22.1 image tags,
`docker-ownership` against a real engine, and `worktree-parallel-up` are
unproven until CI runs.
- Docker image builds, the release workflows and Blacksmith checkout
behaviour.
- Whether CVE-2026-111497 is reachable in Hack's use.
- The whole suite on the final head; CI is the check for that.
- The issue's other done criteria: a green merged `next`, a published
prerelease, and the published build reporting the new pin.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- codesmith:footer -->
---
<a
href="https://backend.blacksmith.sh/track/run-proof?expires=1796240682&installation_model_id=17053&pr_number=545&ref=codesmith_pr_footer&repository=hack-dance%2Fhack&return_to=https%3A%2F%2Fgithub.com%2Fhack-dance%2Fhack%2Fpull%2F545&variant=run_proof_on_this_pr&signature=cfadffa3b85fba4f3f8041d26088dbdafce9792afdc357fbc9c8ba4cec915168"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/proof-run-proof-on-this-pr-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/proof-run-proof-on-this-pr-light.svg"><img
alt="Run Proof on this PR"
src="https://pr-comments-assets.blacksmith.sh/codesmith/proof-run-proof-on-this-pr-dark-3x.png"
width="168" height="28"></picture></a>
<sup><a
href="https://app.blacksmith.sh/hack-dance/codesmith/hack/pr/545?autoLogin=true&ref=codesmith_pr_footer">Open
in [code]smith</a> · Need help on this PR? Tag
<code>@codesmith-bot</code> with what you need.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

---------

Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant