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
4 changes: 2 additions & 2 deletions apps/desktop/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Source: `hack env list --json --path <root>`.
|---|---|
| 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.
Expand Down Expand Up @@ -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=<generated_at>` 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`.
Expand Down
11 changes: 10 additions & 1 deletion docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions docs/versions/v5/guides/share-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/versions/v5/reference/command-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 18 additions & 0 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,10 @@ import {
listProjectEnvPlaintextEntries,
materializeProjectEnv,
migrateLegacyProjectEnv,
type ProjectEnvKeyState,
type ProjectEnvResolvedMetadata,
parseProjectEnvTarget,
probeProjectEnvKeyState,
projectEnvConfigExists,
projectEnvConfigExistsForMutation,
protectProjectEnvEntries,
Expand Down Expand Up @@ -1320,6 +1322,9 @@ const handleEnvList: CommandHandlerFor<typeof listSpec> = 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(
Expand All @@ -1333,6 +1338,7 @@ const handleEnvList: CommandHandlerFor<typeof listSpec> = async ({
showSecrets,
materialized,
materializedExists,
keyState,
}),
null,
2
Expand All @@ -1350,6 +1356,7 @@ const handleEnvList: CommandHandlerFor<typeof listSpec> = async ({
],
["selected_scope", selectedScope],
["files", modern.files.join(", ")],
["project_key", keyState],
[
"unknown_scopes",
modern.unknownScopes.length > 0
Expand Down Expand Up @@ -1647,6 +1654,13 @@ const handleEnvApply: CommandHandlerFor<typeof applySpec> = 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;
Expand All @@ -1657,6 +1671,7 @@ function buildModernEnvJsonPayload(input: {
readonly showSecrets: boolean;
readonly materialized: Record<string, string>;
readonly materializedExists: boolean;
readonly keyState: ProjectEnvKeyState;
}) {
return {
project: input.projectName,
Expand All @@ -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",
Expand Down Expand Up @@ -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,
}),
Expand Down
33 changes: 33 additions & 0 deletions src/lib/project-env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectEnvKeyState> {
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
Expand Down
Loading
Loading