Environment and secrets
Hack now uses repo-level env overlay files as the canonical project env model.
The short version:
- commit
.hack/hack.env.default.yaml - optionally commit
.hack/hack.env.<overlay>.yaml - use
.hack/hack.env.local.yamland.hack/hack.env.<overlay>.local.yamlfor worktree-local overrides - keep
.hack.secret.keyout of git, or provideHACK_ENV_SECRET_KEY - let
hack up,hack run,hack restart,hack host exec,hack host shell, and env-aware session flows inject values directly at runtime - use
hack env materializeonly when you explicitly need a compatibility.hack/.env
Current model
Canonical env state lives at the repo root:
.hack/hack.env.default.yaml.hack/hack.env.<overlay>.yaml.hack/hack.env.local.yaml.hack/hack.env.<overlay>.local.yaml
Legacy compatibility note:
- if an older repo already tracks
.hack/hack.env.local.yamlas the shared--env localoverlay, Hack keeps reading that file as the shared overlay - in that compatibility case, worktree-local default overrides are written to
.hack/hack.env.default.local.yamlso--localmutations do not overwrite tracked shared config
Compatibility and local-only state still lives under .hack/:
.hack/.env.hack/.env.state.json.hack/.internal/...
Encryption key material is local-only by default:
.hack.secret.key- or
HACK_ENV_SECRET_KEY
hack init now scaffolds .hack/hack.env.default.yaml by default.
Ignore rules (committed .hack/.gitignore)
Hack owns a committed .hack/.gitignore that keeps machine-local generated
files out of git. Because the file is committed, fresh clones and linked git
worktrees inherit the rules with zero setup. It covers (patterns relative to
.hack/):
.internal/.branch/.env.env.state.jsonhack.env.local.yamlhack.env.*.local.yaml.hack.env*.tmp(staging files an interrupted env write can leave behind)tickets/(retained only to contain retired machine-local ticket caches during upgrades)
How it is maintained:
- created by
hack initfor a new project, and refreshed when you write machine-local env overrides (hack env set --local,hack env hydrate) - in an existing project without it (for example one an earlier Hack version
initialized),
hack doctorreports the "hack gitignore" check as a warning, andhack doctor --fixcreates the file, or restores its managed block, as its own step. Commit the result so fresh clones and worktrees get the rules.hack initdoes not repair an existing project unless you confirm overwriting its scaffold files. Hack never writes through a symlinked.hack/.gitignoreor.hack/(including a.hack/that links outside the repository); doctor reports that case and leaves it alone. Doctor asks git about the project root (the directory that holds.hack/), not about.hack/itself. So when the project root is not a git checkout, the check reports "Skipped (not a git checkout)" even if.hack/links into another repository; Hack still refuses to write through that link. - a read-only
.hack/.gitignoreis never replaced, the same policy as forinfo/exclude. A file with no write permission bit (for example mode 0444) is read-only for every user, root included; otherwise Hack asks the operating system whether you may write it. Hack checks the mode bits first, so an access control list (ACL) that grants you write access to a 0444 file does not make it writable for Hack; add a write bit instead. When it lacks the managed block,hack doctorwarns and names the fix (make it writable, then runhack doctor --fix), and--fixleaves it unchanged.hack env set --localandhack env hydratestill write the local layer, print the same warning on stderr (so--jsonoutput on stdout stays valid JSON), and leave the file unchanged. Interactivehack initthat overwrites an existing scaffold prints the same warning and leaves the file unchanged. A read-only file that already has the block is fine. - runtime commands (
hack up,down,restart,run,exec) never write into your working tree to set up ignores, so they leavegit statusunchanged. Before they write.hack/.internal/or.hack/.branch/files, they add the same patterns, anchored at the repository root, to a marked block in the repository'sinfo/exclude(resolved withgit rev-parse --git-common-dir, so one block covers every linked worktree). Env commands that write.hack/.env(hack env setwithout an env config,hack env materialize) do the same. That file is local to your clone and never committed. The update is best-effort: if git is unavailable, orinfo/orinfo/excludeis a symlink or not writable, Hack leaves it alone and the command still runs. "Not writable" follows the.hack/.gitignorerule above: aninfo/excludewith no write permission bit is left alone even for root or when an ACL grants write. - never commit
.hack/.internal/: it holds machine-local generated state that runtime commands rewrite. If it is tracked,hack doctor --fixuntracks it. - the entries live inside a marked, hack-managed block; anything you add outside the markers is preserved on every rewrite
- the root
.gitignorestill gets a.hack.secret.keyentry when the secret key is first generated (the key lives at the repo root, not under.hack/). Hack writes that entry before the key, so if the entry cannot be written, no key is created. Before a--secretset or hydrate uses an existing checkout-local key that git does not ignore, Hack adds the entry; if it cannot, or a later rule re-includes the key, the command is refused and nothing is encrypted with that key. The refusal names the cause: either the entry could not be written, or the file already lists the key and a later rule (such as!.hack.secret.key) re-includes it, which you need to remove - outside git there is nothing to commit, so an existing key does not need the
entry and Hack does not recreate a deleted root
.gitignorefor it. Hack does not run when you later turn the directory into a repository withgit init, so it cannot add the entry at that moment. If the entry is missing then,hack doctorreportsenv-key-unignored(see Key strength and exposure), and the first--secretset or hydrate in the new repository adds the entry before it uses the key, or is refused - older repos that added
.hack/.internal/to the root.gitignorekeep working; the entries are equivalent and nothing is removed
Leak detection and repair:
hack doctorruns a "generated files" check: it lists any of the paths above (including retired.hack/tickets/caches, plus.hack.secret.key) that are tracked in git, except a tracked.hack/hack.env.local.yaml, which is never flagged because older repos may intentionally track it as the shared--env localoverlay (see the legacy compatibility note above)- a tracked
.hack.secret.keyis reported as an error — it is a committed secret; rotate it after untracking hack doctor --fixuntracks the offenders withgit rm --cached(files stay on disk) and re-ensures.hack/.gitignore; commit the removal--fixand--migrate-env-configcannot be combined with--json; run the repair and JSON audit separately so a mutating request is never silently ignored. run them separately from a JSON-mode invocation
File format
Each env file is YAML with:
version: 1environmentsecretsprovider: project_keyvalues
values is split by scope:
global: values applied everywhere<service>: values applied only for that compose servicehost: optional overrides applied to host execution (hack host exec,hack host shell, and lifecycle hooks/processes)
Example:
version: 1
environment: default
secretsprovider: project_key
values:
global:
GLOBAL_FLAG: "true"
API_BASE_URL: "https://api.example.com"
api:
PORT: "4000"
SERVICE_TOKEN:
secure: v1:...Plaintext values are written as scalars. Secret values are written as { secure: ... }.
Merge rules
Hack resolves env in this order:
- load
.hack/hack.env.default.yaml - if
--env=<name>is selected, load.hack/hack.env.<name>.yaml - in a linked git worktree, load the primary checkout's
.hack/hack.env.local.yaml - if
--env=<name>is selected, load the primary checkout's.hack/hack.env.<name>.local.yaml - load this checkout's
.hack/hack.env.local.yaml - if
--env=<name>is selected, load this checkout's.hack/hack.env.<name>.local.yaml - merge
values.global - if a service scope is requested, merge
values.<service>on top
Worktree-local overrides win over shared repo overlays. Service values override global values.
Primary local overlays are read live, without copying or creating files. Checkout-local
overrides always win over primary local overlays; hack env set --local writes only to
the current checkout. Inheritance is disabled by CI=1 or CI=true,
HACK_EXECUTION_MODE=slim or codex, HACK_WORKTREE_INHERIT_LOCAL=0 (also
false, off, or no), or "worktree": { "inherit_local": false } in
.hack/hack.config.json. Symlinked primary project directories are ignored;
symlinked, non-regular, or unreadable primary local files are rejected, and an
unreadable .hack/hack.config.json in a linked worktree is an error rather than a
silent opt-in. Checkout paths containing newlines never inherit. A worktree
whose only env input is an inherited overlay still uses the YAML commands. If every
input of a materialized .hack/.env later disappears (including by opting out),
hack doctor reports the output as obsolete.
Host execution applies host values last. This includes hack host exec, hack host shell,
and lifecycle hooks/processes, which all run on the host rather than in Compose.
Projects can set a default overlay in .hack/hack.config.json:
{
"env": {
"defaultOverlay": "qa"
}
}Use --env=base to bypass that default and read only .hack/hack.env.default.yaml
(plus local overrides). default and none are aliases for base, in any case, on every
command that accepts --env; an overlay file named after one of those spellings is never
selected by name. Any other value must already be a canonical overlay name: lowercase
letters and digits joined by single hyphens (^[a-z0-9]+(-[a-z0-9]+)*$, for example
qa or qa-2), at most 128 characters long; surrounding whitespace is ignored. Hack
never rewrites a requested name, so QA, q_a, X=1 or a name longer than 128
characters is refused with a fixed Invalid --env value. message (E_USAGE under
--json) that does not repeat the argument, before any project file is read or any
child process starts. The limit keeps every file name Hack derives from the overlay
name, such as the layer's lock, within the usual 255-byte file name limit.
See the command and delivery matrix for what each command
does with the selection.
Metadata-only planning API
Repository integrations can call resolveProjectEnvMetadata from
src/lib/project-env-config.ts to inspect modern env bindings without obtaining a
key or decrypting values. Pass projectRoot, projectDir, and serviceNames;
optional envName selects an overlay, omission uses the project default, and
null bypasses that default.
The result contains selection and file paths, declared and unknown scope names,
and per-target key metadata (scope and secret) for Compose and host execution.
It contains no plaintext values, ciphertext, merged config, or raw layers. It
reads the same ordered files and uses the same scope projection as
resolveProjectEnvConfig: later layers override earlier ones, null removes an
earlier binding, and a later value can reintroduce it. Linked worktrees inherit
primary local layers before their own local overrides, subject to the existing
inheritance settings. If a service is named host, that scope remains a service
scope rather than an override applied to every host target.
A null result means no modern env configuration exists; it does not inspect or
resolve legacy .env or secret-store values. Callers retain responsibility for
their existing legacy fallback. Selected managed-env layer reads distinguish a
missing path from a failed read or a dangling symlink. Only regular files
(including readable symlinks to regular files inside the real project root) are read;
invalid or unreadable layers throw a value-free error without YAML excerpts or a nested cause. Project configuration
selection keeps its existing semantics. Metadata inspection never writes key
files or materializes .env.
Paths, variable names and scope names can still disclose private project details; keep this metadata private. It is not portable plan serialization or an atomic snapshot/admission check. It does not establish that encrypted values can be decrypted or that runtime injection will succeed.
Runtime behavior
Direct runtime injection is the default path.
Hack reads the canonical YAML files and injects the resolved env directly into:
hack uphack runhack restart- lifecycle host processes
hack host exechack host shellhack session start --env ... --service ...hack session exec --env ... --service ...
That means .hack/.env is no longer the primary runtime source of truth.
On the Compose backend, Hack still generates .hack/.internal/compose.env.override.yml
so each service gets its own environment. The file holds no values: each entry is a
${HACK_ENV_<digest>_<KEY>} reference, and Hack passes the resolved values to
docker compose through its process environment. Nothing Hack writes there contains a
decrypted value, whether up succeeds, fails or is interrupted. The file is written
with mode 0600, and Hack refuses to write through a symlink at that path. A value
reaches the container literally, $ included.
Only the docker compose invocations that read the override (up, run, exec,
ps and the dependency cache initialization) receive the HACK_ENV_* values. The other
docker commands Hack runs around Compose, such as docker inspect, docker logs and
docker events for dependency cache progress and reuse, get the same environment
without them.
Because the file holds only references, a docker compose -f .hack/.internal/compose.env.override.yml ... command run outside Hack (a script, an IDE
integration or a test harness) gets a blank value for every managed variable, with only a
per-variable warning from Compose. Use hack run or hack exec, or supply the same
HACK_ENV_* variables to that command.
A value that a service gets from the global scope uses one HACK_ENV_* reference shared
by every such service. A key that a service scope sets keeps a reference of its own for
that service. The Compose client environment therefore grows with the distinct values,
not with services times keys: twenty services that share one 64 KiB global value add
about 64 KiB, not 1.25 MiB. The choice follows which scope sets each key, so the
override file still holds no value. One safety check does compare values: if a key that
the resolved scopes call global has a value for a service that differs from the global
value, that service keeps a reference of its own for the key, so the container still gets
its own value. In that case the file shows that the service's value differs from the
global value, but never the value itself or its length. With a normal env config the
scopes and values come from the same resolution and always agree, so this check never
changes the file. Compose also receives each global
value under its own name for ${KEY} interpolation in your Compose file, so a global
value reaches the docker compose process twice.
The operating system limits the arguments and environment of a new process (ARG_MAX):
1 MiB in total on macOS, and on Linux a quarter of the stack size limit (2 MiB with the
default 8 MiB stack, at most 6 MiB), plus 128 KiB (MAX_ARG_STRLEN) for a single
NAME=value string. Hack measures the environment the docker compose process would
get (your shell environment plus the managed values) before it writes the override or
starts Compose. It keeps 64 KiB for the Compose arguments. When the environment does not
fit, up, restart, run and exec fail with E_ENV_TOO_LARGE. The message and the
--json detail name the limit (ARG_MAX or MAX_ARG_STRLEN) and give byte counts only,
never a name or a value. To fix it, make large values smaller or remove them (for
example, mount a large credential as a file and pass its path), set a value once in the
global scope instead of in each service scope, or unset large variables in the shell
that runs hack.
When you upgrade from an earlier version, read the two subsections at the end of this
section: managed values that contain $ now reach containers differently, and an
override written by the earlier version can still hold values.
hack env materialize is manual by design. Use it only when you need a compatibility file for an external tool that expects .env on disk.
Hack writes .hack/.env and .hack/.env.<overlay> (from materialize, legacy
hack env set, and the daemon env route) with mode 0600 by atomic replace: a
private temporary file in .hack/ is written, fsynced and renamed over the target,
so readers never see a partial file. An existing file with group or other access is
tightened to 0600 even when its contents are unchanged. These write and update paths
refuse, with a fixed message that names no file or value, an env file that is a symlink
(including a dangling one) or not a regular file, or one whose .hack directory is a
symlink. Replace the link with a regular file or directory to continue. Code paths
that only read an env file do not apply this check and still follow a symlink.
For host execution, hack host exec, hack host shell, and lifecycle hooks/processes use a host-local view.
That means Hack prefers host-usable values when it can:
- explicit
hostscope values override the normalglobal+<service>merge - common container-only hostnames such as
host.docker.internaland compose service hosts are rewritten to127.0.0.1
Use --target compose if you explicitly want the container-oriented compose view instead.
Upgrading: managed values that contain $
Earlier versions wrote each value into the override literally, so Compose interpolated
a $ inside a managed value: $VAR and ${VAR} became the host or Compose value of
VAR (blank when unset), and $$ became $. Now Compose substitutes each reference
once and does not interpolate the value, so the container receives the value exactly as
stored, $ included. If a managed value contains $:
- the container sees a different value after the upgrade, and the next
hack uprecreates that service's containers because their configuration changed; - a value that relied on interpolation (for example
${HOME}/data) now reaches the container unexpanded; store the expanded value instead; - a value that escaped
$as$$now reaches the container as$$; store a single$.
Values without $ reach containers unchanged, and their containers are not recreated.
Overrides from earlier versions
An override written by an earlier Hack version can still hold literal values. The next
hack up, hack restart, hack run or hack exec replaces it or, when it renders no
override, removes it. hack down removes it before its lifecycle hooks, the remote target
or docker compose down run, so a failed hook, a remote target or a failed Compose down
does not keep it. hack doctor reports such a file in its "compose env at rest" check
(counts only, never values), and hack doctor --fix removes it.
A legacy adoption (hack config adopt) pins the override's exact bytes and file
identity for as long as the adoption exists, and adopted commands never rewrite it. So:
hack config adopt,hack config adopt --stopandhack config adopt --dry-runrefuse a literal override before they prepare anything. Runhack uponce so Hack rewrites it with references, then adopt.- A preparation that an earlier version made over a literal override is never
published. If it has no interrupted stop or pending switch, doctor names
hack upfollowed byhack config adopt --stop: there is no active adoption to roll back, andhack upstarts the originals. For an interrupted stop,hack config adopt --recover --stop(which retries the stop, then publishes) andhack config adopt --recover(which completes a pending switch) refuse before any original file moves. The switch stays journaled as pending, and the error nameshack config adopt --recover --rollbackas the step that releases it. After that rollback, runhack upand adopt again. - An active adoption made by an earlier version over a literal override keeps that file. Hack
never removes it while a prepared, active or pending adoption exists, because removal
would make the adoption refuse to load.
hack doctorreports it with this next step. To drop the values, stop the adopted containers (hack down), runhack config adopt --rollback, runhack upso Hack rewrites the file with references, then adopt again withhack config adopt --stop.
A removal first moves the file aside, then checks what it moved. If a concurrent Hack
command had just replaced it with a value-free file, that file is put back rather than
deleted. Between the move and the put-back the path is briefly absent, so a Compose
command started by that concurrent Hack command at that moment can fail to find it; run
the command again. If Hack is killed between the move and the delete, the moved file
(.hack/.internal/.compose.env.override.yml.<id>.remove) can hold values. hack doctor
reports such files by count, and the next removal, hack doctor --fix, or hack up,
hack restart, hack run or hack exec deletes each one once it is more than a minute
old. A newer one may belong to a removal that is still running, so it is left alone.
Every step that touches the override first checks, without following a symlink, that
.hack and .hack/.internal are real directories and that the override is a regular
file; a step that moves or deletes something checks the directories again first. If
.hack, .hack/.internal or the override is a symlink or not a regular file or
directory, a Compose command that renders the override refuses with a fixed message,
and hack doctor reports a fixed refusal that names no value. Neither hack doctor --fix,
a removal nor the sweep of leftover .remove files lists, reads, moves or deletes
anything behind the link. Replace the link with a regular directory or file.
Choosing the right execution surface
hack host exec/hack host shell: run on your host machine with Hack-resolved env injected.hack run <service> ...: run a one-off command in the compose network with an ephemeral service container.hack exec <service> ...: run inside an already-running container when you specifically need that container's live filesystem or process context.
For host execution, --scope means "which env scope should Hack inject", not "where should this run."
For example, hack host exec --scope api -- bun db:migrate still runs on the host.
Command and delivery matrix
Every command that accepts --env resolves it with the same rule: omitted selects
the configured default overlay (env.defaultOverlay); base, default or none
selects base layers only; any other value must be a canonical overlay name and selects
that overlay. The table lists every such command; tests/env-command-matrix.test.ts
fails when a command gains or loses --env without a matching row, or when a row's selectors
drift from the CLI. Commands not listed here refuse --env as an option that is not
valid for them (arguments after a standalone -- belong to the child command).
The Selectors column lists the env-related flags each command accepts.
--service selects a Compose service scope, --scope selects the env scope injected
into a host command, and --target on env and host commands chooses the host or
compose env view. On up, down and restart, --target is execution routing
(auto|local|remote), not an env view.
| Command | Selectors | Resolution timing | Refresh path | Native and compose limits |
|---|---|---|---|---|
hack up | --env, --target | Once, when services are created; values go into Compose containers and lifecycle hooks/processes, and the selection is recorded for the running stack. | hack restart or hack env apply --service <service>; editing env files does not change running containers. | Authored native projects receive the same selection before dispatch. |
hack down | --env, --target | At teardown, for lifecycle down hooks only; omitted reuses a named overlay recorded for the running stack, else the configured default. | None; nothing is delivered to containers. | The native runtime backend refuses --env. |
hack restart | --env, --target | When services are recreated; omitted reuses a named overlay recorded for the running stack, else the configured default, and an explicit --env (including base) wins. | This is the refresh path for the whole stack or the named services. | Authored native projects receive the same selection before dispatch. |
hack run | --env | Per invocation, for the new one-off container. | Each run resolves again. | The native runtime backend refuses --env; restart with the selection first. |
hack exec | --env | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose exec and native-runtime exec (Compose-file and authored native projects) warn on stderr when the service's resolved env changed since it started. | hack restart <service> (or hack restart --env <name>); native projects restart as a whole with hack restart | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses --env. |
hack env list | --env, --service | Per invocation, read-only; secret values are masked unless --show-secrets. | Not needed. | Reads files; no runtime required. |
hack env get | --env, --service | Per invocation; prints exactly one plaintext value to stdout. | Not needed. | Reads files; refuses native project inputs. |
hack env explain | --env, --service, --target | Per invocation; reports source, precedence and delivery without the value. | Not needed. | Reads files; no runtime required. |
hack env apply | --env, --service | Runs hack restart <service> with the same --env, recreating one service. | This is the refresh path for one service. | --service is required. |
hack env add | --env, --service | Selects the file written (hack.env.<name>.yaml, or the base file for base/default/none; --local writes the local variant). | Running workloads keep old values until hack env apply or hack restart. | Mutates only env files. |
hack env set | --env, --service | Alias for hack env add, except that it refuses a dangling layer link. | Same as hack env add. | Mutates only env files. |
hack env hydrate | --env, --service | Selects the local file written, as for hack env add --local; imports every entry in one commit. | Same as hack env add. | Mutates only the machine-local env file; refuses a git-tracked one. |
hack env unset | --env, --service | Selects the file the key is removed from, as for hack env add. | Same as hack env add. | Mutates only env files. |
hack env materialize | --env, --service | Writes .hack/.env once, at invocation. | Rerun hack env materialize. | Compatibility output for external tools; runtime injection does not depend on it. |
hack env exec | --env, --service, --target | Per invocation, into one host command (host view by default). | Each invocation resolves again. | Runs on the host, not in a container. |
hack env shell | --env, --service, --target | When the shell starts. | Start a new shell. | Runs on the host, not in a container. |
hack host exec | --env, --scope, --target | Per invocation, into one host command (host view by default). | Each invocation resolves again. | Runs on the host, not in a container. |
hack host shell | --env, --scope, --target | When the shell starts. | Start a new shell. | Runs on the host, not in a container. |
hack config validate | --env | Per invocation; resolves the native selection without reading managed env documents. | Not needed. | Authored native projects only; cannot be combined with --file. |
hack config plan | --env | Per invocation; metadata only (names, scopes, secret flags), never decrypted. | Not needed. | Authored native projects only. |
hack project target | --env, --service | Per invocation, read-only; reports the overlay selection (explicit, project default or base) without reading env files or values. | Not needed. | Selection only: overlay existence is not checked; no runtime required. |
hack project task list | --env | Per invocation, read-only; selects the overlay that each recipe's reviewed env scope is compared with, without reading env files or values. | Not needed. | Selection only; lists recipes and runs nothing, so no runtime is required. |
hack project task inspect | --env | Per invocation, read-only; as for hack project task list, for one recipe. | Not needed. | Selection only; runs nothing, so no runtime is required. |
hack session start | --env, --service | When the workspace is created; the name gains .env-<name> (.env-base for base spellings), and --up runs hack up -d with the same selection. | Start a new workspace or pass --env to hack session exec. | Injects into the workspace shell, not into containers. |
hack session exec | --env, --service | Per command; omitted reuses the selection encoded in the workspace name. | Each command resolves again. | Injects into the workspace command, not into containers. |
Stale env warning on hack exec
hack up and hack restart record, for each Compose service they create, a keyed
digest of the env set delivered to it. The digest lives in Hack-managed runtime state
(.hack/.internal/runtime-state.json) and holds no key name or value. hack exec
resolves the same env set again and, when the digest differs, prints one fixed warning
to stderr that names the service and suggests hack restart <service>. The command
still runs with the env the container was created with, and stdout stays the target
command's output. The warning never prints a key or value.
The check is advisory. Identical env, state written before this check existed, and a
service whose last targeted restart failed produce no warning. hack up <service>,
hack restart <service> and hack env apply --service <service> refresh only the
named services' digests.
Projects on the native runtime backend run the same check. The foreground hack up or hack restart records
value-free digests of the env selection it delivered once the run is ready, in
.hack/.internal/native-env-fingerprints/ (one file per instance, mode 0600). The
record is bound to that run and removed when the foreground ends. hack exec resolves
the same selection again: for a Compose-file (image) project, the env for the run's
recorded env name; for an authored project, the managed values for the recorded
--env and --profile selection. When they differ, it prints one fixed warning to
stderr that names the service and suggests hack restart, since native restart is
whole-project. An image-project exec receives freshly resolved values itself, so its
warning says the running service still uses the env it started with. A restart drops
the record once the previous run stops, so a failed restart produces no warning. A
record from another run, a missing record, or any resolution failure produces no
warning. An authored-project check is bounded: after five seconds without a result
it is skipped and the command's output and exit code follow as usual. Ctrl-C during
the check ends the command with exit code 130 without waiting for it.
Two exec paths do not run this check yet: an authored .hack/hack.project.json
project on the Compose backend (see
native configuration with Compose), and an
existing Compose project switched with
hack config adopt, whose exec runs in the
retained containers.
MCP tools accept no env selector. They run the CLI without --env, so each row's
omitted---env behavior applies.
An overlay name is a label for a set of env files. It implies no production authority
and no network or instance isolation: a prod overlay still runs on the local stack,
and two overlays share the same instance unless you choose a separate branch instance.
Common commands
Inspect resolved env:
hack env list
hack env list --env qa
hack env list --env qa --service api
hack env list --json
hack env list --show-secrets
hack env explain GITHUB_TOKEN --env runner --service installer --target composehack env explain never prints the value. It reports availability, secret classification,
selected/effective scope, source file and precedence, and whether the value is delivered to host
lifecycle code or Compose. Recreate just one service after an env change with:
hack env apply --service api --env qa--show-secrets prints secret values in plaintext instead of the masked default; use it deliberately.
Read one value explicitly:
hack env get SERVICE_TOKEN --service api
hack env get SERVICE_TOKEN --service api --env qa
hack env get API_BASE_URL --env defaulthack env get KEY_NAME writes exactly the resolved UTF-8 value to stdout, with
no added newline, quotes, label, or JSON envelope. Empty values succeed with
zero output bytes. Multiline content and trailing newlines are preserved; shell
command substitution may itself remove trailing newlines. Missing or deleted
values exit nonzero with an error on stderr and empty stdout. Invalid arguments,
malformed env files, and unavailable or denied keys/backends also fail without
printing values, ciphertext, or raw backend errors. --json and --show-secrets
are not accepted by get; help is available separately with --help.
Omitting --env uses the configured default overlay. --env default (also
--env base or --env none) selects base layers, including local overrides.
An absent optional overlay keeps the existing base/local fallback behavior.
--service api selects global plus service values using the same layer ordering
as runtime injection; the default scope is global. Linked worktrees use the
same primary-local inheritance and checkout-local precedence as other env flows.
get returns that stored scope's value without host-address rewriting.
This explicit command needs no running containers and adds no confirmation.
Existing native keychain authorization still applies. Modern YAML secrets decrypt
only the requested winning entry, so an unrelated missing key or corrupt encrypted
value does not force disclosure or block a plaintext read. Legacy projects retain
their configured backend and declared-key lookup; only the requested overlay/base
key is fetched. The existing encrypted-file backend internally decrypts its sealed
bundle. Empty legacy values are returned as present by get; legacy runtime
injection keeps its existing empty-value fallback behavior.
get does not migrate or materialize env files, write plaintext artifacts, or add
secret values to diagnostics, MCP tools, or audit output. Treat its stdout as a
secret when the requested value is sensitive; avoid redirecting it into logs or
committed files. Use hack env explain KEY_NAME for inspection without disclosure.
Add or update values:
hack env add API_BASE_URL https://api.example.com
hack env add --env qa API_BASE_URL https://qa.example.com
hack env add API_BASE_URL https://api.example.com --localhack env set still works as a compatibility alias for hack env add, but add is the primary UX now.
The two differ on an in-project dangling layer link: set refuses it and add replaces it.
Entering secret values
Never pass a secret value as a command argument: arguments land in shell history and are visible to other local users and tools through process listings. Use one of these instead:
# Pipe the value from a secret manager or file; it never appears in argv.
<secret-source> | hack env add SERVICE_TOKEN --service api --secret --value-stdin
hack env add SERVICE_TOKEN --service api --secret --value-stdin < token.txt
# In an interactive terminal, omit the value to use the hidden prompt.
hack env add SERVICE_TOKEN --service api --secret--value-stdin works on hack env add and hack env set, with or without --secret.
It reads all of stdin, up to 64 KiB, and strips exactly one trailing newline (\n or
\r\n), so printf '%s\n' "$value" and printf '%s' "$value" store the same value; any
further trailing newlines are kept. It refuses, with a fixed message that never repeats the
input and without writing anything:
- an interactive terminal on stdin (pipe the value, or drop
--value-stdinfor the hidden prompt) - a value argument alongside the flag, including
KEY=value - a missing key argument
- empty input, input over 64 KiB, invalid UTF-8, or a NUL byte
- input that has not reached end of file within 30 seconds, for example a pipe whose writer
never closes it, or an inherited stdin that nothing writes to. Close the pipe after writing
the value, or redirect from a file.
HACK_ENV_VALUE_STDIN_TIMEOUT_MSsets the deadline (1,000–600,000 ms).
Calls to the OS keychain (macOS Keychain, or the Secret Service over the D-Bus session bus on
Linux) are bounded too. With no desktop session, a locked keychain, or a session bus that never
answers, the call fails after 60 seconds with a fixed message instead of waiting. The outcome of a
timed-out write or delete is unknown, so check it once the keychain responds. For unattended
hosts, use the encrypted_file secrets backend with its key file in place or
HACK_SECRETS_FILE_KEY set; without either, that backend also reads its key from the keychain.
HACK_KEYCHAIN_TIMEOUT_MS sets this deadline (1,000–600,000 ms).
--local writes to the worktree-local override file (.hack/hack.env.local.yaml or
.hack/hack.env.<overlay>.local.yaml) instead of the shared repo env file — use it for
per-checkout values you don't want to commit.
With no env layers yet, hack env set --local writes legacy .hack/.env (or
.hack/.env.<overlay>), including when a hack.env.json contract exists.
Use hack env add --local to migrate first and then write the machine-local layer.
Contract migration writes plain_env values from legacy dotenv files to the gitignored
machine-local layer, never a tracked layer, and refuses a plain_env value that comes only
from your shell. It reports each moved key and destination path, without values. It refuses
a local destination already tracked by Git. Values declared as keychain migrate to tracked
layers as ciphertext.
Migration requires effective ignore protection before writing plaintext. It refuses
an outdated read-only .hack/.gitignore or a local destination that Git does not ignore.
Without hack.env.json, set --local still writes .hack/.env; if a contract is
added later, migration keeps those values machine-local too.
Repeating hack env add or hack env set with the value already stored is a no-op: when
the existing secret decrypts to the same value under the current key, its ciphertext is
kept, so once Hack has written the file it stays byte-identical and Git shows no change.
A hand-edited file (comments, other formatting, or no global: {}) is normalized on the
first repeat and stays byte-identical after that. A different value, a plain entry
becoming secret, or an entry that does not decrypt under the current key gets new
ciphertext with a fresh IV. Each command writes one key; a script that sets several keys
is not atomic across them. To write many machine-local values in one commit, use
hack env hydrate (below).
Each env file write goes to a hidden .<file>.<id>.tmp file in the same directory and
replaces the file by rename, so an interrupted write leaves the previous file in place.
A write killed before the rename can leave that staging file behind. It holds the whole
new layer, so .hack/.gitignore ignores .hack.env*.tmp, and you can delete it. Hack
does not remove it on the next write, because another write may still be using it. The
rename has these effects:
- a symlinked YAML env layer is allowed only when its fully resolved path stays inside the project's real root. Relative links and link chains are resolved at each hop; a root behind a symlink works too. An allowed link keeps its link, the write lands at its target, and the staging file is created next to that target
- outside targets (including dangling outside links), sibling-prefix escapes and link
loops are refused with a closed, value-free error before reading values from stdin
or writing decrypted or hydrated output. This applies to the base, tracked overlays
and local layers, including init scaffold writes; mutation discovery checks all named
YAML layers, including case-variant names on case-insensitive filesystems.
hack doctor --fixreports an error for a.hacklinked outside the project and leaves the link and outside files in place hack env setandhack env unsetrefuse an in-project symlink whose target does not exist withE_PATH_CONFLICT, and strict planning and hydrate reads refuse it too. Replace or remove the link, then retry.hack env addreplaces that link with a regular file- the writer rechecks both the layer and its resolved destination before staging and commit. These path checks narrow concurrent-swap races; they are not an atomic filesystem fence against a directory changed after the last check
- a hard link is broken: the env file gets a new inode, and any other name for the old file keeps the previous contents
- the directory must be writable, even when the env file itself is writable; if it is
not (for example a
.hackdirectory at mode0555), Hack refuses with a one-line error that names the env file and says its directory is not writable
A rename does not need write access to the file it replaces, so Hack checks that access
itself before it stages anything. It refuses to replace an existing env file that it
cannot read, that is not a regular file, that is not writable, or whose owner and group
it cannot keep (for example a root-owned file left by an earlier sudo hack). hack env unset refuses an env file it cannot read in the same way, rather than reporting that
there is nothing to remove. The error is one line under every HACK_LOGGER backend
(HACK_DEBUG=1 adds the stack), names the file and the reason, never a value, and the
file is left as it was. Fix the file's permissions or ownership and run the command
again. A rewrite keeps the file's permission bits, owner and group.
This includes a file you own whose group you are not a member of. Without root, the new
file cannot be given that group, so Hack refuses rather than let the file fall back to
your default group: keeping group-readable permission bits under a different group could let other
accounts read it (on macOS the default group is usually staff, which every local
account belongs to). Such a file usually comes from moving it (mv) out of a directory
with another group, for example /tmp on macOS, where new files take the directory's
group wheel, or from being removed from the file's group. Run chgrp <your-group> <file> and run the command again.
The other files these commands write fail the same way. These are .hack/.gitignore
(--local and hydrate), .hack.secret.key (the first --secret value, including
the key lock Hack takes in the git directory first), the repository root .gitignore
entry for that key, and .hack/.internal/ (the hydrate lock). If a write is denied,
the filesystem is read-only, the disk or quota is full, a parent path is not a
directory, or a directory is in the way of a file, Hack prints one line. That line
names the file or directory Hack was writing, gives the reason and says what to fix. It
never shows the raw system error, the hidden staging file name or the lock's staging
file. An env file on a read-only filesystem is reported as a read-only filesystem, not
as a file that is not writable.
Each of these lines starts with a stable code, and --json puts the same code and
message in the envelope:
E_PERMISSION: permission denied, a read-only filesystem, or an owner or group Hack cannot keep. A.hackdirectory you cannot search (for example mode0644) and one you cannot write (for example mode0555) both give this code.E_NO_SPACE: the disk or the disk quota is full.E_PATH_CONFLICT: a path is the wrong kind of entry, such as a file where Hack needs a directory, a directory where Hack needs a file (including a.hack/.gitignorethat is a directory or a symlink), or a.hack/.internalthathack env hydratefinds to be a symlink (dangling or not). A file name or path that is too long for the filesystem is reported with this code too; use a shorter--envoverlay name or move the project to a shorter path.E_CONFIG_INVALID:hack env hydratecould not check its project inputs. A.hack/.internalthat is a file gives this code withCannot inspect Hack project inputs. Check filesystem permissions and paths before retrying. Values omitted.: Hack cannot tell whether the private state it keeps inside.hack/.internalexists, so it stops before hydrating instead of treating it as absent. Replace the file with a directory (or remove it), then retry.E_LOCKED: the project env key lock (<git common dir>/.hack.secret.key.lock, taken by the first--secretvalue) stayed held for the whole 10-second wait. Nothing was written. Another hack command may be creating the key; retry when it finishes. If no other hack command is running, the lock is stale (for example its process ID now belongs to an unrelated process, which Hack never treats as abandoned): remove the lock file the message names, then retry.
The first --secret value also works on a filesystem without hard links, such as ExFAT
or FAT on a USB drive. Hack normally publishes the key file and its locks with a hard
link, so they appear complete or not at all. Where that is not possible, it creates an
empty placeholder exclusively and renames the complete file over it, which keeps the
same rules: an existing file or symlink is never replaced or followed, and a reader
never sees a partial file. If Hack is killed between those two steps, an empty
.hack.secret.key or lock file is left behind. Hack refuses it rather than use or
replace it; remove it and run the command again. ExFAT and FAT do not store Unix
permissions, so on such a drive the key file is not limited to your user the way mode
0600 limits it elsewhere.
If you omit key, value, or --service, hack env add prompts for them interactively.
Pass --no-interactive (or set HACK_NO_INTERACTIVE=1) for scripted/agent usage: the
command applies documented defaults or fails fast with E_INTERACTIVE_REQUIRED instead of
blocking on a prompt. In scripts, supply secret values with --value-stdin rather than as
arguments. hack env unset and hack env set accept --local and
--no-interactive too.
Remove a value:
hack env unset API_BASE_URL
hack env unset SERVICE_TOKEN --service api --env qa
hack env unset API_BASE_URL --localConcurrent writes to one env file
Hack env commands that change an env file never silently lose another command's change.
hack env set, hack env add, hack env unset and hack env hydrate each read the
file, change it and replace it while holding that file's write lock,
.hack/.internal/<file name>.lock (for example .hack/.internal/hack.env.yaml.lock).
The same applies to the legacy .hack/.env files. So when several commands change
different keys of the same file at once, every change is kept. A command
that finds the lock held waits up to 10 seconds, then stops with a one-line message that
names the file and its lock and says nothing was written; run it again. The lock is
released when the command ends. If a command is killed while it holds the lock, the next
command takes the lock over once it confirms that process has exited.
Hack never removes a lock it cannot prove is abandoned. That includes a lock file that
is not a complete Hack receipt, one owned by another user or writable by others, and one
that names a process ID now used by an unrelated live process. In those cases every
retry would wait and fail the same way, so the message also says what to do: if no
hack env command is running, remove the lock file it names and retry. If a command was
killed while it was taking over an abandoned lock, it leaves <lock>.recovery behind,
and the message says the recovery guard is busy and names both paths; when no hack env
command is running, remove both and retry. hack env hydrate reports the same message
when it cannot get the env file's lock.
.hack/.internal/ is ignored by the managed .hack/.gitignore, so lock files never show
in git status. If .hack/.internal is a symlink, dangling or not, these commands
refuse with one E_PATH_CONFLICT line instead of creating the lock through it. If it is a
regular file, they refuse with one E_CONFIG_INVALID line (Cannot inspect Hack project inputs...) and write nothing. If you cannot write the directory that
holds the env file (for example a read-only checkout), no lock is taken: no command can
replace the file there, so a command that changes nothing still succeeds and one that
would change the file is refused as before.
Programs that do not take this lock, such as an editor or Hack v4, are not kept out. For
hack.env*.yaml files, hack env set, add, unset and hydrate also check that the
file still has the contents they read just before replacing it. If it changed, the
command writes nothing and stops with a retryable conflict ("The file changed while it
was being replaced; nothing was written."), or, for hydrate, a concurrent_write
refusal. That check narrows the window in which such a program's write can be
overwritten, but cannot close it.
Hydrate machine-local values in one step
hack env hydrate imports many values into the machine-local layer at once, for example
when a new checkout or machine pulls its secrets from a secret manager. Tracked defaults
stay in hack.env.default.yaml; hydration writes only the gitignored local layer
(.hack/hack.env.local.yaml, or .hack/hack.env.<overlay>.local.yaml with --env).
# Pipe KEY=value lines from a secret manager; values never appear in argv.
<secret-source> | hack env hydrate --secret
# Or read a file, or a pipe such as <(secret-source).
hack env hydrate --secret --file .secrets.env
hack env hydrate --secret --file secrets.json --format json --env qa --service apiInput comes only from piped stdin or --file, never from arguments, and is at most
1 MiB. The default env format is strict, one entry per line:
- blank lines and lines starting with
#are skipped, and anexportprefix is allowed KEY=value, where the key matches^[A-Z_][A-Z0-9_]*$; an unquoted value is the rest of the line with surrounding spaces removed, and#inside it is part of the value'...'is literal;"..."accepts\n,\r,\t,\"and\\; a quoted value must close on the same line, followed only by spaces or a# comment- empty values, repeated keys and
${VAR}expansion are not supported
--format json takes one object whose values are non-empty strings. A key that
appears twice is refused, as in the env format; it is not resolved by keeping the
last value. One malformed entry refuses the whole input. The message gives only the line number, never the
line, and nothing is written.
--secret encrypts every imported value; without it the values are stored as plain
entries in the local layer. A plain import refuses to replace a key that the local
layer already stores as a secret, so repeat an import with the same flags it was first
run with. --service picks one scope for all entries (default
global). The output and --json result list the added, updated and unchanged keys,
never values.
Hydration is idempotent and all-or-nothing:
- An entry that already holds its value is left alone: a secret keeps its ciphertext and a plain number keeps its YAML type. When nothing changes, the file is not rewritten, so comments and formatting survive and Git shows no change.
- A changed value gets new ciphertext with a fresh IV.
- Local entries that are not in the input, other scopes, and removal markers for other
keys are kept. A key in the input replaces that key's local entry, including a
value you set by hand with
--local. - The whole new layer is written to a staging file and published by one rename, so an interrupted hydration leaves the previous layer, with every key at its old value.
Hydration refuses, without changing the layer, when:
- git tracks the target local layer
- the project is in a git checkout but git cannot say whether it tracks the local layer,
for example after a
safe.directoryrefusal, with a damaged index, or with git not installed (outside any git checkout, hydration proceeds) - the local layer exists but is not a valid env file, for example invalid YAML, the
wrong
version, or a scope that is not a mapping; fix or remove it first - a secret already in the local layer does not decrypt under the project env key, or no
key is available (
.hack.secret.keyis used beforeHACK_ENV_SECRET_KEY, as when values are read) --secretis used, no.hack.secret.keyexists, andHACK_ENV_SECRET_KEYis set (a new key file would replace that key for every layer)--secretis not used and an imported key is stored as a secret in the target scope; re-run with--secret, or remove the key withhack env unset <key> --localfirst- another
hack env hydratefor the same project is running, or an interrupted one left a stale lock (see below) - the local layer changed between hydration's read and its rename, by a program that does not take the env file's write lock (for example an editor or Hack v4)
- hydration waited 10 seconds for the env file's write lock without getting it
Concurrent hydrations are serialized by .hack/.internal/env-hydrate.lock, which holds
the owner's process ID. A hydration that is killed (for example with SIGKILL) cannot
remove it; the next hydration then reports the stale lock and its path instead of taking
it over. Remove the file once no hydration is running. hack env set --local does not
take this lock, but hydration also holds the local file's write lock (see
Concurrent writes to one env file), so a
concurrent hack env set --local waits for the hydration and both changes are kept.
Materialize a compatibility .hack/.env:
hack env materialize
hack env materialize --env qa
hack env materialize --env qa --service apiRun a host command with injected env:
hack host exec -- bun db:migrate
hack host exec --env qa --scope api -- bun db:migrate
hack host exec --env qa --scope api --target compose -- bun testCancelling hack host exec or hack env exec with SIGINT or SIGTERM forwards
the signal to the command. Commands run in an owned process group,
so cancellation also stops their descendants, escalating to SIGKILL after two
seconds if necessary. Hack returns 130 for SIGINT and 143 for SIGTERM. Interactive
commands use a supervisor in their own foreground group on the same terminal,
preserving stdin, separate output streams, and Ctrl-Z/foreground resume. Piped
stdin also retains the controlling terminal, so commands can open /dev/tty
for native authentication prompts. The
supervisor holds group ownership until cancellation cleanup finishes. SIGKILL cannot be forwarded; supervisors must terminate the
whole owned process tree when force-killing a wrapper. Commands have no implicit
time limit, and normal completion preserves the command's exit status.
When you want to inspect an injected value, avoid hack host exec -- echo $VAR. Your current
shell expands $VAR before Hack starts the child process, so the command often sees an empty
string.
Use one of these instead:
hack host exec -- printenv APPLE_TEAM_ID
hack host exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"'
hack host exec --shell 'echo $APPLE_TEAM_ID'Open a host shell with injected env:
hack host shell
hack host shell --env qa --scope api
hack host shell --env qa --scope api --target composeExample with an explicit host override:
version: 1
environment: default
secretsprovider: project_key
values:
global:
DATABASE_URL:
secure: v1:...
REDISHOST: redis
host:
REDISHOST: "127.0.0.1"Sessions and env
Session flows can now carry env selection too.
Examples:
hack session start my-project --env qa --service api --detach
hack session exec my-project.env-qa.svc-api --env qa --service api "bun db:migrate"When you pass --env or --service, Hack creates a stable scoped workspace name instead of reusing the plain default workspace. That prevents one shell from silently inheriting the wrong env selection.
Secret key handling
Secret values in the YAML files are encrypted with the project key. The env and secrets threat model lists what this protects against and the gaps that are still open.
Default behavior:
- generate
.hack.secret.keythe first time you add a secret - add
.hack.secret.keyto.gitignore - decrypt secrets from
.hack.secret.keyon the local machine - linked git worktrees can reuse the primary checkout's
.hack.secret.keywhen the worktree copy is missing
Linked worktree behavior:
- when a repo uses linked git worktrees, Hack prefers a shared key under the git common dir
- sibling worktrees can decrypt the same committed secrets without manually copying gitignored files
- if a checkout-local
.hack.secret.keyexists, it still wins for that checkout hack upin a linked worktree auto-selects a branch instance (worktree.auto_branch); that selection determines which instance's resolved env is the one actually injected, so keep the key readable from every checkout that runshack up
Linked worktrees also read the primary checkout's eligible local YAML overrides
(hack.env.local.yaml, or its legacy-compatible local-default path, and the selected
hack.env.<overlay>.local.yaml). Resolution order is checkout tracked default and
overlay, primary local default and overlay, then checkout local default and overlay.
The resolved file list includes inherited paths. Primary tracked configuration,
legacy .env, runtime state and unrelated ignored files are not inherited.
Local values override inherited values. A YAML null entry explicitly removes a
value supplied by earlier layers; deleting the entry instead reveals the earlier
value again. hack env unset KEY --local in an inheriting linked worktree records
that removal without modifying the primary file. A later set --local replaces it.
Inheritance reads current files each time and never copies them into the worktree.
Invalid inherited files and redirected file paths fail explicitly.
Set worktree.inherit_local: false in the checkout's Hack config to disable local
file inheritance. CI (CI=1 or CI=true) and slim/Codex execution mode disable it
automatically. This setting does not change the existing secret-key lookup order
below. Missing primary local files are optional; retained encrypted values still
require the selected checkout's valid decryption key.
Full read order for decrypting secrets (first match wins):
- checkout-local
.hack.secret.key - shared key under the git common dir
- the primary checkout's key, inherited automatically for a linked worktree that has neither of the above
HACK_ENV_SECRET_KEY
Write-path guarantees in a linked worktree (first secret added from any checkout):
- an existing checkout-local key is reused as-is
- otherwise an existing shared key (git common dir) is reused
- otherwise the primary checkout's key is adopted by copying it to the shared location, so sibling checkouts converge on one key
- only then is a new key generated, and it is written to the shared location — never silently to the checkout
- concurrent first use from several checkouts converges on one key: when no key exists, creation runs under a cooperative lock at
<git common dir>/.hack.secret.key.lock, and a key file is published only when complete, never empty or partial (Hack v4 writers do not take the lock; on a filesystem without hard links, such as ExFAT, an empty placeholder is visible for a moment before the complete key replaces it) - if the shared location cannot be resolved (git unavailable) or written, Hack falls back to a checkout-local key and prints a loud divergence warning
hack doctorflags divergent.hack.secret.keycontents across checkouts with the exact paths
Key strength and exposure (hack doctor "env key" check):
hack doctor and hack doctor --json audit the checkout-local key, the shared key
under the git common dir and, in a linked worktree, the primary checkout's key. When
no key file exists, the check classifies HACK_ENV_SECRET_KEY instead. The check is
read-only. It never prints key bytes or absolute paths, and --fix does not act on it.
Each finding names a project-relative subject and the exact command to run from the
project root. --json adds an envKey object to the check with status and
findings (code, status, subject, remedy). The native runtime backend's
hack doctor runs the same check inside a project.
Nested public artifact directories are found with a bounded walk from the project
root: at most six levels deep, 4096 directories and 100,000 entries. It skips
node_modules and .git, does not follow symlinked directories, and reports a path
it cannot spell portably as <unprintable-path>/.hack.secret.key.
| Code | Status | Meaning |
|---|---|---|
env-key-ok | ok | key material is a regular, private, 256-bit-class file |
env-key-absent | ok | no key file and no HACK_ENV_SECRET_KEY |
env-key-weak-permissions | warn | group or others have access; suggests chmod 600 |
env-key-wrong-owner | warn | the file is not owned by the current user |
env-key-wrong-length | warn | shorter than the 43-character base64url form of a 256-bit key, or empty |
env-key-low-entropy | warn | long enough but repetitive (estimated under 128 bits) |
env-key-unignored | warn | the checkout-local key is not ignored by git, so git status offers it for commit (for example after git init in a directory whose root .gitignore entry was removed); suggests adding it to the root .gitignore |
env-key-symlink | error | the key path is a symlink; Hack refuses symlinked keys |
env-key-not-regular | error | the key path is a directory or another non-file entry |
env-key-unreadable | error | unreadable, oversized or not UTF-8 |
env-key-tracked | error | a file named .hack.secret.key is tracked by git at any depth |
env-key-public-path | error | a key copy sits directly in public/, static/, dist/, build/, out/, www/, _site/, .next/ or .output/, at the project root or in a nested package such as apps/web/public/ |
Hack derives the AES-256-GCM key by hashing the key text with SHA-256, so key
strength is the entropy of that text. Generated keys are 32 random bytes in base64url.
For a weak, tracked or published key, rotation is required. Preserve a private,
recoverable copy of the old key and encrypted inputs before changing either.
Recover each selected secret with the old key, then re-add it with the new key
using the hidden prompt or --value-stdin; never put secret values in arguments.
Individual atomic writes do not make this sequence a crash-safe rotation transaction.
See the env security decision and rotation gates.
CI and managed container fallback:
- if
.hack.secret.keyis missing, Hack falls back toHACK_ENV_SECRET_KEY
That makes this model usable in CI and portable container images without committing the key file.
Published container paths:
- slim managed-container base:
hackdance/hack:slim— the supported CI / managed-container story - full remote runtime:
hackdance/hack:latest— serves the remote/node surface, which is experimental and unsupported (hidden behindhack help --all); only relevant if you have explicitly opted into that surface
In both cases, inject HACK_ENV_SECRET_KEY from the runtime or secret manager instead of copying
.hack.secret.key into the image.
Legacy repos and migration
Older repos may still have:
.hack/hack.env.json.hack/.env.hack/.env.<overlay>- configured secret backends via
hack env backend
Hack still detects and reads that legacy format, but the preferred migration path is:
hack doctor --migrate-env-configThat migrates the repo into:
.hack/hack.env.default.yaml.hack/hack.env.<overlay>.yaml.hack/hack.env.local.yamland.hack/hack.env.<overlay>.local.yamlfor plain values.hack.secret.keywhen needed
Tracked layers receive secret-backed ciphertext, not legacy plaintext.
The migration runs the same way on the Compose and native backends: it changes project
files only, so native hack doctor --migrate-env-config needs no Docker and no native
runtime.
hack.env.json and the Compose file are shared, git-tracked configuration, so the
migration never deletes or edits a git-tracked file without your consent. Interactively,
doctor lists the git-tracked files that may change before it migrates, and asks before it
removes env_file entries from a tracked Compose file and, separately, before it removes
git-tracked legacy files such as hack.env.json. A file whose git status can't be read
inside a git checkout counts as tracked. A run that can't prompt (no TTY,
HACK_NO_INTERACTIVE, or --no-interactive) refuses with E_INTERACTIVE_REQUIRED before
any doctor step writes, naming each tracked file it would delete or edit, unless you pass
--accept-tracked-env-changes:
hack doctor --migrate-env-config --no-interactive --accept-tracked-env-changesThe list is conservative; with a contract it can include hack.config.json, which the
migration rewrites when it still holds legacy keys such as defaultEnvConfig, and a legacy
dotenv file the migration ends up keeping. Commit the changes so teammates get them.
Commands that need env values, such as hack env exec, also migrate a
project that has hack.env.json and no env layers, without asking. That migration reports
each moved key, never its value, and removes a legacy .hack/.env or .hack/.env.<overlay>
file only when every entry now lives in an env layer, Compose no longer names it as
env_file, Compose doesn't interpolate a key from it that the env layers now set only for a
service, and git doesn't track it, so no second plaintext copy stays behind. It keeps
hack.env.json, never edits the Compose file and never deletes a git-tracked file; it names
a kept file and says why. Run hack doctor --migrate-env-config for those
changes. Like doctor, it rewrites legacy keys in hack.config.json.
A repo that has no .hack/hack.env.json is migrated as well. Its values live only in
.hack/.env and .hack/.env.<overlay>, which are gitignored and may hold passwords or
API keys, so the migration keeps them out of tracked files: each .hack/.env entry
becomes a plaintext global value in the gitignored machine-local layer
hack.env.local.yaml, and each overlay entry that differs from .hack/.env becomes a
global value in hack.env.<overlay>.local.yaml. These machine-local layers are created
as private (mode 0600) files and never replace an existing entry. The tracked hack.env.default.yaml, and
hack.env.<overlay>.yaml for each migrated overlay, are written with no values, so the
repo switches to env layers. Teammates do not receive these values through git; share
the ones they need with hack env set (plain settings) or hack env set --secret
(encrypted) after the migration.
A repo with no legacy env file gets only an empty hack.env.default.yaml.
Once this is committed, a teammate's own .hack/.env and overlays may stop reaching services,
because their checkout now uses env layers. Hack compares those files with what the layers
deliver. For any value the layers don't deliver, hack up warns and doctor reports it. For a
value in .hack/.env, hack env materialize also refuses, so the file isn't replaced; it doesn't
write overlay files, so an overlay value doesn't stop it. They run
hack doctor --migrate-env-config to move those values to their own local layers, adding to a
local layer that already exists and making it private (mode 0600). A value equal to what the
layers deliver isn't copied, so later team changes still reach them. A key the local layer sets
differently refuses (E_PATH_CONFLICT), and so does a local layer that can't be read as an env
layer (E_CONFIG_INVALID), which is left as it is. Adding to a layer drops its YAML comments. Messages name valid keys, count malformed entries and name unreadable files,
and never show a value. This comparison skips a .hack/.env with a valid materialization state.
A migration that runs for another reason, such as an overlay value, also checks that file's text,
as described below. With nothing else to migrate, doctor reports that the project already uses env
layers without checking the text, and a later hack env materialize replaces the file. Tell
teammates when you commit the migration.
Migrated values are global, so a service added later receives them too, and so do host lifecycle hooks. A migrated value also takes precedence over the same variable exported in your shell, including for Compose interpolation.
This migration refuses, with a one-line E_* error, writes nothing and keeps the legacy
files, when:
- a key does not match
^[A-Z_][A-Z0-9_]*$, for example a lowercase name or a line that starts withexport(E_CONFIG_INVALID). The error counts the keys per file but does not show them, because a malformed line may hold a value. Rename or remove them; - a legacy file is named
.hack/.env.default,.hack/.env.localor.hack/.env.<name>.local, whose layer would be the default or a machine-local layer rather than an overlay (E_CONFIG_INVALID). Rename or remove it; - a legacy
.hack/.env.<name>file has a name that is not an overlay name (lowercase letters, digits and single dashes), such as.env.staging.oldor.env.Prod(E_CONFIG_INVALID). The error names such files. Rename or remove them; a renamed overlay is then selected by its new name (--env <new-name>). Template and backup copies (.env.example,.env.sample,.env.template,.env.dist,.env.bak,.env.backup,.env.old,.env.orig) are not overlays: they are neither migrated nor offered for cleanup; - Compose
env_fileuses one of those template or backup copies (E_CONFIG_INVALID), so its values would be lost. Rename the file to an overlay name or move it out of.hack, and updateenv_fileto match; - Compose
env_fileuses an overlay such as.env.stagingthat holds values of its own and is notenv.defaultOverlay(E_CONFIG_INVALID). Compose loads that file on every launch, but after the migration a launch without--envwould not select the overlay, so the service would lose those values. Setenv.defaultOverlayto it, or remove theenv_fileentry and select the overlay with--env; - an entry named like an env layer (
.hack/hack.env.*.yaml) is a dangling symlink, a directory or another non-file (E_PATH_CONFLICT). With a symlinked layer inside the project, or with nothing to move,hack doctorreports that the repo already uses env layers and migrates nothing, and it never writes through the link. It can still remove.hack/.env*entries fromenv_filein the Compose file, and a service then stops receiving a value only that file set, so move such values first withhack env set --local. A.hackor a layer linked outside the project is refused with a closed error, and the link and outside files stay as they are; - outside a git checkout,
hack.env.local.yamlsetsenvironment: local, so it serves as thelocaloverlay and base values written there wouldn't reach the default env (E_CONFIG_INVALID). If it's meant to be your machine-local layer, set itsenvironmenttodefault, then retry; - git tracks a machine-local layer it would write, or, in a git checkout, git cannot
report whether it does (
E_PATH_CONFLICT); - in a git checkout, git does not ignore a machine-local layer it would write, for
example because
.hack/.gitignoreis read-only and lacks Hack's managed block (E_PATH_CONFLICT). Make.hack/.gitignorewritable, or ignore the path, and retry; - a migrated value would replace a service or host value in an env layer, in the base or in
an overlay, including an overlay a linked worktree inherits from its primary checkout
(
E_CONFIG_INVALID). A machine-local global value outranks a service or host value in an earlier layer, so that target would silently switch. The error names the keys, targets and envs, never values. Remove the key from the legacy file, or change that value in its layer; - a key the legacy files set is also set in a Compose service's own
environment(E_CONFIG_INVALID). Compose ranksenvironmentaboveenv_file, but migrated values become global layer values that Hack applies afterenvironment, so the service would switch to the.envvalue. This includes an entry that only refers to its own key (PORT: ${PORT}), whose result can differ when an overlay or the shell sets the key. The error names the keys and services, never values. Remove the key from the legacy.envfile or overlay that sets it, or from that service'senvironment, then retry; - a service does not list a legacy file with values to migrate in
env_file(E_CONFIG_INVALID). Migrated values are global and reach every service, including third-party images, so that service would receive values it never had. This includes a file no service lists, whose values today reach only Compose interpolation. Listing the file for each service is how you accept that. Otherwise, move those values out of the file and set them withhack env set --serviceafter migrating, or keep them in your shell. Values Compose interpolates need the shell or a listed file, because service values do not reach interpolation. The error names files, services and keys, never values; - Compose uses a top-level
include, a serviceextends, a!resetor!overridetag, a variable or a~in anenv_filepath, a file listed twice in oneenv_file, or aformaton anenv_fileentry (E_CONFIG_INVALID), so the migration cannot tell which values services receive. Write thoseenv_fileentries directly in the Compose file, each file once, with a plain path and noformat; - a key the legacy files set is also set in another file a Compose service uses as
env_file(E_CONFIG_INVALID), including asexport KEY=value,KEY: value, a bareKEYor after a closing quote (A='x' KEY=value), for the same reason: that file stays in Compose, but the migrated value would override it. The check does not look at the order of the entries, or expand variables in anenv_filepath. Remove the key from one of the files, then retry; - a legacy entry is one Compose reads differently from Hack (
E_CONFIG_INVALID): an unquoted inline comment (PORT=3000 # dev), a$outside single quotes, a\in a quoted value (such as\nin a PEM key), text after a closing quote (A='x' B=2), or a line without=(A: 1, or a bareA). The value would change or be lost. The error counts entries per file and never shows them. Write each value on its own line, in single quotes, with real line breaks and no backslashes, or remove the line; - a service lists legacy files in an order that gives a key a different value from the migrated
layers (
E_CONFIG_INVALID), such asenv_file: [.env.staging, .env]with.env.stagingas the default overlay. Compose lets the last file win, but the default overlay always wins over.hack/.envin env layers. List.envfirst, or make the files agree; - the default overlay sets a key differently from
.hack/.env, and Compose reads that key: its name appears anywhere in the Compose file or anotherenv_file(${KEY}, a barebuild.argsentry, a secret'senvironment:), or it is aCOMPOSE_*setting (E_CONFIG_INVALID). Compose takes it from.hack/.envtoday, but the overlay value would apply after the migration. Make the two files agree or stop using the key in Compose; - a migrated
.hack/.envvalue would replace what an overlay's layers deliver for a key its legacy overlay file doesn't set (E_CONFIG_INVALID), because a local base outranks tracked and inherited overlay layers. Remove the key from.hack/.env, or set the value you want in that legacy overlay file, creating it if needed; the Composeenv_filechecks above then apply to that file; - env layers include a symlink while legacy values remain undelivered (
E_PATH_CONFLICT). Replace linked layers with regular files containing the intended layer content, then retryhack doctor --migrate-env-config. The migration does not write through a linked layer; .hack/.envhas a state file, env layers exist, and its text isn't whathack env materializewould write (E_PATH_CONFLICT). The file is out of date or was edited by hand, and Hack can't tell which. If it's generated, move any value of your own withhack env set --local, then runhack env materialize, which replaces it. If it's your own file, delete.hack/.env.state.jsonso it's read as a legacy file, but not for generated output, whose values would then migrate as your own. When the text does match, the migration doesn't read the file or offer it for cleanup;.hack/.envhas a state entry that can't be read or parsed, such as a dangling link, and env layers exist (E_PATH_CONFLICT). If.envis your own file, delete.hack/.env.state.json. If it's generated output you don't need, delete both, then runhack env materialize;- a legacy file sets a key that Docker, its build tools or the process read from their
environment (
E_CONFIG_INVALID):DOCKER_*,BUILDKIT_*,BUILDX_*, a proxy setting,SOURCE_DATE_EPOCH,HOMEorPATH. After the migration it would reach the Docker client too. Remove it from the legacy file, and set it in your shell or for one service withhack env set --service --localafter migrating; - the Compose file is not a single YAML document this check can read, such as several
---documents (E_CONFIG_INVALID). Merge it into one document; .hack/.envor a legacy overlay is a symlink to a file outside.hack(E_PATH_CONFLICT), so its values would be copied from elsewhere into this project's layers. Replace the link with a regular file in.hack, or remove it;- the Compose file exists but cannot be read (
E_PERMISSION), so the migration can't tell which values services receive. Make it a readable regular file, then retry; - an env layer can't be read or resolved (
E_CONFIG_INVALID), for example it doesn't parse or its project env key is missing, including a layer for an overlay you don't use, so the migration can't tell which values the layers already deliver. The error names the file or gives thehack env listcommand that shows the problem. Fix it, then retry; - a legacy
.hack/.envor overlay file exists but cannot be read, for example mode 000 or a dangling symlink (E_PERMISSION), so its values would be lost. A symlinked overlay that resolves to a readable file inside.hackis migrated like a regular one; cleanup removes only the link, never its target.
A refused migration keeps the legacy source files. Earlier layer merges or ignore-rule updates may remain, and removing newly created layers after a failure can itself fail. Check the layers and ignore rules before retrying; migration compares the values again.
With hack.env.json, the migration refuses (E_CONFIG_INVALID) when a plain_env value comes only
from your shell, because it would otherwise be saved to a plaintext file you didn't ask for. This applies to commands
that migrate implicitly, such as hack env exec. Simply skipping the value isn't enough, because
env layers don't read the contract, so services would stop receiving it. The error names the key and
never the value, and gives the commands for the scope the contract declares:
- Run one command without the variable, so the migration runs:
env -u KEY hack env exec -- true. Add a-ufor each key it names. - Set it for the declared scope:
hack env set KEY --service <name> --local --value-stdin, or without--servicefor a key the contract doesn't scope.--localalone delivers it to every service, so keep--servicefor a scoped key. - If an overlay file also sets the key, the migration moves that value to the overlay's
machine-local layer, which outranks the local base, so the overlay keeps it. To change it:
hack env set KEY --env <overlay> --local --value-stdin, with--serviceas above. The error prints a command only for a name the CLI accepts. It names an overlay file--envcan't select, such as.env.My_Stagingor.env.default, or a service--servicecan't. Fix those before step 1, because env layers don't read the contract once the project migrates. - Keep it exported if Compose interpolation, or a host command run without that service's scope,
reads a key you set with
--service, because a service value doesn't reach them. A value set without--servicedoes reach them, and overrides your shell.
The refusal comes before Hack provisions any secret-store key. If plaintext values are mirrored to a
secret backend whose key isn't available, Hack can't tell whether a key your shell sets is also in
the backend, so it refuses that key the same way. Make the backend's key available and retry. If the
backend has nothing stored yet and HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK isn't set, you can run
these steps instead, and the migration creates its key; set the key only if the backend doesn't hold
it. Any other backend failure, such as a corrupt store, stops the migration with that error.
With hack.env.json, the migration also refuses (E_CONFIG_INVALID), writing no env layer, an
overlay named default, local or <name>.local, in any case, that sets a value of its own, because
it would replace the base layer or write a machine-local file, and a contract service that isn't a
valid env scope when a value would be written for it, because no read could parse that layer. A
legacy .env.default or .env.local refuses even when it sets no value of its own, and so does a
different-case spelling of either, through the destination check. A
secret backend's key may already have been created by then. The migration also refuses, writing nothing
itself, a hack.env.json it can't open or read as a contract, and it doesn't quote the file. Earlier
hack doctor steps, such as creating .hack/.gitignore, can still have run.
With hack.env.json, the migration discovers only regular .hack/.env.<overlay> files, so a
symlinked overlay is not migrated. It refuses (E_PATH_CONFLICT) when .hack/.env or a
configured default overlay is a symlink, wherever it points, because another file's values,
such as a machine-local .env.local, would otherwise be copied into this project's env layers.
Without hack.env.json, nothing lists values kept in a secret backend, so none are
migrated: set them again with hack env set --secret after the migration. In that case .hack/hack.config.json, and
any secret backend it configures, is left unchanged. The migration changes project files
only, so it runs even when Docker is not reachable; the other --migrate-env-config
repairs still need Docker. As with any migration, hack doctor then offers to remove the
legacy .hack/.env* files that Compose no longer references (a non-interactive run
accepts for untracked files, and for git-tracked ones only with --accept-tracked-env-changes).
Files Compose still names as env_file are offered only when the Compose repair
removed those references. Without hack.env.json, a legacy file that git tracks, or one
whose status git cannot report in a git checkout, is never offered, because the migrated
values now live only in this machine's local layers.
Doctor offers legacy dotenv files for cleanup only when every parsed entry was
delivered to an env layer. Files containing undeclared or undelivered entries stay
on disk; the cleanup report lists their key names and omits their values. This
also applies to untracked overlays such as .env.bak and .env.example.
Doctor also keeps .hack/.env when the contract scopes a key in it to services
(services: [...]) and the Compose file or another env_file it lists names that key,
for example in ${KEY}. Compose reads .hack/.env for interpolation on its own, and
service values don't reach interpolation, so removing the file would drop that value.
To remove the file, set the key without --service or export it in your shell.
Migration refuses a legacy .env.default overlay before writing env layers,
because it would overwrite the base hack.env.default.yaml layer. Rename that
legacy overlay and update its project references before retrying.
It also refuses .env.local, whose tracked overlay path would collide with the
machine-local base layer. Rename that legacy overlay before migration.
All tracked and effective local destinations are checked for collisions before any
writes, including compatibility paths. For example, .env.staging together with
.env.staging.local is refused because both would write hack.env.staging.local.yaml.
Collision checks include case-only aliases for checkouts with case-insensitive filenames.
If a legacy dotenv file cannot be read, migration refuses cleanup and leaves
legacy artifacts in place. Env layers may already have been written; check read
access and preserve the remaining entries before cleaning up manually.
hack doctor also warns when a repo still depends on the old format. Its env mode
check reports an error, not the legacy format, when a .hack/hack.env.*.yaml entry is a
dangling symlink or another non-file.
For modern env repos, hack doctor also warns when materialized compatibility output in
.hack/.env or .hack/.env.state.json is stale and points to hack env materialize.
The legacy .hack/.env holds one value per key, with no per-service values. In a legacy
project, hack env set and hack env unset change only that global value, so they refuse
--service <service> with a usage error instead of ignoring it (an invalid scope is refused
the same way in both formats). --service global, or no --service, still works. Migrate to
env layers first with hack doctor --migrate-env-config, which also converts a project that
has only .hack/.env (see above), then set or unset the service-scoped value.
Values migrated from .hack/.env live in the machine-local layer, and a later layer wins
over a more specific scope in an earlier one. So to override a migrated key for one service,
add --local: hack env set KEY --service api --local. Without it the new value goes to the
tracked default layer, where the migrated global value in hack.env.local.yaml still wins
and the command has no effect on the runtime value.
hack env set and hack env unset choose between the env layers and the legacy file by
looking for .hack/hack.env.*.yaml entries, hack.env.default.yaml included. A regular
file, or a symlink inside the project to one, counts as an env layer. An entry linked outside
the project is refused with a closed error. Any other entry with a layer name, such
as a dangling symlink or a directory, is refused with E_PATH_CONFLICT. Nothing is
written either way, and neither silently selects the legacy .hack/.env. Replace or remove the entry,
then retry. Reads refuse the same way: hack env list, hack env explain and runtime
env injection stop with E_PATH_CONFLICT on a selected layer entry that is not a regular
file (such as a dangling hack.env.default.yaml), and with a closed one-line error on one
that cannot be read, instead of falling back to the legacy file.
Compatibility notes
The modern YAML env model is the canonical repo format today.
Some older surfaces are still compatibility-oriented:
hack env backend ...remains relevant for legacy repos and older secret-store configurations. Theencrypted_filestore (also used by thecloudshim) is replaced atomically with mode0600, and set/delete hold an in-process queue plus a cooperative<store>.lockso concurrent writers, including separate processes, do not lose updates. A store path that is a symlink is refused for writes. Explicit key provisioning also uses an atomic mode-0600replacement and refuses symlinked key paths or immediate parent directories. A key generated on first use is published only when no key file exists, so concurrent first users adopt the key published first instead of replacing it under a store already encrypted with it. This protects individual key writes; it does not make key replacement and store re-encryption a crash-safe rotation transaction. Keep the previous key until recovery has been verified. The v1 store envelope and legacyv1:values authenticate no associated data: they are not bound to a path, project or variable name beyond the scoped key inside the encrypted store plaintext- the daemon's env HTTP route (used by the macOS app) reads and writes the legacy dotenv-shaped
contract (
source: 'plain_env'|'keychain',resolvedFrom: 'dotenv'/'process'/'keychain'/'portable_backend'), not the modern YAMLhack env list --jsonshape; there is no web dashboard surface anymore
If you are writing new docs or new project setup flows, document the YAML overlay model first and treat .hack/hack.env.json as legacy migration context.
Recommended project policy
- Commit
.hack/hack.env.default.yaml - Commit
.hack/hack.env.<overlay>.yamlwhen the overlay is team-shared - Never commit
.hack.secret.key - Prefer
hack env addover hand-editing encrypted values - Enter secrets with
--value-stdinor the hidden prompt, never as command arguments - Prefer direct runtime injection over materializing
.hack/.env - Use
hack host execor env-aware sessions for host-side scripts like migrations, generators, and admin tasks
Related docs
- Initialize a project
- Sessions
- CLI reference
- Env and secrets threat model: assets, trust boundaries, current mitigations and open gaps
- Env secret security decision and rotation gates
Host command lifetime and diagnostics
hack host exec and hack env exec record payload-free execution metadata under
$HACK_HOME/host-commands (normally ~/.hack/host-commands). Records contain the
executable basename, project, process identities, owned process group, intended
lifetime, elapsed time, exit/cancellation/timeout outcome, and final child CPU/RSS
accounting. Arguments, shell strings, environment values and command output are
not recorded. Completed records expire after seven days during later executions;
interrupted records remain available for review. A diagnostic storage failure
warns on stderr and preserves command execution and exit status.
hack host exec --timeout 60 -- bun scripts/check.ts </dev/null
hack env exec --timeout 30 -- bun scripts/probe.ts </dev/null
hack host exec --lifetime persistent -- bun run dev
hack host ps --json
hack host ps --project my-project--timeout is a positive number of seconds. It sends SIGTERM to the owned process
group, escalates after two seconds, and returns 124. It currently requires
non-TTY stdin; piping input or redirecting from /dev/null preserves a safe group
boundary without changing interactive terminal job control. There is no default
timeout. --lifetime persistent declares intent and does not detach or create a
mux session; it cannot be combined with a timeout. Persistent commands still
respond to explicit cancellation. Use lifecycle processes or hack session for
managed persistent workspaces.
hack host ps also inspects existing lifecycle sessions using their stored mux
ownership evidence. It is read-only: missing wrappers and unverified groups are
reported for review and never killed. A PID must match its recorded start time
before live command metrics are attributed to it. Lifecycle definition freshness
is not revalidated by this resource snapshot. Commands started before tracking was
available cannot be retroactively assigned ownership.
Live CPU time is the cumulative CPU of currently observed tree/group members; short-lived descendants already gone are not included. Final CPU comes from the OS-reaped child resource usage and is a separate accounting source. RSS sums may count shared memory more than once. Missing observations are null, not zero. SIGKILL cannot be intercepted: interrupted records may lack a completion result, and group members without a surviving identity are explicitly unverified.
See runtime performance diagnostics for project-listing timings, container details, watcher measurements and read-only cleanup previews.
Disabling native keychain access
HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1 disables native keychain reads, writes,
and deletes, including hack secrets, project keychain stores, and macOS node
auth tokens. All three operations throw an explicit disabled error.
Encrypted-file stores still accept a configured key file or HACK_SECRETS_FILE_KEY,
and can generate a new key file when no encrypted store exists.
bun test loads tests/preload.ts, which sets this flag and replaces Bun.secrets
with an in-memory backend. Tests that exercise keychain behavior can temporarily
clear the flag; the preload still prevents native keychain access. Child CLI
processes inherit the flag, so tests must pass it through when supplying a custom
process environment. The in-memory backend is cleared after each test.
Source: v5 (next.12) · docs/env.md.