diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 11c6e2336..da8c67a02 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -137,7 +137,7 @@ Source: `hack env list --json --path `. |---|---| | Normal | Table: name, layer (`layer` / `resolved_from`), scope, kind (`storage.kind`), set (`has_value` ✓/✗), required. | | Missing required | Red rows from `missing_required`, first. | -| Locked | Env key missing or unreadable: lock icon, "Names are shown; values cannot be resolved", Copy `hack doctor`. Needs R10. | +| Locked | `key_state` is `missing` or `unreadable`: lock icon, "Names are shown; values cannot be resolved", Copy `hack doctor`. `ok` does not prove the key decrypts the values. | | No env config | "This project has no env layers" + docs link. | Editing is "later". Never a reveal button. @@ -384,7 +384,7 @@ Each is small enough for one PR. 11. **R7c** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate. 12. **R8** Daemon unit staleness: `service.unit_stale` and reason in `hack daemon status --json`, and `version` there. 13. **R9** (optional) Push updates: `GET /v1/projects?wait=` long-poll. -14. **R10** Env key state: `key_state: ok | missing | unreadable` in `hack env list --json` (no values). +14. **R10 — exists** Env key state: `key_state: ok | missing | unreadable | not_checked` in `hack env list --json` (no values). `not_checked` is the legacy `hack.env.json` contract. The payload keeps its published bare-object shape; no envelope was added. 15. **R11** Admin argv plan: `hack global install|trust --plan --json` listing the root-only argv lines. 16. **R12a** Always-on persistence and `hack autostart add|remove|list|pause|resume` ([section 6](#6-always-on-hack-2213-proposed-contract)). 17. **R12b** Always-on fields in project payloads and `autostart_global`. diff --git a/docs/env.md b/docs/env.md index 9672fcc10..e20c038f9 100644 --- a/docs/env.md +++ b/docs/env.md @@ -638,7 +638,16 @@ lifecycle code or Compose. Recreate just one service after an env change with: hack env apply --service api --env qa ``` -`env list` prints names, scopes, source layers and whether each value is set. It hides **all** values, including plaintext entries migrated from legacy dotenv files. JSON omits the `value` field and includes `has_value`. Modern listings do not acquire a decryption key. Legacy backend inspection may require backend access to determine availability. +`env list` prints names, scopes, source layers and whether each value is set. It hides **all** values, including plaintext entries migrated from legacy dotenv files. JSON omits the `value` field and includes `has_value`. Modern listings do not decrypt values. Legacy backend inspection may require backend access to determine availability. + +JSON also reports `key_state`, whether the project env key can be read, without the key or any value. It looks for the key in the same order as the runtime: the checkout's `.hack.secret.key`, the shared or primary-checkout copy, then `HACK_ENV_SECRET_KEY`. + +| `key_state` | Meaning | +| --- | --- | +| `ok` | A key is present and readable. It is not checked against existing values; `hack doctor` reports a layer the key cannot decrypt. | +| `missing` | No key file and no `HACK_ENV_SECRET_KEY`. An empty or whitespace-only key file counts as absent, as it does for the runtime, so the next location is used. Names are still listed. Copy the original key from a checkout that has it; a new key cannot decrypt existing values. | +| `unreadable` | A key file exists but is refused (a symlink, not a regular file, unreadable, not UTF-8 or larger than 1 MiB). Names are still listed. Run `hack doctor`. | +| `not_checked` | The project uses the legacy `hack.env.json` contract, which does not use this key. | `--show-values` reveals resolved values only when both stdin and stdout are terminals and interaction is enabled. The older `--show-secrets` spelling is an alias. Redirecting or piping the output refuses the reveal, including with `--json`; ordinary `--json` is metadata only. Use `env get KEY` for an explicit single-value read in a script. diff --git a/docs/versions/v5/guides/share-environment.md b/docs/versions/v5/guides/share-environment.md index 829386631..2eb9c29b3 100644 --- a/docs/versions/v5/guides/share-environment.md +++ b/docs/versions/v5/guides/share-environment.md @@ -11,6 +11,10 @@ Inspect masked values and provenance rather than printing credentials. Scope values to the intended service/overlay. A linked worktree can inherit the primary checkout's key; a separate host needs its own approved runtime delivery mechanism. +`hack env list --json` reports `key_state` (`ok`, `missing`, `unreadable` or +`not_checked`), so a script or app can tell whether this checkout can read the +project key without printing it. See [Environment and secrets](../../env.md). + For encrypted-file storage, possession of the project key grants decryption of values encrypted with it. Keep an approved backup separate from the repository. Git access and secret access are different grants. Removing someone's repository diff --git a/docs/versions/v5/reference/command-contracts.md b/docs/versions/v5/reference/command-contracts.md index 157389ee3..a89243ab2 100644 --- a/docs/versions/v5/reference/command-contracts.md +++ b/docs/versions/v5/reference/command-contracts.md @@ -350,7 +350,7 @@ Daemon status fields: status, running, api_ok, api_reachable, api_compatible, pr Env explain: key:string, available/secret:boolean, format:project_env_config_v1/legacy_env_contract; environment/effective_scope/source:string|null; requested_scope:string, target:host/compose, delivered_to/precedence:string[]. It emits no value. -Env list: project and env_selection (requested/default/effective selections, overlay_path, overlay_exists). Modern YAML adds format:project_env_config_v1, selected_scope, scopes, files, unknown_scopes, status/storage descriptions, vars[], missing_required. Legacy reports have storage/status, materialization and key projections. Rows in vars[] include key/required/source/services/resolved_from/storage plus has_value, scope and layer metadata. The value field is absent by default for every row, including plaintext entries. Values and modern materialized contents appear only with explicit --show-values (--show-secrets alias) and interactive stdin/stdout; noninteractive reveal refuses. Otherwise modern reports include materialized_keys. Scope representation differs between modern and legacy formats. Legacy reports additionally contain warnings[] (kind/key/overlay_path/services/message). This prevents accidental output exposure, not reads by local processes: env get KEY is the named noninteractive plaintext read. CLI reports differ from MCP's narrower independently value-free keys projection. Nested modern/legacy variants remain versioned report shapes; application values are not new stable reference keys. +Env list: project and env_selection (requested/default/effective selections, overlay_path, overlay_exists). Every report adds key_state (ok, missing, unreadable, or not_checked for the legacy contract): whether the project env key can be read, never the key itself. Modern YAML adds format:project_env_config_v1, selected_scope, scopes, files, unknown_scopes, status/storage descriptions, vars[], missing_required. Legacy reports have storage/status, materialization and key projections. Rows in vars[] include key/required/source/services/resolved_from/storage plus has_value, scope and layer metadata. The value field is absent by default for every row, including plaintext entries. Values and modern materialized contents appear only with explicit --show-values (--show-secrets alias) and interactive stdin/stdout; noninteractive reveal refuses. Otherwise modern reports include materialized_keys. Scope representation differs between modern and legacy formats. Legacy reports additionally contain warnings[] (kind/key/overlay_path/services/message). This prevents accidental output exposure, not reads by local processes: env get KEY is the named noninteractive plaintext read. CLI reports differ from MCP's narrower independently value-free keys projection. Nested modern/legacy variants remain versioned report shapes; application values are not new stable reference keys. Env protect: without keys, JSON is {operation:inventory, entries:[]}; with keys it is {operation:protect, entries:[]}. Each entry names key, scope, filePath, environment (nullable) and local; no values or ciphertext are returned. Omitted --env covers this checkout's overlays. An unqualified key covers matching scopes; scope.KEY or --service narrows it. Files commit independently; successful retries leave already encrypted entries unchanged. This command requires YAML layers. diff --git a/src/commands/env.ts b/src/commands/env.ts index c480d0ebc..1195fca98 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -90,8 +90,10 @@ import { listProjectEnvPlaintextEntries, materializeProjectEnv, migrateLegacyProjectEnv, + type ProjectEnvKeyState, type ProjectEnvResolvedMetadata, parseProjectEnvTarget, + probeProjectEnvKeyState, projectEnvConfigExists, projectEnvConfigExistsForMutation, protectProjectEnvEntries, @@ -1320,6 +1322,9 @@ const handleEnvList: CommandHandlerFor = async ({ projectDir: project.projectDir, }); const materializedExists = Object.keys(materialized).length > 0; + const keyState = await probeProjectEnvKeyState({ + projectRoot: project.projectRoot, + }); if (json) { process.stdout.write( `${JSON.stringify( @@ -1333,6 +1338,7 @@ const handleEnvList: CommandHandlerFor = async ({ showSecrets, materialized, materializedExists, + keyState, }), null, 2 @@ -1350,6 +1356,7 @@ const handleEnvList: CommandHandlerFor = async ({ ], ["selected_scope", selectedScope], ["files", modern.files.join(", ")], + ["project_key", keyState], [ "unknown_scopes", modern.unknownScopes.length > 0 @@ -1647,6 +1654,13 @@ const handleEnvApply: CommandHandlerFor = async ({ return await run(command, { cwd: project.projectRoot, hackReexec: true }); }; +/** + * `key_state` in `hack env list --json`: whether the project env key can be + * read (see {@link probeProjectEnvKeyState}), or `not_checked` for the legacy + * contract format, which does not use that key. Never carries key material. + */ +type EnvListKeyState = ProjectEnvKeyState | "not_checked"; + function buildModernEnvJsonPayload(input: { readonly project: ProjectContext; readonly projectName: string; @@ -1657,6 +1671,7 @@ function buildModernEnvJsonPayload(input: { readonly showSecrets: boolean; readonly materialized: Record; readonly materializedExists: boolean; + readonly keyState: ProjectEnvKeyState; }) { return { project: input.projectName, @@ -1668,6 +1683,7 @@ function buildModernEnvJsonPayload(input: { overlay_exists: input.modern.selection.overlayExists, }, format: "project_env_config_v1", + key_state: input.keyState, status: { ...MODERN_ENV_STATUS_CLASSIFICATION, summary: "Project env config overlays", @@ -1899,6 +1915,8 @@ function writeEnvListJson(input: { status: describeEnvAggregateStatusForJson({ storage: input.resolved.storage, }), + // The legacy contract does not use the project env key. + key_state: "not_checked" satisfies EnvListKeyState, storage: serializeEnvStorageForJson({ storage: input.resolved.storage, }), diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index a38723b0d..5ffc59ada 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -5145,6 +5145,39 @@ async function resolveProjectEnvKey(opts: { return null; } +/** + * Whether the project env key can be read, by the same priority the runtime + * uses (checkout-local, shared common-dir, primary checkout, then + * `HACK_ENV_SECRET_KEY`). + * + * - `ok`: a key is present and readable. + * - `missing`: no key file and no `HACK_ENV_SECRET_KEY`. An empty or + * whitespace-only file counts as absent, as it does for the runtime. + * - `unreadable`: a key file exists but is refused (symlink, not a regular + * file, unreadable or oversized), so the runtime cannot use it. + * + * Read-only: never creates, repairs or rotates a key. The key text stays in + * this function; callers receive only the state. It does not check that the + * key decrypts existing values; `hack doctor` reports undecryptable layers. + */ +export type ProjectEnvKeyState = "ok" | "missing" | "unreadable"; + +export async function probeProjectEnvKeyState(opts: { + readonly projectRoot: string; +}): Promise { + try { + const keyText = await resolveProjectEnvKey({ + projectRoot: opts.projectRoot, + required: false, + }); + return keyText === null ? "missing" : "ok"; + } catch { + // Every refusal and read failure means the runtime cannot use the key. + // Listing names must still succeed, so this never throws. + return "unreadable"; + } +} + /** * Next action when a project env key is needed but not found. A key is only * needed to decrypt values that are already encrypted, so this never diff --git a/tests/env-list-key-state.test.ts b/tests/env-list-key-state.test.ts new file mode 100644 index 000000000..3bacba597 --- /dev/null +++ b/tests/env-list-key-state.test.ts @@ -0,0 +1,230 @@ +import { afterEach, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + realpath, + rm, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * `hack env list --json` reports whether the project env key can be read + * (`key_state`), names only, and keeps listing names when it cannot. + */ + +const ENTRYPOINT = resolve(import.meta.dir, "..", "index.ts"); +const SECRET_VALUE = ["synthetic", "key", "state", "sentinel"].join("-"); +const CLI_TEST_TIMEOUT_MS = 60_000; + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function createProject(opts: { readonly legacy?: boolean } = {}) { + const root = await realpath(await mkdtemp(join(tmpdir(), "hack-key-state-"))); + roots.push(root); + const projectRoot = resolve(root, "repo"); + const projectDir = resolve(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + await writeFile( + resolve(projectDir, "docker-compose.yml"), + "services:\n api:\n image: alpine:3.20\n" + ); + await writeFile( + resolve(projectDir, "hack.config.json"), + `${JSON.stringify({ name: "key-state", dev_host: "key-state.hack" })}\n` + ); + if (opts.legacy) { + await writeFile( + resolve(projectDir, "hack.env.json"), + `${JSON.stringify({ + version: 1, + vars: [{ key: "LEGACY_KEY", source: "plain_env", required: false }], + })}\n` + ); + } else { + await writeFile( + resolve(projectDir, "hack.env.default.yaml"), + "version: 1\nsecretsprovider: project_key\nvalues:\n global: {}\n" + ); + } + return { root, projectRoot, projectDir }; +} + +type Project = Awaited>; + +async function runHack(opts: { + readonly project: Project; + readonly args: readonly string[]; + readonly stdin?: string; + readonly env?: Readonly>; +}) { + const proc = Bun.spawn([process.execPath, ENTRYPOINT, ...opts.args], { + cwd: opts.project.projectRoot, + env: privateCliEnv({ + root: opts.project.root, + env: { + HACK_GLOBAL_CONFIG_PATH: join(opts.project.root, "hack-config.json"), + HACK_HOME: join(opts.project.root, "hack-home"), + HACK_NO_INTERACTIVE: "1", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + NO_COLOR: "1", + ...opts.env, + }, + }), + stdin: opts.stdin === undefined ? "ignore" : "pipe", + stdout: "pipe", + stderr: "pipe", + }); + if (opts.stdin !== undefined && proc.stdin) { + proc.stdin.write(opts.stdin); + await proc.stdin.end(); + } + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; +} + +/** A modern project with one encrypted value and a generated project key. */ +async function createSecretProject(): Promise { + const project = await createProject(); + const set = await runHack({ + project, + args: [ + "env", + "set", + "API_TOKEN", + "--secret", + "--value-stdin", + "--path", + project.projectRoot, + ], + stdin: SECRET_VALUE, + }); + expect(set.exitCode).toBe(0); + return project; +} + +async function listJson(opts: { + readonly project: Project; + readonly env?: Readonly>; +}) { + const result = await runHack({ + project: opts.project, + args: ["env", "list", "--json", "--path", opts.project.projectRoot], + env: opts.env, + }); + const output = `${result.stdout}${result.stderr}`; + expect(output).not.toContain(SECRET_VALUE); + return { + exitCode: result.exitCode, + json: JSON.parse(result.stdout) as { + readonly format?: string; + readonly key_state: string; + readonly vars: ReadonlyArray<{ + readonly key: string; + readonly value?: unknown; + }>; + }, + }; +} + +function keyPath(project: Project): string { + return resolve(project.projectRoot, ".hack.secret.key"); +} + +test( + "env list --json reports a readable project key as ok", + async () => { + const project = await createSecretProject(); + const { exitCode, json } = await listJson({ project }); + expect(exitCode).toBe(0); + expect(json.format).toBe("project_env_config_v1"); + expect(json.key_state).toBe("ok"); + expect(json.vars.map((row) => row.key)).toEqual(["API_TOKEN"]); + expect(json.vars[0]).not.toHaveProperty("value"); + }, + CLI_TEST_TIMEOUT_MS +); + +test( + "env list --json reports a missing key and still lists names", + async () => { + const project = await createSecretProject(); + await unlink(keyPath(project)); + const { exitCode, json } = await listJson({ project }); + expect(exitCode).toBe(0); + expect(json.key_state).toBe("missing"); + expect(json.vars.map((row) => row.key)).toEqual(["API_TOKEN"]); + }, + CLI_TEST_TIMEOUT_MS +); + +test( + "env list --json counts HACK_ENV_SECRET_KEY as a readable key", + async () => { + const project = await createSecretProject(); + await unlink(keyPath(project)); + const { json } = await listJson({ + project, + env: { HACK_ENV_SECRET_KEY: "synthetic-fallback-key-text-for-the-probe" }, + }); + expect(json.key_state).toBe("ok"); + }, + CLI_TEST_TIMEOUT_MS +); + +test( + "env list --json treats an empty key file as absent, like the runtime", + async () => { + const project = await createSecretProject(); + await writeFile(keyPath(project), " \n"); + expect((await listJson({ project })).json.key_state).toBe("missing"); + const withFallback = await listJson({ + project, + env: { HACK_ENV_SECRET_KEY: "synthetic-fallback-key-text-for-the-probe" }, + }); + expect(withFallback.json.key_state).toBe("ok"); + }, + CLI_TEST_TIMEOUT_MS +); + +test( + "env list --json reports a refused key file as unreadable and still lists names", + async () => { + const project = await createSecretProject(); + // A symlinked key is refused by the runtime regardless of the user, so + // this case does not depend on file permissions or running as root. + const elsewhere = resolve(project.root, "elsewhere.key"); + await writeFile(elsewhere, "synthetic-key-text-behind-a-symlink\n"); + await unlink(keyPath(project)); + await symlink(elsewhere, keyPath(project)); + const { exitCode, json } = await listJson({ project }); + expect(exitCode).toBe(0); + expect(json.key_state).toBe("unreadable"); + expect(json.vars.map((row) => row.key)).toEqual(["API_TOKEN"]); + }, + CLI_TEST_TIMEOUT_MS +); + +test( + "env list --json does not check the key for the legacy contract", + async () => { + const project = await createProject({ legacy: true }); + const { json } = await listJson({ project }); + expect(json.key_state).toBe("not_checked"); + }, + CLI_TEST_TIMEOUT_MS +);