Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
871debc
feat(secrets): retire a store whose key is gone, and fail doctor only…
roodboi Nov 13, 2026
3bc26d7
fix(secrets): re-check a store under its lock before retiring, and sc…
roodboi Nov 13, 2026
f913a59
fix(secrets): shell-quote every printed restore and retire command (H…
roodboi Nov 13, 2026
580e91d
fix(secrets): a key file that cannot be read is not missing, and its …
roodboi Nov 13, 2026
938212e
fix(secrets): retire by rename over an exclusive placeholder, refuse …
roodboi Nov 13, 2026
177610c
fix(secrets): a keychain that cannot be asked is not a missing key, a…
roodboi Nov 13, 2026
f4bc814
fix(secrets): only a Hack store envelope is retirable, and printed ch…
roodboi Nov 13, 2026
5a68914
fix(secrets): retire into an exclusive directory so nothing outside i…
roodboi Nov 13, 2026
484c874
fix(secrets): recovery advice that never overwrites, and count invali…
roodboi Nov 13, 2026
623c9a7
Merge remote-tracking branch 'origin/next' into claude/hack-2177-reti…
roodboi Nov 13, 2026
072955e
fix(secrets): pin a store reached through a symlinked directory to it…
roodboi Nov 13, 2026
31f64f9
fix(secrets): bind the confirmed retire target and check eligibility …
roodboi Nov 13, 2026
598c1ed
fix(secrets): show control characters in configured paths instead of …
roodboi Nov 13, 2026
3b0c8a0
fix(secrets): refuse a configured store path that is itself a symlink…
roodboi Nov 13, 2026
45f998d
fix(secrets): include registered worktrees in the store-reader scan (…
roodboi Nov 13, 2026
967739b
fix(secrets): count an unavailable registered checkout as unchecked (…
roodboi Nov 13, 2026
baee881
fix(secrets): refuse retiring a store another registered project's ke…
roodboi Nov 13, 2026
ef48a22
fix(secrets): test every configured key file directly before retiring…
roodboi Nov 13, 2026
4af05a1
fix(secrets): check the keychain fallback for projects without a key …
roodboi Nov 13, 2026
354f950
Merge remote-tracking branch 'origin/next' into claude/hack-2177-reti…
roodboi Nov 13, 2026
47afaee
fix(secrets): count rejected worktree entries in the strict registry …
roodboi Nov 13, 2026
a49048f
fix(secrets): refuse an uninspectable store path, and count a malform…
roodboi Nov 13, 2026
48bc0a6
fix(secrets): retire-store cannot rule out HACK_SECRETS_FILE_KEY in a…
roodboi Nov 13, 2026
e1fe59b
docs(secrets): say retire-store needs --allow-unchecked in help and r…
roodboi Nov 13, 2026
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
15 changes: 14 additions & 1 deletion docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -1677,7 +1677,20 @@ Some older surfaces are still compatibility-oriented:
before it lands, so a concurrent writer's key is kept, never overwritten. The
process-wide key cache holds only keychain material, so one store's key file never
stands in for another store's key. `hack doctor` reports an unreadable or refused
store, a missing or refused store key, and a key that does not open the store. Explicit key
store, a missing or refused store key, and a key that does not open the store.
A store whose key is missing or wrong is an error only in a project that reads
it (its legacy env contract declares a store-backed value, or plaintext is kept
in the backend), and a warning elsewhere. The finding names the registered
projects that read the store, and offers two ways forward: restore the key, or
run `hack secrets retire-store`. That command moves the store into a new
directory `<store>.retired-<UTC time>/` beside it, deletes and prints nothing, and says
how to move it back. It needs `--yes` without a terminal. It refuses a store that
another registered project's key still opens, and, unless `--allow-unchecked` is
passed, a store some registered projects could not be checked for. Because
`HACK_SECRETS_FILE_KEY` overrides key files and may be set in another shell for a
project that selects the store, the command cannot rule it out and also needs
`--allow-unchecked` as confirmation that none does. Doctor then lists
retired files as information. Explicit key
provisioning also uses an atomic mode-`0600` replacement 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
Expand Down
32 changes: 32 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -2338,6 +2338,7 @@ hack secrets <subcommand> [options]
| `hack secrets set [name]` | Store a secret |
| `hack secrets get [name]` | Print a secret (exit 1 if missing) |
| `hack secrets delete [name]` | Delete a stored secret |
| `hack secrets retire-store` | Move an encrypted secret store whose key is gone aside (reversible) |

### Options

Expand Down Expand Up @@ -2427,6 +2428,37 @@ the deletion is refused with E_INTERACTIVE_REQUIRED unless --yes is passed.
| `--help, -h` | Show help |
| `--version, -v` | Show version |

## `hack secrets retire-store`

Move an encrypted secret store whose key is gone aside (reversible)

### Usage

```bash
hack secrets retire-store [options]
```

For an encrypted_file (or cloud shim) store that hack doctor reports with a missing or wrong key.
Moves the store into a new directory <store>.retired-<UTC time>/ beside it: nothing is deleted, read or printed,
and the key file is left alone. It prints how to put the store back. The store is the one this project
selects (--path or the current directory), or the global one outside a project. Refused for a store that
opens with the key in use, that another registered project's key still opens, or that is unreadable for
another reason. HACK_SECRETS_FILE_KEY set in another shell cannot be inspected, so retiring needs --allow-unchecked:
pass it once you have confirmed no shell for a project that selects the store sets it (it also covers registered
projects that could not be checked). Without a TTY (or with
HACK_NO_INTERACTIVE / --no-interactive) it is refused with E_INTERACTIVE_REQUIRED unless --yes is passed.

### Options

| Option | Description |
| --- | --- |
| `--path, -p <dir>` | Run a project command against a repo path (overrides cwd search) |
| `--yes` | Confirm moving the store aside without prompting (required without a TTY or with HACK_NO_INTERACTIVE) |
| `--allow-unchecked` | Accept what cannot be checked: a HACK_SECRETS_FILE_KEY set in another shell for a project that selects the store, and registered projects that could not be checked. Retiring needs it; a key known to open the store still refuses |
| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
| `--help, -h` | Show help |
| `--version, -v` | Show version |


## Integrations

Expand Down
34 changes: 34 additions & 0 deletions docs/versions/v5/guides/upgrade-from-v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,40 @@ an explicit `env get KEY` is a deliberate plaintext read. Follow that PR's
migration notes when installing a release that contains it. Do not use the new
output contract or assume the protection command exists in next.16.

#### An old global secret store whose key is gone

A machine upgraded from v4 may still have an encrypted store such as
`~/.hack/secrets.enc.json` whose key file was lost. From next.17 (HACK-2169,
HACK-2177), Doctor reports that store as a `secret integrity` finding:

- an **error** in a project whose legacy env contract reads the store, and a
**warning** in any other project;
- the finding names the registered projects that read the store. Without the
key, its entries cannot be counted or read.

There are two ways forward. Do not provision a new key: it cannot open these
entries.

1. **Restore the key.** Put the key file back where the finding says (mode
600), from a backup or another machine, or set `HACK_SECRETS_FILE_KEY` to the
same key.
2. **Retire the store** when the key is gone for good:
`hack secrets retire-store`, run in a project that selects the store or
outside any project for the global one. Without a terminal, add `--yes`.
- It moves the store into a new directory `<store>.retired-<UTC time>/`
beside it, keeping its name. It deletes nothing, reads and prints no
entry, and leaves the key file alone. It only retires a file that is a
Hack encrypted store, and refuses one that another registered project's
key still opens. If some registered projects could not be checked (for
example a worktree on an unmounted volume), it refuses unless you pass
`--allow-unchecked`. The same flag confirms that no shell running a
project that selects the store sets `HACK_SECRETS_FILE_KEY` to its key.
That variable overrides key files and the keychain and cannot be checked
from the command, so in practice retiring needs `--allow-unchecked`.
- It prints the `mv` command that puts the store back.
- Afterwards Doctor lists the retired file as information, and the project
check no longer fails.

Sources for next.16 behavior: [env list and get](https://github.com/hack-dance/hack/blob/b21c3b189841c1a3cbb8af2b76414fa347dc7057/src/commands/env.ts),
[project key checks](https://github.com/hack-dance/hack/blob/b21c3b189841c1a3cbb8af2b76414fa347dc7057/src/lib/doctor-env-key.ts).

Expand Down
12 changes: 9 additions & 3 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3168,11 +3168,16 @@ async function checkSecretIntegrity({
message: "Every stored secret opens with the key in use",
};
}
// Information alone (a retired store kept beside its old name) is ok.
let status: CheckResult["status"] = "ok";
if (findings.some((finding) => finding.status === "error")) {
status = "error";
} else if (findings.some((finding) => finding.status === "warn")) {
status = "warn";
}
return {
name: SECRET_INTEGRITY_CHECK,
status: findings.some((finding) => finding.status === "error")
? "error"
: "warn",
status,
message: findings.map((finding) => finding.message).join("\n"),
};
}
Expand Down Expand Up @@ -5879,6 +5884,7 @@ async function renderRecoveryGuidance(
const hasActions =
guidance.temporaryBreakage.length > 0 ||
guidance.configurationRepair.length > 0 ||
(guidance.choices?.length ?? 0) > 0 ||
guidance.followUp.length > 0;

if (!hasActions) {
Expand Down
61 changes: 61 additions & 0 deletions src/commands/recovery-guidance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,31 @@ export type RecoveryCheckResult = {
export type DoctorRecoveryGuidance = {
readonly temporaryBreakage: readonly string[];
readonly configurationRepair: readonly string[];
/**
* Alternatives the operator chooses between (one is enough), as plain
* text; for example the two ways forward for a secret store whose key is
* missing (HACK-2177).
*/
readonly choices?: readonly string[];
readonly followUp: readonly string[];
readonly verify: readonly string[];
readonly capture: readonly string[];
};

const FIX_CHECKS = new Set(["caddy local ca", "dns:hack", "dns:hack.gy"]);
/** The secret integrity finding for a store whose key is missing names this command. */
const RETIRE_STORE_COMMAND = "hack secrets retire-store";
/** The command as the finding names it, with the diagnosed project's `--path`. */
const RETIRE_STORE_COMMAND_PATTERN =
/hack secrets retire-store(?: --path (?:'(?:[^']|'\\'')*'|[A-Za-z0-9_@%+=:,./-]+))?/u;

/** The two ways forward for such a store, shown as one choice (HACK-2177). */
function secretStoreChoices(command: string): readonly string[] {
return [
"Restore the encrypted secret store's key: put the key file back where the secret integrity check says (mode 600), or set HACK_SECRETS_FILE_KEY to the same key.",
`Or, if the key is gone for good, retire the store: \`${command}\` moves it aside to a timestamped name beside it, deletes nothing, and prints how to put it back. It needs --allow-unchecked, your confirmation that no shell for a project that selects the store sets HACK_SECRETS_FILE_KEY to its key, which Hack cannot inspect.`,
];
}
const SAFE_SHELL_ARG_PATTERN = /^[A-Za-z0-9_./:-]+$/u;
const PROJECTS_PRUNE_COMMAND_PATTERN =
/hack projects prune(?: --project [A-Za-z0-9_.-]+)?/u;
Expand Down Expand Up @@ -61,6 +80,7 @@ export function buildDoctorRecoveryGuidance(input: {
}): DoctorRecoveryGuidance {
const temporaryBreakage: string[] = [];
const configurationRepair: string[] = [];
const choices: string[] = [];
const followUp: string[] = [];

for (const result of input.results) {
Expand All @@ -72,6 +92,21 @@ export function buildDoctorRecoveryGuidance(input: {
continue;
}

if (
result.name === "secret integrity" &&
result.message.includes(RETIRE_STORE_COMMAND)
) {
const command =
result.message.match(RETIRE_STORE_COMMAND_PATTERN)?.[0] ??
RETIRE_STORE_COMMAND;
for (const choice of secretStoreChoices(command)) {
pushUnique(choices, choice);
}
// Any other finding in the same check still needs a look.
followUp.push(`${result.name}: ${result.message}`);
continue;
}

if (result.message.includes("hack global up")) {
pushUnique(temporaryBreakage, "hack global up");
continue;
Expand Down Expand Up @@ -133,6 +168,7 @@ export function buildDoctorRecoveryGuidance(input: {
return {
temporaryBreakage,
configurationRepair,
choices,
followUp,
verify: ["hack doctor"],
capture: ["hack crash-capture --path <repo>"],
Expand All @@ -150,6 +186,17 @@ export function scopeDoctorRecoveryGuidance(input: {
configurationRepair: input.guidance.configurationRepair.map((command) =>
scopeRecoveryCommand({ command, projectRoot: input.projectRoot })
),
// A retire command without --path acts on the current directory: scope
// it to the diagnosed project, as the other project commands are.
choices: (input.guidance.choices ?? []).map((choice) =>
input.projectRoot?.trim() &&
choice.includes(`\`${RETIRE_STORE_COMMAND}\``)
? choice.replace(
`\`${RETIRE_STORE_COMMAND}\``,
`\`${RETIRE_STORE_COMMAND} --path ${quoteShellArg(input.projectRoot.trim())}\``
)
: choice
),
followUp: [...input.guidance.followUp],
verify: input.guidance.verify.map((command) =>
scopeRecoveryCommand({ command, projectRoot: input.projectRoot })
Expand Down Expand Up @@ -188,6 +235,11 @@ export function buildRecoveryNextSteps(input: {
nextSteps.push(`Configuration repair: \`${command}\`.`);
}

const choices = scoped.choices ?? [];
if (choices.length > 0) {
nextSteps.push(`Choose one: ${choices.join(" ")}`);
}

for (const item of scoped.followUp) {
nextSteps.push(`Manual follow-up: ${item}`);
}
Expand Down Expand Up @@ -244,6 +296,15 @@ export function buildRecoveryWorkflowLines(input: {
stepNumber += 1;
}

const choices = scoped.choices ?? [];
if (choices.length > 0) {
lines.push(`${stepNumber}. Choose one:`);
for (const choice of choices) {
lines.push(` - ${choice}`);
}
stepNumber += 1;
}

if (scoped.followUp.length > 0) {
lines.push(`${stepNumber}. Investigate:`);
for (const item of scoped.followUp) {
Expand Down
Loading
Loading