Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,14 +592,17 @@ input and without writing anything:
`.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.

In a project with `.hack/hack.env.json` and no env layers yet, `hack env set --local` refuses and
writes nothing for a plaintext value, because that project's migration copies `.hack/.env` values to
tracked layers. Use `hack env add` with the same flags, which migrates first and then writes the
machine-local layer. That migration still copies values already in `.hack/.env`, and a `plain_env`
value exported in your shell, to tracked layers.
With `--secret`, the value goes to the secret backend, and a key the contract declares as `keychain`
reaches a tracked layer as ciphertext when the project migrates. Without `hack.env.json`, `--local`
still writes `.hack/.env`.
With no env layers yet, `hack env set --local` writes legacy `.hack/.env` (or
`.hack/.env.<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 (or the shell)
to the gitignored machine-local layer, never a tracked layer. It reports each moved
key and destination path, without values. It refuses a local destination already
tracked by Git. Values declared as `keychain` migrate to tracked layers as ciphertext.
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
Expand Down Expand Up @@ -1044,15 +1047,24 @@ That migrates the repo into:

- `.hack/hack.env.default.yaml`
- `.hack/hack.env.<overlay>.yaml`
- `.hack/hack.env.local.yaml` and `.hack/hack.env.<overlay>.local.yaml` for plain values
- `.hack.secret.key` when needed

Tracked layers receive secret-backed ciphertext, not legacy plaintext.

Doctor offers legacy dotenv files for cleanup only when every parsed entry was
delivered to an env layer. Files containing undeclared or undelivered entries stay
on disk; the cleanup report lists their key names and omits their values. This
also applies to untracked overlays such as `.env.bak` and `.env.example`.
Migration refuses a legacy `.env.default` overlay before writing env layers,
because it would overwrite the base `hack.env.default.yaml` layer. Rename that
legacy overlay and update its project references before retrying.
It also refuses `.env.local`, whose tracked overlay path would collide with the
machine-local base layer. Rename that legacy overlay before migration.
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.
Expand Down
2 changes: 1 addition & 1 deletion docs/security/env-secrets-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ refer to `hack-dance/hack`.
| The key is committed. | The root `.gitignore` gets a `.hack.secret.key` entry before the key is created, so a failed entry write leaves no key; an existing checkout-local key git does not ignore is repaired or refused before a `--secret` write uses it, and `hack doctor` reports it as `env-key-unignored` (HACK-1375). Encryption refuses a git-tracked key, including an inherited primary key, before it is copied (#288). `hack doctor` reports `env-key-tracked` at any depth (#309) and the "generated files" check can untrack a root key with `--fix`. |
| A key copy ships in build output or a static site. | `hack doctor` reports `env-key-public-path` for copies under `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` and `.output/` (#309). |
| A weak, short or repetitive key. | `env-key-wrong-length` and `env-key-low-entropy` (#309). Generated keys are 32 random bytes in base64url. |
| Materialized `.hack/.env` or local overrides are committed. | Hack owns a committed `.hack/.gitignore` block, and `hack doctor` flags tracked generated files (see [ignore rules](../env.md#ignore-rules-committed-hackgitignore)). |
| Materialized `.hack/.env` or local overrides are committed. | Hack owns a committed `.hack/.gitignore` block, and `hack doctor` flags tracked generated files (see [ignore rules](../env.md#ignore-rules-committed-hackgitignore)). Contract migration puts legacy `plain_env` values in the ignored machine-local layer, refuses a tracked destination, and reports only keys and destination paths (HACK-1712/1713). |
| A secret leaks into a public artifact through a path nobody listed. | `tests/env-secret-sentinel-scan.test.ts` runs in one of the hosted CI `test shard i/4` jobs; the required `test` check passes only when every shard passes. It gives a primary checkout and a linked worktree random synthetic secrets through `env add --secret --value-stdin` (shared and `--local`), runs materialize, exec, up (Compose generation), env apply, logs, list, explain and doctor, then commits everything with `git add -A`. It requires zero occurrences of any value or the project key, raw or hex/base64/base64url encoded, in CLI output, recorded child argv, tracked files, every Git object, HACK_HOME, the private HOME and its caches, and generated Compose and runtime files. Plaintext may exist only in `.hack/.env`, and key text only in key files; each must be git-ignored, untracked and mode 0600. `.hack/.internal/compose.env.override.yml` must hold `${HACK_ENV_...}` references and no value, and must also be private and ignored. Values may reach a child's environment only for the exec'd command and `docker compose`. Positive controls prove delivery and that the scanner finds planted encodings in files and Git objects. Docker is a recording shim; no engine, keychain or installed Hack is reached. |
| A dependency vulnerability ships in Hack. | `bun install --frozen-lockfile`, and the read-only `scripts/dependabot-inventory.ts` lists open alerts (#309). Hack does not sandbox project dependencies that run under `hack host exec`; they receive the injected env by design. |

Expand Down
8 changes: 0 additions & 8 deletions src/cli/env-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,6 @@ export function parseEnvScopeOption(input: {
}
}

/**
* `hack env set --local` in a project with a `hack.env.json` contract and no env
* layers. The plaintext path writes `.hack/.env`, and the contract migration copies
* that file's values to tracked layers, so `--local` would not stay local.
*/
export const LEGACY_ENV_LOCAL_CONTRACT_MESSAGE =
"--local needs env layers, but this project has no env layers yet, and its hack.env.json migration copies .hack/.env values to tracked layers. Nothing was written. Use `hack env add` with the same flags, which migrates first and then writes the machine-local layer.";

/**
* Resolve the `--scope`/`--service` pair of host execution commands.
* Returns `null` when neither names a scope (the global scope).
Expand Down
7 changes: 6 additions & 1 deletion src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { lookup } from "node:dns/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { dirname, join, relative, resolve } from "node:path";
import { note, spinner } from "@clack/prompts";
import { YAML } from "bun";
import { HACK_AGENT_INTEGRATION_CONTENT_REVISION } from "../agents/integration-revision.ts";
Expand Down Expand Up @@ -3728,6 +3728,11 @@ async function maybeMigrateProjectEnvConfig(opts: {
return;
}
const migrationNotes = [`Wrote ${migrated.wroteFiles.join(", ")}`];
for (const moved of migrated.movedPlainValues) {
migrationNotes.push(
`Moved ${moved.key} to ${relative(project.projectRoot, moved.path)} (values omitted).`
);
}
if (migrated.updatedProjectConfig) {
migrationNotes.push(
"Updated .hack/hack.config.json for the modern env format."
Expand Down
15 changes: 1 addition & 14 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
withHandler,
} from "../cli/command.ts";
import {
LEGACY_ENV_LOCAL_CONTRACT_MESSAGE,
parseApplyServiceOption,
parseEnvGetArguments,
parseEnvScopeOption,
Expand All @@ -20,7 +19,6 @@ import {
import { resolveRequestedEnvName } from "../cli/env-selection.ts";
import { optEnv, optJson, optPath, optProject } from "../cli/options.ts";
import { resolveRegisteredProjectOption } from "../cli/project-selector.ts";
import { PROJECT_ENV_CONTRACT_FILENAME } from "../constants.ts";
import { readControlPlaneConfig } from "../control-plane/sdk/config.ts";
import { updateGlobalConfig } from "../lib/config.ts";
import { resolveGlobalConfigPath } from "../lib/config-paths.ts";
Expand All @@ -32,7 +30,7 @@ import {
assertEnvValueStdinUsage,
readEnvValueFromStdin,
} from "../lib/env-value-stdin.ts";
import { pathExists, readTextFile } from "../lib/fs.ts";
import { readTextFile } from "../lib/fs.ts";
import { isRecord } from "../lib/guards.ts";
import { resolveHackInvocation } from "../lib/hack-cli.ts";
import type {
Expand Down Expand Up @@ -1912,17 +1910,6 @@ const handleEnvSet: CommandHandlerFor<typeof setSpec> = async ({
}

const storeSecret = args.options.secret === true;
// Before stdin is read or anything is written. A secret goes to the store, not
// to .hack/.env, so the contract migration can't copy its plaintext to a tracked layer.
if (
args.options.local === true &&
!storeSecret &&
(await pathExists(
resolve(project.projectDir, PROJECT_ENV_CONTRACT_FILENAME)
))
) {
throw new CliUsageError(LEGACY_ENV_LOCAL_CONTRACT_MESSAGE);
}
const runtimeConfig = await readHackEnvRuntimeConfig({
projectDir: project.projectDir,
});
Expand Down
Loading
Loading