From 4828f6ef75ae0621b23431a0c0e52a5434b70d4b Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:49:57 +0000 Subject: [PATCH 01/42] fix(env): stage secret writes in a self-ignoring directory; safe key rotation advice (HACK-2165) Audit F1 and lead item 2: - A private write (.hack/.env files, the project key, the encrypted store and its key) staged its complete contents in a `...tmp` sibling that no ignore entry covers, so a crash could leave a copy git would stage. Staging now happens in `.hack-staging/` next to the target: a private (0700) directory whose `.gitignore` of `*` exists before any staging file, so git never lists a leftover. The next write there sweeps leftovers older than ten minutes, and staging siblings from earlier versions of the same target. A linked or shared staging directory is refused. Store-internal writers (background commands, shared-host claims, the .internal compose override) keep sibling staging. - doctor's weak-key remedy said `mv .hack.secret.key .hack.secret.key.old` (a name git does not ignore) and to re-add secrets as argv values. It now keeps a 0600 copy of the old key outside the worktree until every secret is re-added, and re-adds with --value-stdin or the hidden prompt. Co-Authored-By: Claude --- src/lib/background-command/store.ts | 4 + src/lib/compose-env-at-rest.ts | 2 + src/lib/doctor-env-key.ts | 15 +- src/lib/private-file.ts | 56 ++++- src/lib/private-staging.ts | 234 ++++++++++++++++++ src/lib/shared-host-lifecycle.ts | 2 + tests/doctor-env-key.test.ts | 10 +- tests/private-staging.test.ts | 203 +++++++++++++++ tests/project-env-write-refusals.test.ts | 27 +- ...ecret-key-provisioning-convergence.test.ts | 9 +- tests/secret-store-file-hygiene.test.ts | 11 +- 11 files changed, 551 insertions(+), 22 deletions(-) create mode 100644 src/lib/private-staging.ts create mode 100644 tests/private-staging.test.ts diff --git a/src/lib/background-command/store.ts b/src/lib/background-command/store.ts index aa1a3add0..1ae19ca9a 100644 --- a/src/lib/background-command/store.ts +++ b/src/lib/background-command/store.ts @@ -682,6 +682,8 @@ async function expireJournal(opts: { await replacePrivateFileAtomic({ path: opts.paths.expired, directories: [opts.paths.dir], + // Store directories hold only their own files; stage beside them. + staging: "sibling", refusal: storeRefusal, text: JSON.stringify({ expiredAt: new Date(opts.now).toISOString(), @@ -718,6 +720,8 @@ async function removeJournalPayload(paths: JournalPaths): Promise { await replacePrivateFileAtomic({ path: paths.meta, directories: [paths.dir], + // Store directories hold only their own files; stage beside them. + staging: "sibling", refusal: storeRefusal, text: JSON.stringify({ ...meta, argv: [], workdir: null }), }).catch(() => undefined); diff --git a/src/lib/compose-env-at-rest.ts b/src/lib/compose-env-at-rest.ts index 997b55528..9d6a55c6a 100644 --- a/src/lib/compose-env-at-rest.ts +++ b/src/lib/compose-env-at-rest.ts @@ -75,6 +75,8 @@ function fileOptions(path: string): ReplaceablePrivateFileOptions { path, directories: overrideDirectories(path), refusal: () => new Error(REFUSAL), + // Already inside the ignored .internal directory, beside its own asides. + staging: "sibling", }; } diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index 88a1c80f6..f6024d005 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -220,8 +220,19 @@ async function lstatOrNull(path: string): Promise { } } +/** + * Where a rotation keeps the retired key: outside every worktree, in a + * private directory, so git can never collect it and values encrypted with + * it stay recoverable until every secret is re-added (HACK-2165). + */ +const RETIRED_KEY_DIR = "~/.hack/retired-keys"; + +/** How a secret is re-added without its value in argv or shell history. */ +const READD_SECRET = + "re-add each secret with ' | hack env add KEY --secret --value-stdin' (or omit the value in a terminal for a hidden prompt)"; + function rotateRemedy(path: (suffix?: string) => string): string { - return `mv ${path()} ${path(".old")}, then re-add each secret with 'hack env add --secret KEY VALUE' (Hack generates a new 256-bit key).`; + return `keep a recoverable copy of the old key outside the worktree first ('install -d -m 700 ${RETIRED_KEY_DIR} && install -m 600 ${path()} ${RETIRED_KEY_DIR}/'), then 'rm ${path()}' and ${READD_SECRET}; Hack generates a new 256-bit key. Delete the retired copy only once every secret decrypts under the new key.`; } async function inspectCandidate(opts: { @@ -578,7 +589,7 @@ export async function inspectProjectEnvKey( code: strength, status: "warn", subject: `$${ENV_KEY_ENV_NAME}`, - remedy: `set ${ENV_KEY_ENV_NAME} to a 256-bit key such as the output of 'openssl rand -base64 32', then re-add each secret with 'hack env add --secret KEY VALUE'.`, + remedy: `keep the current value somewhere private until every secret is re-added, set ${ENV_KEY_ENV_NAME} to a 256-bit key such as the output of 'openssl rand -base64 32', then ${READD_SECRET}.`, }); } } diff --git a/src/lib/private-file.ts b/src/lib/private-file.ts index 3014a1843..015393fd4 100644 --- a/src/lib/private-file.ts +++ b/src/lib/private-file.ts @@ -12,6 +12,7 @@ import { basename, dirname, join } from "node:path"; import { publishFileExclusive } from "./exclusive-publish.ts"; import { closedFileWriteError } from "./fs.ts"; import { isRecord } from "./guards.ts"; +import { preparePrivateStaging } from "./private-staging.ts"; /** * Bounded, no-follow reads and exclusive creates for private key material. @@ -207,6 +208,14 @@ export async function createPrivateFileExclusive(opts: { /** Options shared by the no-follow read and atomic replace of a private file. */ export type ReplaceablePrivateFileOptions = { readonly path: string; + /** + * Where a replace or publish stages the complete contents. `"ignored"` + * (the default) uses the private, self-ignoring staging directory next to + * the target (see `private-staging.ts`), so a copy left by a crash is + * never visible to git; `"sibling"` uses a `...tmp` sibling, + * for files outside any checkout whose directory must hold nothing else. + */ + readonly staging?: "ignored" | "sibling"; /** * Directories (normally the file's parent inside the project) that must be * real directories rather than symlinks, with a stable identity across the @@ -255,6 +264,30 @@ async function recheckRealDirectories(opts: { } } +/** A staging path for `opts.path` and the check to run before publishing it. */ +async function stagingFor(opts: ReplaceablePrivateFileOptions): Promise<{ + readonly path: string; + readonly recheck: () => Promise; +}> { + if (opts.staging === "sibling") { + return { + path: join( + dirname(opts.path), + `.${basename(opts.path)}.${randomUUID()}.tmp` + ), + recheck: async () => undefined, + }; + } + try { + return await preparePrivateStaging({ + target: opts.path, + refusal: opts.refusal, + }); + } catch (error: unknown) { + throw closedFileWriteError({ error, path: opts.path, operation: "write" }); + } +} + /** Null when absent; refuses a symlink (dangling or not) or non-regular entry. */ async function observeReplaceableTarget(opts: { readonly path: string; @@ -368,8 +401,9 @@ async function readReplaceableTargetOnce( * Replaces `path` with `text` atomically and durably, leaving mode 0600 (or * `mode`, for a non-secret file such as a committed ignore file). * - * A uniquely named sibling is created with O_CREAT|O_EXCL|O_NOFOLLOW and mode - * 0600, fchmod 0600, written and fsynced, then renamed over `path`; readers see + * A uniquely named staging file (see `staging`) is created with + * O_CREAT|O_EXCL|O_NOFOLLOW and mode 0600, fchmod 0600, written and fsynced, + * then renamed over `path`; readers see * either the previous or the complete new contents, never a partial file. A * symlink (dangling or not) or non-regular entry at `path`, or a symlinked or * replaced directory from `directories`, throws `refusal()` before any write @@ -393,10 +427,8 @@ export async function replacePrivateFileAtomic( const mode = (opts.mode ?? 0o600) & 0o777; const directories = await captureRealDirectories(opts); await observeReplaceableTarget(opts); - const staging = join( - dirname(opts.path), - `.${basename(opts.path)}.${randomUUID()}.tmp` - ); + const prepared = await stagingFor(opts); + const staging = prepared.path; const file = await openForPrivateWrite({ path: opts.path, open: staging }); let committed = false; try { @@ -405,6 +437,7 @@ export async function replacePrivateFileAtomic( await file.writeFile(opts.text, "utf8"); await file.sync(); await recheckRealDirectories({ directories, refusal: opts.refusal }); + await prepared.recheck(); await observeReplaceableTarget(opts); await opts.beforeRename?.(); await rename(staging, opts.path); @@ -424,8 +457,8 @@ export async function replacePrivateFileAtomic( * Publishes `text` at `path` only when no entry exists there, and reports * whether this call published it. * - * The complete contents are staged in a private (0600, fsynced) sibling and - * published with link(2), which fails with EEXIST instead of replacing an + * The complete contents are staged in a private (0600, fsynced) file (see + * `staging`) and published with link(2), which fails with EEXIST instead of replacing an * existing entry. Unlike {@link createPrivateFileExclusive}, readers never see * a partial file, so a concurrent loser can adopt the winner's file * immediately. An existing regular file returns false untouched; a symlink @@ -446,10 +479,8 @@ export async function publishPrivateFileExclusive( if (await observeReplaceableTarget(opts)) { return false; } - const staging = join( - dirname(opts.path), - `.${basename(opts.path)}.${randomUUID()}.tmp` - ); + const prepared = await stagingFor(opts); + const staging = prepared.path; const file = await openForPrivateWrite({ path: opts.path, open: staging }); let published = false; try { @@ -458,6 +489,7 @@ export async function publishPrivateFileExclusive( await file.writeFile(opts.text, "utf8"); await file.sync(); await recheckRealDirectories({ directories, refusal: opts.refusal }); + await prepared.recheck(); published = await publishFileExclusive({ source: staging, target: opts.path, diff --git a/src/lib/private-staging.ts b/src/lib/private-staging.ts new file mode 100644 index 000000000..a640e9041 --- /dev/null +++ b/src/lib/private-staging.ts @@ -0,0 +1,234 @@ +import { randomUUID } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { chmod, lstat, mkdir, open, readdir, unlink } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { isRecord } from "./guards.ts"; + +/** + * Where private writes stage their complete contents before the rename or + * link that publishes them (HACK-2165). + * + * A staging copy holds the whole secret (an env file, a key), and a process + * killed between its create and its rename leaves it behind. A sibling named + * after the target (`...tmp`) is not matched by the ignore entry + * for the target itself, so git could stage it. Staging copies therefore go + * into a private directory next to the target, {@link PRIVATE_STAGING_DIRNAME}, + * that ignores itself: it holds a `.gitignore` of `*`, so git never lists + * anything in it without any change to the project's own ignore files. It is + * on the target's filesystem, so the rename stays atomic. + * + * Ownership: every entry in that directory is a Hack staging file; leftovers + * older than {@link STAGING_LEFTOVER_MIN_AGE_MS} are removed by the next + * staging write in it and by `hack doctor --fix`. Staging siblings written by + * earlier Hack versions are swept the same way. + */ +export const PRIVATE_STAGING_DIRNAME = ".hack-staging"; + +/** + * A staging file younger than this may belong to a write still in progress, + * so sweeps leave it alone. + */ +export const STAGING_LEFTOVER_MIN_AGE_MS = 10 * 60_000; + +const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const STAGING_FILE = new RegExp(`^.+\\.${UUID}\\.tmp$`); + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Matches the `...tmp` sibling earlier versions staged `name` in. */ +export function legacyStagingSiblingPattern(name: string): RegExp { + return new RegExp(`^\\.${escapeRegExp(name)}\\.${UUID}\\.tmp$`); +} + +const LEGACY_SIBLING = new RegExp(`^\\.(.+)\\.${UUID}\\.tmp$`); + +/** + * The target name a legacy `...tmp` sibling was staging, or null + * when `fileName` is not one. + */ +export function legacyStagingTargetName(fileName: string): string | null { + return LEGACY_SIBLING.exec(fileName)?.[1] ?? null; +} + +/** Whether `name` is a staging file inside {@link PRIVATE_STAGING_DIRNAME}. */ +export function isStagingFileName(name: string): boolean { + return STAGING_FILE.test(name); +} + +function isMissing(error: unknown): boolean { + return isRecord(error) && error.code === "ENOENT"; +} + +function isExisting(error: unknown): boolean { + return isRecord(error) && error.code === "EEXIST"; +} + +/** A directory only the current user can use: real, owned, no group/other bits. */ +function isPrivateDirectory(stats: Stats): boolean { + return ( + stats.isDirectory() && + !stats.isSymbolicLink() && + stats.uid === process.getuid?.() && + (stats.mode & 0o077) === 0 + ); +} + +/** A leftover Hack may remove: a regular file of ours, old enough. */ +export function isRemovableLeftover(opts: { + readonly stats: Stats; + readonly now: number; +}): boolean { + return ( + opts.stats.isFile() && + opts.stats.uid === process.getuid?.() && + opts.now - opts.stats.mtimeMs >= STAGING_LEFTOVER_MIN_AGE_MS + ); +} + +export type StagingLeftover = { + readonly path: string; + readonly stats: Stats; +}; + +/** + * Leftover staging copies for targets in `directory`: every old-enough file + * in its staging directory, and old-enough legacy siblings of the given + * target names. Nothing is read or removed. + */ +export async function findStagingLeftovers(opts: { + readonly directory: string; + readonly names: readonly string[]; + readonly now: number; +}): Promise { + const found: StagingLeftover[] = []; + const collect = async (dir: string, accept: (name: string) => boolean) => { + const entries = await readdir(dir).catch(() => [] as string[]); + for (const name of entries.filter(accept).sort()) { + const path = join(dir, name); + const stats = await lstat(path).catch(() => null); + if (stats && isRemovableLeftover({ stats, now: opts.now })) { + found.push({ path, stats }); + } + } + }; + const patterns = opts.names.map(legacyStagingSiblingPattern); + await collect(opts.directory, (name) => + patterns.some((pattern) => pattern.test(name)) + ); + const staging = join(opts.directory, PRIVATE_STAGING_DIRNAME); + const stagingStats = await lstat(staging).catch(() => null); + if (stagingStats && isPrivateDirectory(stagingStats)) { + await collect(staging, isStagingFileName); + } + return found; +} + +/** + * Removes a leftover only while it is still the file that was found (same + * device and inode), so a replacement is never removed by mistake. + */ +export async function removeStagingLeftover( + leftover: StagingLeftover +): Promise { + const current = await lstat(leftover.path).catch(() => null); + if ( + !current?.isFile() || + current.dev !== leftover.stats.dev || + current.ino !== leftover.stats.ino + ) { + return false; + } + await unlink(leftover.path).catch((error: unknown) => { + if (!isMissing(error)) { + throw error; + } + }); + return true; +} + +export type PreparedStaging = { + /** Where to create the staging file (exclusively, no-follow). */ + readonly path: string; + /** Re-check before publishing; throws `refusal()` when the directory changed. */ + readonly recheck: () => Promise; +}; + +/** + * Prepares the staging directory next to `target` and returns a fresh + * staging path in it. The directory is created private (0700) with its + * self-ignoring `.gitignore`; an existing entry there that is a link, not a + * directory, not the user's own or open to group or others is refused with + * `refusal()`, so a staging copy is never created through it. Old leftovers + * of this target are swept first. + */ +export async function preparePrivateStaging(opts: { + readonly target: string; + readonly refusal: () => Error; + readonly now?: number; +}): Promise { + const directory = join(dirname(opts.target), PRIVATE_STAGING_DIRNAME); + const created = await mkdir(directory, { mode: 0o700 }).then( + () => true, + (error: unknown) => { + if (!isExisting(error)) { + throw error; + } + return false; + } + ); + if (created) { + // The umask can only remove bits: a restrictive one would leave a + // directory the owner cannot create files in. + await chmod(directory, 0o700); + } + const identity = await lstat(directory).catch(() => null); + if (!(identity && isPrivateDirectory(identity))) { + throw opts.refusal(); + } + await ensureSelfIgnore(directory); + const leftovers = await findStagingLeftovers({ + directory: dirname(opts.target), + names: [basename(opts.target)], + now: opts.now ?? Date.now(), + }); + for (const leftover of leftovers) { + await removeStagingLeftover(leftover).catch(() => false); + } + return { + path: join(directory, `${basename(opts.target)}.${randomUUID()}.tmp`), + recheck: async () => { + const current = await lstat(directory).catch(() => null); + if ( + !(current && isPrivateDirectory(current)) || + current.dev !== identity.dev || + current.ino !== identity.ino + ) { + throw opts.refusal(); + } + }, + }; +} + +/** `*` ignores every entry, the `.gitignore` itself included. */ +async function ensureSelfIgnore(directory: string): Promise { + let file: Awaited> | null = null; + try { + file = await open( + join(directory, ".gitignore"), + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_WRONLY, + 0o600 + ); + await file.writeFile("*\n", "utf8"); + } catch (error: unknown) { + if (!isExisting(error)) { + throw error; + } + } finally { + await file?.close(); + } +} diff --git a/src/lib/shared-host-lifecycle.ts b/src/lib/shared-host-lifecycle.ts index 47f81702c..006b838ee 100644 --- a/src/lib/shared-host-lifecycle.ts +++ b/src/lib/shared-host-lifecycle.ts @@ -479,6 +479,8 @@ async function writeClaim( directories: [spec.stateDir], refusal: () => new ClaimRefusal(), text: `${JSON.stringify(claim)}\n`, + // Store directories hold only their own files; stage beside them. + staging: "sibling", }); } diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index 381a497a3..f47204ffb 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -178,9 +178,15 @@ describe("env key doctor diagnostic", () => { const inspection = await inspect(root); expect(codes(inspection)).toEqual(["env-key-wrong-length"]); - expect(inspection.findings[0]?.remedy).toContain( - `mv ${KEY_FILE} ${KEY_FILE}.old` + // HACK-2165: the old key leaves the worktree (never a committable + // `.old` sibling) and secrets are re-added without argv values. + const remedy = inspection.findings[0]?.remedy ?? ""; + expect(remedy).toContain( + `install -m 600 ${KEY_FILE} ~/.hack/retired-keys/` ); + expect(remedy).toContain("--value-stdin"); + expect(remedy).not.toContain(".old"); + expect(remedy).not.toContain("KEY VALUE"); expectValueFree({ output: serialized(inspection), root, secrets: [key] }); }); diff --git a/tests/private-staging.test.ts b/tests/private-staging.test.ts new file mode 100644 index 000000000..88b2e0b61 --- /dev/null +++ b/tests/private-staging.test.ts @@ -0,0 +1,203 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; +import { replacePrivateFileAtomic } from "../src/lib/private-file.ts"; +import { + findStagingLeftovers, + PRIVATE_STAGING_DIRNAME, + preparePrivateStaging, + STAGING_LEFTOVER_MIN_AGE_MS, +} from "../src/lib/private-staging.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; + +const dirs: string[] = []; + +afterEach(async () => { + for (const dir of dirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "hack-private-staging-")); + dirs.push(dir); + return dir; +} + +const refusal = () => new Error("synthetic staging refusal"); +const UUID = "0f0e0d0c-0b0a-4908-8706-050403020100"; + +async function git(cwd: string, args: readonly string[]): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + env: fixtureGitEnv({ + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + HOME: cwd, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + }, + }), + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + return out; +} + +async function backdate(path: string): Promise { + const old = (Date.now() - STAGING_LEFTOVER_MIN_AGE_MS - 60_000) / 1000; + await utimes(path, old, old); +} + +test("the staging directory is private and ignores itself before any staging file exists", async () => { + const root = await tempDir(); + const target = join(root, PROJECT_ENV_KEY_FILENAME); + const prepared = await preparePrivateStaging({ target, refusal }); + const staging = join(root, PRIVATE_STAGING_DIRNAME); + expect(prepared.path.startsWith(`${staging}/`)).toBe(true); + expect((await lstat(staging)).mode & 0o777).toBe(0o700); + expect(await readFile(join(staging, ".gitignore"), "utf8")).toBe("*\n"); + expect(await readdir(staging)).toEqual([".gitignore"]); +}); + +test("an interrupted key publication leaves nothing git can stage (HACK-2165 F1)", async () => { + const root = await tempDir(); + await git(root, ["init", "-q", "-b", "main"]); + await writeFile(join(root, ".gitignore"), `${PROJECT_ENV_KEY_FILENAME}\n`); + const target = join(root, PROJECT_ENV_KEY_FILENAME); + // A process killed after writing its staging copy, before the publish. + const prepared = await preparePrivateStaging({ target, refusal }); + await writeFile(prepared.path, "synthetic-staged-key\n", { mode: 0o600 }); + await writeFile(target, "synthetic-published-key\n", { mode: 0o600 }); + + await git(root, ["add", "-A"]); + const staged = await git(root, ["diff", "--cached", "--name-only"]); + expect(staged.split("\n").filter(Boolean)).toEqual([".gitignore"]); + const status = await git(root, [ + "status", + "--porcelain", + "--untracked-files=all", + ]); + expect(status).not.toContain(PRIVATE_STAGING_DIRNAME); + expect(status).not.toContain(".tmp"); +}); + +test("a write stages in the private directory and leaves only its .gitignore", async () => { + const root = await tempDir(); + const target = join(root, ".env"); + await replacePrivateFileAtomic({ + path: target, + directories: [root], + refusal, + text: "SYNTHETIC=1\n", + }); + expect(await readFile(target, "utf8")).toBe("SYNTHETIC=1\n"); + expect((await readdir(root)).sort()).toEqual([ + ".env", + PRIVATE_STAGING_DIRNAME, + ]); + expect(await readdir(join(root, PRIVATE_STAGING_DIRNAME))).toEqual([ + ".gitignore", + ]); + + // A failure before the rename removes the staging copy too. + await expect( + replacePrivateFileAtomic({ + path: target, + directories: [root], + refusal, + text: "SYNTHETIC=2\n", + beforeRename: async () => { + throw new Error("synthetic abort"); + }, + }) + ).rejects.toThrow("synthetic abort"); + expect(await readFile(target, "utf8")).toBe("SYNTHETIC=1\n"); + expect(await readdir(join(root, PRIVATE_STAGING_DIRNAME))).toEqual([ + ".gitignore", + ]); +}); + +test("the sibling option keeps the previous staging location", async () => { + const root = await tempDir(); + const target = join(root, "claim.json"); + await replacePrivateFileAtomic({ + path: target, + directories: [root], + refusal, + text: "{}\n", + staging: "sibling", + }); + expect(await readdir(root)).toEqual(["claim.json"]); +}); + +test("old leftovers are swept by the next staging write; recent ones are kept", async () => { + const root = await tempDir(); + const target = join(root, ".env"); + const staging = join(root, PRIVATE_STAGING_DIRNAME); + await preparePrivateStaging({ target, refusal }); + const oldStaged = join(staging, `.env.${UUID}.tmp`); + const recentStaged = join(staging, `.env.${UUID.replace("0f", "1f")}.tmp`); + const oldSibling = join(root, `..env.${UUID}.tmp`); + const otherFile = join(root, `..envx.${UUID}.tmp`); + for (const path of [oldStaged, recentStaged, oldSibling, otherFile]) { + await writeFile(path, "SYNTHETIC_LEFTOVER=1\n", { mode: 0o600 }); + } + for (const path of [oldStaged, oldSibling, otherFile]) { + await backdate(path); + } + + const found = await findStagingLeftovers({ + directory: root, + names: [".env"], + now: Date.now(), + }); + expect(found.map((leftover) => leftover.path).sort()).toEqual( + [oldSibling, oldStaged].sort() + ); + + await preparePrivateStaging({ target, refusal }); + expect((await readdir(staging)).sort()).toEqual( + [".gitignore", `.env.${UUID.replace("0f", "1f")}.tmp`].sort() + ); + // Only this target's legacy sibling is swept; another name is left alone. + expect((await readdir(root)).sort()).toEqual( + [PRIVATE_STAGING_DIRNAME, `..envx.${UUID}.tmp`].sort() + ); +}); + +test("a linked or shared staging directory is refused before anything is staged", async () => { + const root = await tempDir(); + const elsewhere = join(root, "elsewhere"); + await mkdir(elsewhere); + const linked = join(root, "linked"); + await mkdir(linked); + await symlink(elsewhere, join(linked, PRIVATE_STAGING_DIRNAME)); + await expect( + preparePrivateStaging({ target: join(linked, ".env"), refusal }) + ).rejects.toThrow("synthetic staging refusal"); + expect(await readdir(elsewhere)).toEqual([]); + + const shared = join(root, "shared"); + await mkdir(join(shared, PRIVATE_STAGING_DIRNAME), { recursive: true }); + await chmod(join(shared, PRIVATE_STAGING_DIRNAME), 0o777); + await expect( + preparePrivateStaging({ target: join(shared, ".env"), refusal }) + ).rejects.toThrow("synthetic staging refusal"); + expect(await readdir(join(shared, PRIVATE_STAGING_DIRNAME))).toEqual([]); +}); diff --git a/tests/project-env-write-refusals.test.ts b/tests/project-env-write-refusals.test.ts index 33a4802e2..91da04706 100644 --- a/tests/project-env-write-refusals.test.ts +++ b/tests/project-env-write-refusals.test.ts @@ -149,8 +149,17 @@ async function refusalOf( throw new Error("Expected the write to be refused"); } +/** Staging leftovers beside the files in `directory` and in its staging directory. */ async function tmpSiblings(directory: string): Promise { - return (await readdir(directory)).filter((name) => name.endsWith(".tmp")); + const staged = await readdir(join(directory, ".hack-staging")).catch( + () => [] as string[] + ); + return [ + ...(await readdir(directory)).filter((name) => name.endsWith(".tmp")), + ...staged + .filter((name) => name.endsWith(".tmp")) + .map((name) => `.hack-staging/${name}`), + ]; } // Root bypasses permission bits, so an unreadable layer only exists for others. @@ -1267,9 +1276,14 @@ function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** Pattern for the staging siblings of `path` (`...tmp`). */ +/** + * Pattern for the staging files of `path`: an atomic layer write's sibling + * (`...tmp`) or a private write's file in the self-ignoring + * staging directory (`.hack-staging/..tmp`, HACK-2165). + */ function stagingOf(path: string): string { - return `/\\.${escapeRegExp(path.split("/").at(-1) ?? "")}\\.[^/]+\\.tmp$`; + const name = escapeRegExp(path.split("/").at(-1) ?? ""); + return `/(?:\\.${name}|\\.hack-staging/${name})\\.[^/]+\\.tmp$`; } /** Pattern for a path ending in `suffix`, whatever prefix resolves to it. */ @@ -1515,10 +1529,15 @@ for (const [step, code] of [ `E_NO_SPACE: Could not write ${keyPath}: ${NO_SPACE_REASON}` ); expectNoErrnoOrStaging(run); - // The ignore entry is written before the key (HACK-1375) and stays. + // The ignore entry is written before the key (HACK-1375) and stays. The + // failed staging copy is gone; its self-ignoring directory stays (HACK-2165). expect((await readdir(repo.projectRoot)).sort()).toEqual([ ".gitignore", ".hack", + ".hack-staging", + ]); + expect(await readdir(resolve(repo.projectRoot, ".hack-staging"))).toEqual([ + ".gitignore", ]); expect( await readFile(resolve(repo.projectRoot, ".gitignore"), "utf8") diff --git a/tests/secret-key-provisioning-convergence.test.ts b/tests/secret-key-provisioning-convergence.test.ts index 845d9bd16..1e3967e47 100644 --- a/tests/secret-key-provisioning-convergence.test.ts +++ b/tests/secret-key-provisioning-convergence.test.ts @@ -286,7 +286,14 @@ test("concurrent first use of an encrypted_file store converges on one key and l expect(keyInfo.mode & 0o777).toBe(0o600); expect(keyInfo.nlink).toBe(1); expect((await readFile(keyPath, "utf8")).trim().length).toBeGreaterThan(0); - expect(await readdir(join(base, "keys"))).toEqual(["secrets-file.key"]); + expect((await readdir(join(base, "keys"))).sort()).toEqual([ + ".hack-staging", + "secrets-file.key", + ]); + // The staging directory keeps only its self-ignoring .gitignore. + expect(await readdir(join(base, "keys", ".hack-staging"))).toEqual([ + ".gitignore", + ]); // Every worker's values decrypt with the key on disk (keychain disabled). const store = await resolveSecretStore({ projectName: "app" }); diff --git a/tests/secret-store-file-hygiene.test.ts b/tests/secret-store-file-hygiene.test.ts index 38652fa31..4575d5fb7 100644 --- a/tests/secret-store-file-hygiene.test.ts +++ b/tests/secret-store-file-hygiene.test.ts @@ -96,9 +96,18 @@ async function configureEncryptedFileStore(): Promise<{ return { storeDir, storePath }; } +/** + * Entries other than the store, with the private staging directory listed + * only when it holds more than its own `.gitignore` (a staging leftover). + */ async function nonEnvelopeEntries(directory: string): Promise { + const staged = await readdir(join(directory, ".hack-staging")).catch( + () => null + ); return (await readdir(directory)).filter( - (name) => name !== "secrets.enc.json" + (name) => + name !== "secrets.enc.json" && + !(name === ".hack-staging" && staged?.join() === ".gitignore") ); } From b1e2d5de0419e5ff50db1a9c43e5c3efa1299774 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:49:57 +0000 Subject: [PATCH 02/42] fix(env): tighten loose machine-local layers on write; report them on read (HACK-2165) Audit S1 and lead item 1: a machine-local layer that predates private creation, or was made by hand, kept mode 0644 through writes, and its staging copy took that mode before the contents were written. A machine-local write now drops group and other bits from an existing layer, before the staging copy holds any content, and a write that changes nothing tightens the file in place. Shared layers keep their mode. Commands that read env layers (env, up/run paths, session) warn once per file on stderr about a loose machine-local file, after a successful read so a refusal stays one line. HACK-1680's keep-mode test now asserts the tightening. Co-Authored-By: Claude --- src/commands/env.ts | 6 ++- src/commands/project.ts | 5 +++ src/commands/session.ts | 3 ++ src/lib/env-layer-modes.ts | 61 ++++++++++++++++++++++++++++ src/lib/fs.ts | 43 +++++++++++++++++++- src/lib/project-env-config.ts | 9 +++-- tests/env-layer-modes.test.ts | 45 +++++++++++++++++++++ tests/env-local-layer-mode.test.ts | 65 +++++++++++++++++++++++++++--- 8 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 src/lib/env-layer-modes.ts create mode 100644 tests/env-layer-modes.test.ts diff --git a/src/commands/env.ts b/src/commands/env.ts index 6d250ac1f..e92d777b7 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -27,6 +27,7 @@ import { parseEnvHydrateInputFormat, readEnvHydrateInput, } from "../lib/env-hydrate-input.ts"; +import { reportLooseEnvLayerModes } from "../lib/env-layer-modes.ts"; import { assertEnvValueStdinUsage, readEnvValueFromStdin, @@ -740,12 +741,15 @@ async function loadModernProjectEnv(input: { const serviceNames = await discoverComposeServiceNames({ composeFile: input.project.composeFile, }); - return await resolveProjectEnvConfig({ + const resolved = await resolveProjectEnvConfig({ projectRoot: input.project.projectRoot, projectDir: input.project.projectDir, envName: input.envName, serviceNames, }); + // After a successful read, so a refusal stays one line. + await reportLooseEnvLayerModes({ projectDir: input.project.projectDir }); + return resolved; } async function resolveEnvInjection(input: { diff --git a/src/commands/project.ts b/src/commands/project.ts index 0c474326c..81d57d615 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,3 +1,4 @@ +import { reportLooseEnvLayerModes } from "../lib/env-layer-modes.ts"; import { createLifecycleProcessController, installLifecycleSignalCleanup, @@ -1416,6 +1417,8 @@ export async function resolveModernComposeEnvOverrides(opts: { envName: opts.envName, serviceNames: opts.allServiceNames, }); + // After a successful read, so a refusal stays one line. + await reportLooseEnvLayerModes({ projectDir: opts.project.projectDir }); if (!modern) { return null; } @@ -1508,6 +1511,8 @@ export async function resolveLifecycleEnvForProject(opts: { envName: opts.envName, serviceNames, }); + // After a successful read, so a refusal stays one line. + await reportLooseEnvLayerModes({ projectDir: opts.project.projectDir }); if (modern) { return selectProjectEnvValuesForExecutionTarget({ resolved: modern, diff --git a/src/commands/session.ts b/src/commands/session.ts index 429d08bc9..43a901fb1 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -13,6 +13,7 @@ import { } from "../cli/command.ts"; import { resolveRequestedEnvName } from "../cli/env-selection.ts"; import { optEnv, optJson, optPretty } from "../cli/options.ts"; +import { reportLooseEnvLayerModes } from "../lib/env-layer-modes.ts"; import { readHackEnvContract, resolveHackEnv, @@ -1101,6 +1102,8 @@ async function resolveSessionInjectedEnv(opts: { envName: opts.envName, serviceNames, }); + // After a successful read, so a refusal stays one line. + await reportLooseEnvLayerModes({ projectDir: opts.project.projectDir }); if (modern) { return await appendHackHostTrustEnvironment( selectProjectEnvValues({ diff --git a/src/lib/env-layer-modes.ts b/src/lib/env-layer-modes.ts new file mode 100644 index 000000000..1a6872015 --- /dev/null +++ b/src/lib/env-layer-modes.ts @@ -0,0 +1,61 @@ +import { lstat, readdir } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Read-time report of machine-local env files that other users can read + * (HACK-2165). Files created by Hack are private (0600) from creation, and a + * write tightens an existing one; a file that predates that rule, or was + * made by hand, stays loose until then. Commands that read env layers report + * it so the user can act before the next write. + * + * Only names and permission bits are inspected; no file is opened. Each + * path is reported once per process, on stderr, so `--json` output on + * stdout stays valid. + */ + +const LOCAL_LAYER = /^hack\.env(\.[^/]+)?\.local\.yaml$/; +const PLAINTEXT_DOTENV = /^\.env(\.[A-Za-z0-9_-]+)?$/; + +const reported = new Set(); + +/** The warning for one loose file; names the file and its mode, never a value. */ +export function looseEnvLayerWarning(opts: { + readonly name: string; + readonly mode: number; +}): string { + const mode = (opts.mode & 0o777).toString(8).padStart(4, "0"); + return `WARN: .hack/${opts.name} holds machine-local env values and is readable by other users (mode ${mode}). Hack makes it 0600 on its next write; to fix it now, run 'hack doctor --fix' or 'chmod 600 .hack/${opts.name}'.\n`; +} + +/** + * Warns once per process about each machine-local env layer or plaintext + * `.hack/.env*` file in `projectDir` that is a regular file of this user's + * with group or other permission bits. Best effort: an unreadable directory + * reports nothing. + */ +export async function reportLooseEnvLayerModes(opts: { + readonly projectDir: string; + readonly write?: (text: string) => void; +}): Promise { + const write = + opts.write ?? ((text: string) => void process.stderr.write(text)); + const names = await readdir(opts.projectDir).catch(() => [] as string[]); + for (const name of names.sort()) { + if (!(LOCAL_LAYER.test(name) || PLAINTEXT_DOTENV.test(name))) { + continue; + } + const path = join(opts.projectDir, name); + if (reported.has(path)) { + continue; + } + const info = await lstat(path).catch(() => null); + if ( + info?.isFile() && + info.uid === process.getuid?.() && + (info.mode & 0o077) !== 0 + ) { + reported.add(path); + write(looseEnvLayerWarning({ name, mode: info.mode })); + } + } +} diff --git a/src/lib/fs.ts b/src/lib/fs.ts index 081a518ab..1236a3026 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -386,7 +386,8 @@ export class AtomicWriteConflictError extends ClosedCliError { * account belongs to), so keeping group-readable mode bits under a different * group could widen who can read the file. * - * An existing target keeps its permission bits, owner and group, and an + * An existing target keeps its permission bits (less group and other bits + * with `tightenExisting`), owner and group, and an * existing symlink keeps pointing where it did: the replacement lands at the * link's resolved path, as a plain write through the link would. A new file * gets the umask default, or `createMode` (still narrowed by the umask) when @@ -414,6 +415,12 @@ export async function writeTextFileAtomicIfChanged( readonly validatePath?: (path: string) => Promise; /** Permission bits for a file this write creates; an existing file keeps its own. */ readonly createMode?: number; + /** + * Private file: an existing target that changes loses its group and + * other permission bits (a 0644 file becomes 0600); owner and special + * bits are kept. Without it an existing file keeps its mode exactly. + */ + readonly tightenExisting?: boolean; } = {} ): Promise<{ readonly changed: boolean }> { await options.validatePath?.(absolutePath); @@ -423,6 +430,12 @@ export async function writeTextFileAtomicIfChanged( throw new AtomicWriteConflictError(); } if (existing.kind === "existing" && existing.text === content) { + if (options.tightenExisting === true && (existing.mode & 0o077) !== 0) { + await tightenUnchangedTarget({ + path: absolutePath, + validatePath: options.validatePath, + }); + } return { changed: false }; } const target = @@ -456,7 +469,11 @@ export async function writeTextFileAtomicIfChanged( if (existing.kind === "existing") { // chown can clear set-id bits, so the mode is applied after it. await preserveOwnership({ file, existing, path: absolutePath }); - await file.chmod(existing.mode); + await file.chmod( + options.tightenExisting === true + ? existing.mode & ~0o077 + : existing.mode + ); } await file.writeFile(content, "utf8"); await file.sync(); @@ -485,6 +502,28 @@ export async function writeTextFileAtomicIfChanged( return { changed: true }; } +/** + * `tightenExisting` for a write that changes nothing: drops the group and + * other bits of the resolved target in place, through a no-follow handle on + * the path the policy check admitted. The contents are not rewritten. + */ +async function tightenUnchangedTarget(opts: { + readonly path: string; + readonly validatePath?: (path: string) => Promise; +}): Promise { + const target = await realpath(opts.path); + await opts.validatePath?.(target); + const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const info = await file.stat(); + if (info.isFile() && (info.mode & 0o077) !== 0) { + await file.chmod(info.mode & 0o7700); + } + } finally { + await file.close(); + } +} + /** * Creates the staging sibling of an atomic write. A directory the caller * cannot create files in (for example a `.hack` left at mode 0555), a diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 74eed2d79..2ae44ae31 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -3726,8 +3726,9 @@ async function writeProjectEnvConfigFile(opts: { readonly expectedExisting?: string | null; /** * A machine-local layer, which may hold plaintext values: when this write - * creates it, it is private (0600) from creation. An existing layer keeps - * its mode. + * creates it, it is private (0600) from creation, and an existing layer + * that predates that rule (or was made by hand) loses its group and other + * permission bits when this write changes it (HACK-2165). */ readonly machineLocal?: boolean; }): Promise { @@ -3739,7 +3740,9 @@ async function writeProjectEnvConfigFile(opts: { ...(opts.expectedExisting === undefined ? {} : { expectedExisting: opts.expectedExisting }), - ...(opts.machineLocal === true ? { createMode: 0o600 } : {}), + ...(opts.machineLocal === true + ? { createMode: 0o600, tightenExisting: true } + : {}), validatePath: async (path) => await assertProjectEnvLayerPath({ projectRoot: opts.projectRoot, diff --git a/tests/env-layer-modes.test.ts b/tests/env-layer-modes.test.ts new file mode 100644 index 000000000..8addc10ee --- /dev/null +++ b/tests/env-layer-modes.test.ts @@ -0,0 +1,45 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + looseEnvLayerWarning, + reportLooseEnvLayerModes, +} from "../src/lib/env-layer-modes.ts"; + +const dirs: string[] = []; + +afterEach(async () => { + for (const dir of dirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +const SENTINEL = "synthetic-loose-layer-value"; + +test("loose machine-local env files are reported once, by name and mode only (HACK-2165)", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "hack-layer-modes-")); + dirs.push(projectDir); + const files: [string, number][] = [ + ["hack.env.local.yaml", 0o644], + ["hack.env.qa.local.yaml", 0o600], + [".env", 0o640], + [".env.state.json", 0o644], + ["hack.env.default.yaml", 0o644], + ]; + for (const [name, mode] of files) { + await writeFile(join(projectDir, name), `KEY=${SENTINEL}\n`, { mode }); + } + const written: string[] = []; + const write = (text: string) => written.push(text); + await reportLooseEnvLayerModes({ projectDir, write }); + expect(written).toEqual([ + looseEnvLayerWarning({ name: ".env", mode: 0o640 }), + looseEnvLayerWarning({ name: "hack.env.local.yaml", mode: 0o644 }), + ]); + expect(written.join("")).not.toContain(SENTINEL); + expect(written[1]).toContain("mode 0644"); + + await reportLooseEnvLayerModes({ projectDir, write }); + expect(written).toHaveLength(2); +}); diff --git a/tests/env-local-layer-mode.test.ts b/tests/env-local-layer-mode.test.ts index 45eebc34a..c70b7433a 100644 --- a/tests/env-local-layer-mode.test.ts +++ b/tests/env-local-layer-mode.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -106,21 +114,66 @@ test("env hydrate creates a machine-local layer as 0600 under umask 022 (HACK-16 }); }); -test("an existing machine-local layer keeps its own mode (HACK-1680)", async () => { +// HACK-1680 kept an existing layer's mode; HACK-2165 tightens one that +// predates private creation (or was made by hand) on its next write. +test.each([ + [0o644, 0o600], + [0o640, 0o600], + [0o600, 0o600], + [0o700, 0o700], +])("an existing machine-local layer at %o is %o after a write (HACK-2165)", async (before, after) => { const repo = await layersRepo(); const layer = resolve(repo.projectDir, "hack.env.local.yaml"); await writeFile(layer, LAYER_TEXT); - await chmod(layer, 0o640); + await chmod(layer, before); await withUmask(0o022, async () => { await setProjectEnvValue({ ...repo, envName: null, scope: "global", - key: "KEEP_MODE", - value: "synthetic-keep-mode", + key: "TIGHTEN_MODE", + value: "synthetic-tighten-mode", + secret: false, + local: true, + }); + }); + expect((await stat(layer)).mode & 0o777).toBe(after); +}); + +test("a write that changes nothing still tightens a loose machine-local layer (HACK-2165)", async () => { + const repo = await layersRepo(); + const layer = resolve(repo.projectDir, "hack.env.local.yaml"); + const write = () => + setProjectEnvValue({ + ...repo, + envName: null, + scope: "global", + key: "SAME_VALUE", + value: "synthetic-same-value", secret: false, local: true, }); + await write(); + await chmod(layer, 0o644); + const before = await readFile(layer, "utf8"); + await write(); + expect((await stat(layer)).mode & 0o777).toBe(0o600); + expect(await readFile(layer, "utf8")).toBe(before); +}); + +test("a tracked (shared) layer keeps its mode on a write", async () => { + const repo = await layersRepo(); + const layer = resolve(repo.projectDir, "hack.env.yaml"); + await writeFile(layer, LAYER_TEXT); + await chmod(layer, 0o644); + await setProjectEnvValue({ + ...repo, + envName: null, + scope: "global", + key: "SHARED_MODE", + value: "synthetic-shared-mode", + secret: false, + local: false, }); - expect((await stat(layer)).mode & 0o777).toBe(0o640); + expect((await stat(layer)).mode & 0o777).toBe(0o644); }); From a0753fd2da7d2c85a73b6dc229e1188b4c6afbf2 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:49:58 +0000 Subject: [PATCH 03/42] fix(mux): expire env payloads staged for sessions that never ran (HACK-2165) Lead item 3: values staged in $TMPDIR/hack-mux-env-*/env.json stayed there when the pane never ran its command. A payload now expires after ten minutes: the pane refuses and removes an expired one, the staging process removes its payload when the limit passes (if it still runs), and the next staging sweeps this user's older payloads. Tests stage in a private directory, never the host's temporary directory. Co-Authored-By: Claude --- src/mux/private-environment.ts | 102 +++++++++++++++++++++++++- src/mux/tmux-backend.ts | 8 +- tests/mux-private-environment.test.ts | 70 +++++++++++++++++- 3 files changed, 173 insertions(+), 7 deletions(-) diff --git a/src/mux/private-environment.ts b/src/mux/private-environment.ts index ab92d48e5..b4bfe9b49 100644 --- a/src/mux/private-environment.ts +++ b/src/mux/private-environment.ts @@ -3,6 +3,7 @@ import { lstat, mkdtemp, open, + readdir, realpath, rmdir, unlink, @@ -14,10 +15,101 @@ const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; const PREFIX = "hack-mux-env-"; const MAX_BYTES = 1024 * 1024; +/** + * How long a staged payload stays usable (HACK-2165). A pane that has not + * consumed it by then refuses it; the staging process removes it when this + * elapses, and the next staging (or `hack doctor --fix`) sweeps any older + * payload a process left behind, so values never outlive a session that + * never ran. + */ +export const MUX_ENV_TTL_MS = 10 * 60_000; + +function isMissing(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +/** A staged payload directory older than the time limit. */ +export type StaleMuxEnvironment = { + readonly root: string; + readonly dev: number; + readonly ino: number; +}; + +/** + * Payload directories under `tmpDir` (default: the OS temporary directory) + * that this user staged and that are older than {@link MUX_ENV_TTL_MS}. Only + * private directories with Hack's prefix and owned by this user qualify; + * nothing is read. + */ +export async function findStaleMuxEnvironments(opts?: { + readonly tmpDir?: string; + readonly now?: number; +}): Promise { + const base = opts?.tmpDir ?? tmpdir(); + const now = opts?.now ?? Date.now(); + const names = await readdir(base).catch(() => [] as string[]); + const stale: StaleMuxEnvironment[] = []; + for (const name of names.filter((entry) => entry.startsWith(PREFIX))) { + const root = join(base, name); + const info = await lstat(root).catch(() => null); + if ( + info?.isDirectory() && + info.uid === process.getuid?.() && + (info.mode & 0o077) === 0 && + now - info.mtimeMs > MUX_ENV_TTL_MS + ) { + stale.push({ root, dev: info.dev, ino: info.ino }); + } + } + return stale; +} + +/** + * Removes a stale payload directory while it is still the one found: its + * `env.json` (a regular file of this user's) and then the directory. Anything + * else inside it is left, and so is the directory. + */ +export async function removeStaleMuxEnvironment( + stale: StaleMuxEnvironment +): Promise { + const info = await lstat(stale.root).catch(() => null); + if ( + !info?.isDirectory() || + info.dev !== stale.dev || + info.ino !== stale.ino + ) { + return false; + } + const path = join(stale.root, "env.json"); + const file = await lstat(path).catch(() => null); + if (file?.isFile() && file.uid === process.getuid?.()) { + await unlink(path).catch((error: unknown) => { + if (!isMissing(error)) { + throw error; + } + }); + } + return await rmdir(stale.root).then( + () => true, + () => false + ); +} + /** The pane consumes and deletes this one-shot, owner-only payload before spawning. */ export async function stageMuxEnvironment( - env: Readonly> + env: Readonly>, + opts?: { + /** Parent of the payload directory; the OS temporary directory by default. */ + readonly tmpDir?: string; + } ) { + const base = opts?.tmpDir ?? tmpdir(); + // Best effort: payloads of sessions that never ran. + for (const stale of await findStaleMuxEnvironments({ tmpDir: base }).catch( + () => [] + )) { + await removeStaleMuxEnvironment(stale).catch(() => false); + } if ( Object.entries(env).some( ([key, value]) => !KEY.test(key) || value.includes("\0") @@ -29,7 +121,7 @@ export async function stageMuxEnvironment( if (Buffer.byteLength(bytes) > MAX_BYTES) { throw new Error("Mux environment exceeds the private input limit"); } - const root = await realpath(await mkdtemp(join(tmpdir(), PREFIX))); + const root = await realpath(await mkdtemp(join(base, PREFIX))); const path = join(root, "env.json"); let identity: { dev: number; ino: number } | undefined; const cleanup = async () => { @@ -115,6 +207,12 @@ export async function runMuxEnvironment( ) { return 1; } + if (Date.now() - info.mtimeMs > MUX_ENV_TTL_MS) { + // Expired: never deliver it; remove it like a consumed payload. + await unlink(path).catch(() => undefined); + await rmdir(root).catch(() => undefined); + return 1; + } const bytes = Buffer.alloc(MAX_BYTES + 1); const { bytesRead } = await file.read(bytes, 0, bytes.length, 0); if (bytesRead !== info.size || bytesRead > MAX_BYTES) { diff --git a/src/mux/tmux-backend.ts b/src/mux/tmux-backend.ts index 3604f11cd..a40e16e0d 100644 --- a/src/mux/tmux-backend.ts +++ b/src/mux/tmux-backend.ts @@ -8,7 +8,7 @@ import { type MuxSessionCreateResult, runMuxCommand, } from "./mux-backend.ts"; -import { stageMuxEnvironment } from "./private-environment.ts"; +import { MUX_ENV_TTL_MS, stageMuxEnvironment } from "./private-environment.ts"; const LIFECYCLE_OWNER_OPTION = "@hack_lifecycle_owner"; const LIFECYCLE_OWNER_ENV = "HACK_LIFECYCLE_OWNER_TOKEN"; @@ -331,6 +331,12 @@ export function createTmuxBackend(): MuxBackend { ]); if (result.exitCode !== 0) { await privateInput?.cleanup(); + } else if (privateInput) { + // A pane that never runs the command must not keep the values: remove + // the payload once its time limit passes, if this process still runs. + setTimeout(() => { + privateInput.cleanup().catch(() => undefined); + }, MUX_ENV_TTL_MS).unref(); } return result; } catch (error) { diff --git a/tests/mux-private-environment.test.ts b/tests/mux-private-environment.test.ts index b01a79f83..e03804ed8 100644 --- a/tests/mux-private-environment.test.ts +++ b/tests/mux-private-environment.test.ts @@ -1,25 +1,41 @@ -import { expect, test } from "bun:test"; +import { afterAll, expect, test } from "bun:test"; import { spawn } from "node:child_process"; import { chmod, lstat, mkdtemp, + readdir, readFile, rm, symlink, unlink, + utimes, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { + findStaleMuxEnvironments, + MUX_ENV_TTL_MS, + removeStaleMuxEnvironment, runMuxEnvironment, stageMuxEnvironment, } from "../src/mux/private-environment.ts"; +/** + * Payloads are staged in a private directory of this file, never the host's + * temporary directory, so the stale-payload sweep cannot reach real state. + */ +const STAGE_DIR = await mkdtemp(join(tmpdir(), "mux-env-stage-")); +afterAll(async () => { + await rm(STAGE_DIR, { recursive: true, force: true }); +}); +const stage = (env: Readonly>) => + stageMuxEnvironment(env, { tmpDir: STAGE_DIR }); + test("private mux payload is owner-only, consumed before spawn, and preserves assignments", async () => { const root = await mkdtemp(join(tmpdir(), "mux-env-test-")); - const payload = await stageMuxEnvironment({ + const payload = await stage({ UID: "fake-uid", NODE_ENV: "selected", FAKE_KEY: "fake-key", @@ -52,7 +68,7 @@ test("private mux payload is owner-only, consumed before spawn, and preserves as test("private mux input refuses public, malformed, oversized, and symlinked payloads", async () => { for (const kind of ["public", "malformed", "oversized", "symlink"] as const) { - const payload = await stageMuxEnvironment({ UID: "fake-uid" }); + const payload = await stage({ UID: "fake-uid" }); const foreign = join(dirname(payload.path), "foreign"); try { if (kind === "public") { @@ -97,7 +113,7 @@ test("private mux input refuses public, malformed, oversized, and symlinked payl */ test("the launcher waits through a group SIGINT that the command handles", async () => { const root = await mkdtemp(join(tmpdir(), "mux-env-signal-")); - const payload = await stageMuxEnvironment({ FAKE_KEY: "fake-key" }); + const payload = await stage({ FAKE_KEY: "fake-key" }); try { const ready = join(root, "ready"); const done = join(root, "done"); @@ -138,3 +154,49 @@ test("the launcher waits through a group SIGINT that the command handles", async await rm(root, { recursive: true, force: true }); } }, 30_000); + +async function expire(path: string): Promise { + const old = (Date.now() - MUX_ENV_TTL_MS - 60_000) / 1000; + await utimes(path, old, old); +} + +test("an expired payload is refused and removed, never delivered (HACK-2165)", async () => { + const payload = await stage({ FAKE_KEY: "synthetic-expired" }); + await expire(payload.path); + expect(await runMuxEnvironment(payload.path, ["true"])).toBe(1); + expect(await lstat(dirname(payload.path)).catch(() => null)).toBeNull(); +}); + +test("the next staging sweeps payloads of sessions that never ran; recent ones stay (HACK-2165)", async () => { + const dir = await mkdtemp(join(tmpdir(), "mux-env-sweep-")); + try { + const stale = await stageMuxEnvironment( + { FAKE_KEY: "synthetic-stale" }, + { tmpDir: dir } + ); + const recent = await stageMuxEnvironment( + { FAKE_KEY: "synthetic-recent" }, + { tmpDir: dir } + ); + await expire(dirname(stale.path)); + const found = await findStaleMuxEnvironments({ tmpDir: dir }); + expect(found.map((entry) => entry.root)).toEqual([dirname(stale.path)]); + + const next = await stageMuxEnvironment( + { FAKE_KEY: "synthetic-next" }, + { tmpDir: dir } + ); + const left = (await readdir(dir)).sort(); + expect(left).toEqual( + [dirname(recent.path), dirname(next.path)] + .map((path) => path.slice(path.lastIndexOf("/") + 1)) + .sort() + ); + // A directory that changed identity is never removed. + expect(await removeStaleMuxEnvironment(found[0]!)).toBe(false); + await recent.cleanup(); + await next.cleanup(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); From 0c94108450bc697f3af4ba3a9a760c2d1ed405c4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:49:58 +0000 Subject: [PATCH 04/42] feat(doctor): secrets hygiene section with safe --fix (HACK-2165) Lead items 2 and 5 and the migration path: `hack doctor` reports, for the project and the global Hack home, secret-bearing files (local layers, .hack/.env files, project keys, encrypted store and key) that are loose, linked, owned by another user, tracked, or unignored; staging copies left by interrupted writes; terminal-session payloads that never ran; and "store present, key missing, cannot be read". `hack doctor --fix` tightens modes, adds missing ignore entries and removes Hack's own leftovers, printing each change; it never deletes a store, a key or a user's file, and never untracks. A doctor hint no longer suggests a secret value in argv. Docs: the env guide, the threat model, and an "after upgrading" step in the v5 upgrade guide. Co-Authored-By: Claude --- docs/env.md | 27 ++ docs/guides/upgrade-to-v5.md | 24 + docs/security/env-secrets-threat-model.md | 4 +- src/commands/doctor.ts | 137 +++++- src/lib/secret-store.ts | 25 ++ src/lib/secrets-hygiene.ts | 522 ++++++++++++++++++++++ tests/secrets-hygiene.test.ts | 250 +++++++++++ 7 files changed, 986 insertions(+), 3 deletions(-) create mode 100644 src/lib/secrets-hygiene.ts create mode 100644 tests/secrets-hygiene.test.ts diff --git a/docs/env.md b/docs/env.md index 8a7702926..b1f08d140 100644 --- a/docs/env.md +++ b/docs/env.md @@ -422,6 +422,33 @@ or `docker compose down` run, so a failed hook, a remote target or a failed Comp 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. +### Secrets at rest: the "secrets hygiene" check + +`hack doctor` checks the files that hold env values or keys, for the project and for the +global Hack home, in its "secrets hygiene" section. It names files, never values: + +- machine-local layers (`hack.env.local.yaml`, `hack.env..local.yaml`), + `.hack/.env` files, the project key (checkout-local and shared) and the encrypted + store and its key, when other users can read them, when they are links, or when they + belong to another user; +- those files when git tracks them, or (inside the project) when git does not ignore + them; +- staging copies an interrupted write left behind, and env values staged for a terminal + session that never ran (older than 10 minutes); +- an encrypted store whose key file is missing: "store present, key missing, cannot be + read". + +`hack doctor --fix` sets the files it reports to mode 0600, adds missing ignore +entries, and removes Hack's own leftovers, and prints each change. It never deletes a +store, a key or a file you wrote, and never untracks a file. + +Hack writes a file that holds a secret by staging the complete contents first. Those +staging copies live in a private directory next to the file, `.hack-staging/`, which +holds a `.gitignore` of `*`, so git never lists anything in it; the next write there +removes copies older than 10 minutes. A machine-local layer that a write changes, or +leaves unchanged, is set to 0600 if other users could read it. Commands that read env +layers warn on stderr about a machine-local file other users can read. + 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: diff --git a/docs/guides/upgrade-to-v5.md b/docs/guides/upgrade-to-v5.md index e9a89070f..ffe22f4c3 100644 --- a/docs/guides/upgrade-to-v5.md +++ b/docs/guides/upgrade-to-v5.md @@ -64,6 +64,30 @@ repository but are unsupported; see [unsupported retained surfaces](../extension When 5.0.0 ships, `brew upgrade hack` or the release installer moves the stable executable to v5. Projects need no changes to keep running on Compose. +## After upgrading, check secrets at rest + +Earlier versions could leave env values and keys more exposed than v5 does: +machine-local env files created by hand or by an older release with mode 0644, +staging copies of `.hack/.env` or the project key under names git does not +ignore, env values staged for a terminal session that never ran, or an +encrypted secret store whose key file is gone. After upgrading, run this once +in each project, and once outside any project for the global Hack home: + +```bash +hack doctor # read the "secrets hygiene" section +hack doctor --fix # apply its safe fixes +``` + +The "secrets hygiene" section names each file and what it holds, never a value. +`hack doctor --fix` sets loose env layers, `.hack/.env` files, keys and stores to +mode 0600, adds a missing ignore entry for a secret-bearing file, and removes +staging copies and terminal-session payloads that Hack left behind. It prints +each change. It never deletes a store, a key or a file you wrote, and it does not +untrack a file git already tracks: the section names the `git rm --cached` +command, and what that file held should be treated as exposed. A store reported +as "store present, key missing, cannot be read" stays where it is: restore its +key file to read it, or remove the store yourself once you no longer need it. + ## The daemon after an upgrade Installing 5.0.0 over a running 4.x does not replace the daemon. On Linux the old diff --git a/docs/security/env-secrets-threat-model.md b/docs/security/env-secrets-threat-model.md index 13a24130d..0f6fd8a9c 100644 --- a/docs/security/env-secrets-threat-model.md +++ b/docs/security/env-secrets-threat-model.md @@ -74,8 +74,8 @@ refer to `hack-dance/hack`. | --- | --- | | A repository's contract names a variable exported in the developer's shell, so the migration saves it to disk. | `migrateLegacyProjectEnv` refuses, naming the key only, when a `plain_env` value resolves from the process environment (HACK-1688). A value in a regular `.hack/.env` migrates to the gitignored machine-local layer, never a tracked one (HACK-1712). | | A repository plants `.hack.secret.key` as a symlink (dangling or not) so that Hack reads or writes a file it should not. | Key reads go through `readBoundedPrivateFile` (`lstat`, `O_NOFOLLOW`, descriptor and path identity checks, 1 MiB bound, fatal UTF-8). New keys are created with `createPrivateFileExclusive` (`O_CREAT|O_EXCL|O_NOFOLLOW`, mode 0600, `fchmod`, `fsync`). Both are in `src/lib/private-file.ts`, used by `resolveProjectEnvKey`, `ensureProjectEnvSecretKey` and `resolveInheritedProjectEnvKey` in `src/lib/project-env-config.ts` (#288). | -| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private sibling is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by `src/lib/hack-env.ts` and `materializeProjectEnv`. | -| A world-readable env file or store. | `.hack/.env`, overlays and the `encrypted_file` store are written 0600, and a same-content file with group or other access is tightened (#302). The `hack doctor` env key check reports `env-key-weak-permissions` and `env-key-wrong-owner` (#309). | +| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private staging file is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by `src/lib/hack-env.ts` and `materializeProjectEnv`. Staging copies of these files and of keys live in `.hack-staging/` next to the target, a private (0700) directory whose `.gitignore` of `*` keeps git from listing a copy a crash leaves; the next write there sweeps copies older than 10 minutes (`src/lib/private-staging.ts`, HACK-2165). | +| A world-readable env file or store. | `.hack/.env`, overlays and the `encrypted_file` store are written 0600, and a same-content file with group or other access is tightened (#302). Machine-local layers are created 0600, and a write, even one that changes nothing, drops the group and other bits of an existing one; commands that read layers warn about a loose one (HACK-2165). The `hack doctor` env key check reports `env-key-weak-permissions` and `env-key-wrong-owner` (#309), and its "secrets hygiene" section reports loose, linked, tracked or unignored secret-bearing files, staging leftovers, stale terminal-session payloads and a store whose key is missing; `--fix` tightens modes, adds ignore entries and removes Hack's own leftovers, never a store, key or user file (`src/lib/secrets-hygiene.ts`, HACK-2165). | | The Compose backend leaves decrypted values at rest in `.hack/.internal/compose.env.override.yml` after `up`, `down`, a failed `up` or an interrupted one. | The override holds only `${HACK_ENV__}` references; each value reaches `docker compose` through its process environment, so no Compose command writes a value to disk, whatever way it ends (`renderComposeEnvReferenceOverride` in `src/lib/compose-managed-env.ts`). The file keeps its path because Compose records it in each container's `config_files` label. It is written with `replacePrivateFileAtomic` (mode 0600, symlink refused), and a literal value is refused before any write (`writeComposeEnvOverride` in `src/lib/compose-env-at-rest.ts`). A file left by an earlier version is replaced by the next Compose command that renders the override, removed by one that renders none and by `hack down` (before its hooks, remote target and Compose down, so none of them can skip it), and reported by the `hack doctor` "compose env at rest" check by counts only; `hack doctor --fix` removes it. Removal moves the file aside atomically and classifies what it moved, so a value-free file written concurrently is put back rather than deleted (the path is absent for that moment; see gap 4). An aside left by a crash between the move and the delete is reported by the doctor check by count and removed by a later removal, `hack doctor --fix` or a Compose command that renders the override, once it is more than a minute old. The doctor check, every removal and the aside sweep apply the writer's no-follow check to `.hack` and `.hack/.internal` first (and recheck them before each move or delete): a symlinked or non-directory `.hack` or `.hack/.internal`, or a symlinked or non-regular override, is a closed, value-free refusal, and nothing behind it is listed, read, moved or deleted (HACK-1316). Legacy adoption gives the reference values only to its Compose config-hash probe at preview and preparation; a saved recheck runs without values and binds each container's config-hash label to the hash proven at preparation. `hack config adopt` (with or without `--stop`) and its preview refuse a literal override before preparation, because an adoption pins the file's bytes and inode for its life. Recovery acts on an existing preparation, so the store refuses instead: publication (`--recover --stop`) and switch completion (`--recover`) refuse a generation bound to a literal override before any original moves, and name `hack config adopt --recover --rollback` (HACK-1318). Rollback stays allowed. While a prepared, active or pending adoption exists, no removal touches the file (removal would make the adoption unloadable); an adoption that an earlier version made over a literal file therefore keeps its values at rest until `hack config adopt --rollback`, and the doctor check names that next step (see gap 4). The per-service `HACK_ENV_*` values go only to `docker compose` invocations that read the override; the `docker inspect`, `logs` and `events` children for dependency cache progress and reuse get the environment without them (`withoutComposeEnvReferences`). Proven with a stub `docker` by `tests/compose-env-at-rest-cli.test.ts`, `tests/compose-env-at-rest.test.ts`, `tests/compose-env-reference-least-privilege.test.ts`, `tests/doctor-fix-noninteractive.test.ts`, `tests/native-compose-adoption-generation-managed.test.ts`, `tests/native-compose-adoption-generation-recovery.test.ts` and the sentinel scan `tests/env-secret-sentinel-scan.test.ts` (HACK-1273, HACK-1318). | | Two writers of the `encrypted_file` store lose an update. | An in-process queue plus a cooperative `.lock` using the owner protocol from `src/lib/projects-registry-lock.ts`; a symlinked store path is refused for writes (`src/lib/secret-store.ts`, #302). | | Concurrent first users of the `encrypted_file` store each generate a key; the last key file wins and the store, already encrypted under another key, can no longer be decrypted. | The generated key is published with `publishPrivateFileExclusive` (`src/lib/private-file.ts`): a fsynced 0600 sibling is linked into place only when no key file exists, so losers adopt the published key. A store found non-empty after the first key read re-reads the key file before the keychain fallback. Proven across eight worker processes by `tests/secret-key-provisioning-convergence.test.ts`. | diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3aa81d8f3..4a785481b 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -240,6 +240,7 @@ import { removeLegacyProjectEnvArtifacts, repairLegacyComposeEnvFileReferences, resolveProjectEnvConfig, + resolveProjectEnvSharedKeyPath, type TrackedEnvMigrationChange, } from "../lib/project-env-config.ts"; import { @@ -255,6 +256,13 @@ import { import { readProjectsRegistry } from "../lib/projects-registry.ts"; import { resolveProxyBindAddress } from "../lib/proxy-bind.ts"; import { readRuntimeProjects } from "../lib/runtime-projects.ts"; +import { resolveFileSecretStorePaths } from "../lib/secret-store.ts"; +import { + applySecretsHygieneFixes, + inspectSecretsHygiene, + type SecretsHygieneFinding, + type SecretsHygieneScope, +} from "../lib/secrets-hygiene.ts"; import { exec, findExecutableInPath, run } from "../lib/shell.ts"; import { type PausableProgress, @@ -3114,7 +3122,7 @@ async function checkProjectEnvOverlayWarnings({ return { name: "env overlay warnings", status: "warn", - message: `defaultEnvConfig=${cfg.defaultEnvConfig} contains plaintext entries for secret-backed vars (${keys}). Those overlay values are ignored; use "hack doctor --migrate-env-config" or "hack env add --env ${cfg.defaultEnvConfig} --secret KEY VALUE".`, + message: `defaultEnvConfig=${cfg.defaultEnvConfig} contains plaintext entries for secret-backed vars (${keys}). Those overlay values are ignored; use "hack doctor --migrate-env-config" or " | hack env add --env ${cfg.defaultEnvConfig} KEY --secret --value-stdin".`, }; } @@ -3559,6 +3567,87 @@ export async function checkHackDirGitignore({ } } +const SECRETS_HYGIENE_CHECK = "secrets hygiene"; + +/** + * What the "secrets hygiene" section covers from `startDir`: the project, when + * there is a legacy (not adopted) one, its shared key, and the encrypted-file + * stores the global and project configuration name. Reads configuration and + * git only. + */ +async function resolveSecretsHygieneScope(opts: { + readonly startDir: string; +}): Promise { + const project = await findDoctorLegacyProjectContext(opts.startDir).catch( + () => null + ); + const stores = new Map(); + for (const projectDir of [undefined, project?.projectDir]) { + const paths = await resolveFileSecretStorePaths({ projectDir }).catch( + () => null + ); + if (paths) { + stores.set(`${paths.storePath}\0${paths.keyPath}`, paths); + } + } + return { + project: project + ? { projectRoot: project.projectRoot, projectDir: project.projectDir } + : null, + sharedKeyPath: project + ? await resolveProjectEnvSharedKeyPath({ + projectRoot: project.projectRoot, + }).catch(() => null) + : null, + stores: [...stores.values()], + storeKeyFromEnvironment: + (process.env.HACK_SECRETS_FILE_KEY ?? "").trim().length > 0, + }; +} + +/** + * Secret-bearing files at rest for the project and the global Hack home + * (HACK-2165): loose modes, links, tracked or unignored files, leftover + * staging copies and terminal-session payloads, and a store whose key is + * missing. File names only, never values; `hack doctor --fix` applies the + * safe fixes (see `secrets-hygiene.ts`). + */ +export async function checkSecretsHygiene(opts: { + readonly startDir: string; +}): Promise { + const findings = await inspectSecretsHygiene( + await resolveSecretsHygieneScope(opts) + ); + return { name: SECRETS_HYGIENE_CHECK, ...describeSecretsHygiene(findings) }; +} + +function describeSecretsHygiene(findings: readonly SecretsHygieneFinding[]): { + readonly status: "ok" | "warn" | "error"; + readonly message: string; +} { + if (findings.length === 0) { + return { + status: "ok", + message: + "Env files, keys and stores are private, ignored by git, and no staging copies are left behind", + }; + } + const fixable = findings.some((finding) => finding.fix !== null); + return { + status: findings.some((finding) => finding.status === "error") + ? "error" + : "warn", + message: [ + ...findings.map((finding) => finding.message), + ...(fixable + ? [ + "Run 'hack doctor --fix' to tighten modes, add the ignore entries and remove Hack's leftovers; it never deletes a store, a key or a file you wrote.", + ] + : []), + ].join("\n"), + }; +} + const COMPOSE_ENV_AT_REST_CHECK = "compose env at rest"; /** @@ -3652,6 +3741,15 @@ async function runProjectChecks(opts: { message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, durationMs: 0, }); + // The global Hack home is covered without a project. + results.push( + await runCheck( + s, + SECRETS_HYGIENE_CHECK, + () => checkSecretsHygiene({ startDir }), + { timeoutMs: 10_000 } + ) + ); return results; } if (dockerOk) { @@ -3730,6 +3828,14 @@ async function runProjectChecks(opts: { checkComposeEnvOverrideAtRest({ startDir }) ) ); + results.push( + await runCheck( + s, + SECRETS_HYGIENE_CHECK, + () => checkSecretsHygiene({ startDir }), + { timeoutMs: 10_000 } + ) + ); if (dockerOk) { results.push( await runCheck( @@ -3783,6 +3889,12 @@ async function runAdoptedProjectChecks(opts: { await runCheck(progress, COMPOSE_ENV_AT_REST_CHECK, () => checkComposeEnvOverrideAtRest({ startDir: opts.startDir }) ), + await runCheck( + progress, + SECRETS_HYGIENE_CHECK, + () => checkSecretsHygiene({ startDir: opts.startDir }), + { timeoutMs: 10_000 } + ), ...adoptedProjectNotApplicableResults().map((result) => ({ ...result, durationMs: 0, @@ -4041,6 +4153,7 @@ async function runDoctorFix(opts: { await maybeEnsureHackDirGitignore({ startDir: opts.startDir }); } await maybeRemovePlaintextComposeEnvOverride({ startDir: opts.startDir }); + await maybeFixSecretsHygiene({ startDir: opts.startDir }); const serviceWrites = await maybeInstallDaemonService(); await maybeRepairHackd({ serviceWrites }); // Env migration changes project files only, so it runs before the Docker @@ -4867,6 +4980,28 @@ async function maybeUntrackGeneratedFiles(opts: { * never write it, and `hack init` refuses or rescaffolds an existing project. * Lines outside the managed markers are kept. An unreadable file is left alone. */ +/** + * `hack doctor --fix` step: applies the "secrets hygiene" fixes and prints + * each change. Safe by construction (modes, ignore entries, Hack's own + * leftovers), so it needs no confirmation of its own. + */ +async function maybeFixSecretsHygiene(opts: { + readonly startDir: string; +}): Promise { + const findings = await inspectSecretsHygiene( + await resolveSecretsHygieneScope(opts) + ); + if (!findings.some((finding) => finding.fix !== null)) { + return; + } + const lines: string[] = []; + await applySecretsHygieneFixes({ + findings, + report: (line) => lines.push(line), + }); + note(lines.join("\n"), SECRETS_HYGIENE_CHECK); +} + async function maybeEnsureHackDirGitignore(opts: { readonly startDir: string; }): Promise { diff --git a/src/lib/secret-store.ts b/src/lib/secret-store.ts index 8d774a1c3..3a0f0f497 100644 --- a/src/lib/secret-store.ts +++ b/src/lib/secret-store.ts @@ -1190,3 +1190,28 @@ function buildEncryptedBackendKeyRecoveryMessage(input: { return `Missing encrypted backend key${location}. ${recovery} Hack will not fall back to plaintext .hack/.env or ambient process env.`; } + +/** + * The encrypted-file store and key paths the configuration names (global + * config, overridden by the project's when `projectDir` is given), resolved + * the way the store resolves them. Reads configuration only: never the + * store, the key or the keychain. Used by `hack doctor`'s secrets hygiene. + */ +export async function resolveFileSecretStorePaths(input: { + readonly projectDir?: string; +}): Promise<{ readonly storePath: string; readonly keyPath: string }> { + const controlPlane = await readControlPlaneConfig({ + projectDir: input.projectDir, + }); + const configured = controlPlane.config.secrets.encryptedFile; + return { + storePath: resolveConfiguredPath({ + path: configured.path, + projectDir: input.projectDir, + }), + keyPath: resolveConfiguredPath({ + path: configured.keyPath ?? DEFAULT_ENCRYPTED_FILE_KEY_PATH, + projectDir: input.projectDir, + }), + }; +} diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts new file mode 100644 index 000000000..ef5554ab3 --- /dev/null +++ b/src/lib/secrets-hygiene.ts @@ -0,0 +1,522 @@ +import { constants, type Stats } from "node:fs"; +import { lstat, open, readdir } from "node:fs/promises"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { PROJECT_ENV_KEY_FILENAME } from "../constants.ts"; +import { + findStaleMuxEnvironments, + MUX_ENV_TTL_MS, + removeStaleMuxEnvironment, + type StaleMuxEnvironment, +} from "../mux/private-environment.ts"; +import { ensureGitignoreEntry } from "./fs.ts"; +import { isKeychainDisabled } from "./keychain.ts"; +import { + findStagingLeftovers, + legacyStagingTargetName, + removeStagingLeftover, + type StagingLeftover, +} from "./private-staging.ts"; +import { + isGitIgnoredProjectEnvPath, + isGitTrackedOrUnknownProjectPath, +} from "./project-env-config.ts"; + +/** + * `hack doctor`'s "secrets hygiene" section (HACK-2165): files at rest that + * hold env values or keys, for a project and for the global Hack home. + * + * It reports loose permission modes, links, tracked or unignored + * secret-bearing files, staging copies left by interrupted writes, payloads + * staged for terminal sessions that never ran, and an encrypted store whose + * key file is missing. Findings name files, never values; nothing is read + * except directory listings, file metadata and git's answers. + * + * Ownership of fixes: `--fix` only tightens modes of this user's regular + * files, adds missing ignore entries, and removes Hack's own leftovers (by + * their staging names, old enough, still the file that was found). It never + * deletes a store, a key or a file a user wrote. + */ + +/** What a secret-bearing file holds, for messages. */ +type Holds = + | "machine-local env values" + | "plaintext env values" + | "the project env key" + | "an encrypted secret store" + | "an encrypted store key"; + +type Candidate = { + readonly path: string; + readonly holds: Holds; + /** + * Inside a checkout: the ignore file and entry that keep it out of git. + * Null for files outside any project tree (git common dir, Hack home). + */ + readonly ignore: { + readonly projectRoot: string; + readonly gitignorePath: string; + readonly entry: string; + } | null; +}; + +export type SecretsHygieneFix = + | { + readonly kind: "chmod"; + readonly path: string; + readonly stats: Stats; + readonly mode: number; + } + | { + readonly kind: "ignore"; + readonly gitignorePath: string; + readonly entry: string; + } + | { readonly kind: "remove_leftover"; readonly leftover: StagingLeftover } + | { readonly kind: "remove_mux"; readonly stale: StaleMuxEnvironment }; + +export type SecretsHygieneFinding = { + readonly status: "warn" | "error"; + readonly path: string; + /** Value-free; names the file and what it holds. */ + readonly message: string; + readonly fix: SecretsHygieneFix | null; +}; + +/** One file or directory the inspection covers, labelled for messages. */ +export type SecretsHygieneScope = { + /** The project's root and its `.hack` directory, or null for none. */ + readonly project: { + readonly projectRoot: string; + readonly projectDir: string; + } | null; + /** The shared key in the git common directory, when there is one. */ + readonly sharedKeyPath: string | null; + /** Configured encrypted-file stores (project and global), de-duplicated. */ + readonly stores: readonly { + readonly storePath: string; + readonly keyPath: string; + }[]; + /** Parent of terminal-session payloads; the OS temporary directory. */ + readonly tmpDir?: string; + /** Whether `HACK_SECRETS_FILE_KEY` supplies the store key. */ + readonly storeKeyFromEnvironment: boolean; + readonly now?: number; +}; + +const LOCAL_LAYER = /^hack\.env(\.[^/]+)?\.local\.yaml$/; +const PLAINTEXT_DOTENV = /^\.env(\.[A-Za-z0-9_-]+)?$/; +const HACK_DIR_STAGED = + /^(\.env(\.[A-Za-z0-9_-]+)?|hack\.env\..*yaml|\.gitignore)$/; + +function octal(mode: number): string { + return (mode & 0o777).toString(8).padStart(4, "0"); +} + +function shown(path: string, project: SecretsHygieneScope["project"]): string { + if (!project) { + return path; + } + const rel = relative(project.projectRoot, path); + return rel.startsWith("..") ? path : rel; +} + +/** What a file in `.hack` holds, or null when it holds no env values. */ +function holdsForProjectFile(name: string): Holds | null { + if (LOCAL_LAYER.test(name)) { + return "machine-local env values"; + } + if (PLAINTEXT_DOTENV.test(name)) { + return "plaintext env values"; + } + return null; +} + +async function projectCandidates( + project: NonNullable +): Promise { + const { projectRoot, projectDir } = project; + const hackGitignore = resolve(projectDir, ".gitignore"); + const names = await readdir(projectDir).catch(() => [] as string[]); + const candidates: Candidate[] = names.flatMap((name): Candidate[] => { + const holds = holdsForProjectFile(name); + return holds + ? [ + { + path: join(projectDir, name), + holds, + ignore: { projectRoot, gitignorePath: hackGitignore, entry: name }, + }, + ] + : []; + }); + candidates.push({ + path: join(projectRoot, PROJECT_ENV_KEY_FILENAME), + holds: "the project env key", + ignore: { + projectRoot, + gitignorePath: join(projectRoot, ".gitignore"), + entry: PROJECT_ENV_KEY_FILENAME, + }, + }); + return candidates; +} + +/** Mode, link and ownership findings for one existing file. */ +function fileFindings(opts: { + readonly candidate: Candidate; + readonly stats: Stats; + readonly name: string; +}): SecretsHygieneFinding[] { + const { candidate, stats, name } = opts; + if (stats.isSymbolicLink()) { + return [ + { + status: "error", + path: candidate.path, + message: `${name} is a symbolic link; Hack refuses to read or write ${candidate.holds} through a link. Replace it with a regular file.`, + fix: null, + }, + ]; + } + if (!stats.isFile()) { + return []; + } + if (stats.uid !== process.getuid?.()) { + return [ + { + status: "warn", + path: candidate.path, + message: `${name} holds ${candidate.holds} but is owned by another user (uid ${stats.uid}); Hack leaves it unchanged.`, + fix: null, + }, + ]; + } + if ((stats.mode & 0o077) === 0) { + return []; + } + const mode = stats.mode & 0o7700; + return [ + { + status: "warn", + path: candidate.path, + message: `${name} holds ${candidate.holds} and is readable or writable by other users (mode ${octal(stats.mode)}); --fix sets ${octal(mode)}.`, + fix: { kind: "chmod", path: candidate.path, stats, mode }, + }, + ]; +} + +/** Git findings for a secret-bearing file inside a checkout. */ +async function gitFindings(opts: { + readonly candidate: Candidate; + readonly name: string; +}): Promise { + const { candidate, name } = opts; + const ignore = candidate.ignore; + if (!ignore) { + return []; + } + const target = { projectRoot: ignore.projectRoot, path: candidate.path }; + if (await isGitTrackedOrUnknownProjectPath(target)) { + return [ + { + status: "error", + path: candidate.path, + message: `git tracks ${name} (or cannot say whether it does), and it holds ${candidate.holds}: untrack it with 'git rm --cached -- ${name}', then treat what it held as exposed.`, + fix: null, + }, + ]; + } + if ((await isGitIgnoredProjectEnvPath(target)) === false) { + const rel = relative(ignore.projectRoot, ignore.gitignorePath); + return [ + { + status: "warn", + path: candidate.path, + message: `git does not ignore ${name}, which holds ${candidate.holds}; --fix adds '${ignore.entry}' to ${rel}.`, + fix: { + kind: "ignore", + gitignorePath: ignore.gitignorePath, + entry: ignore.entry, + }, + }, + ]; + } + return []; +} + +async function candidateFindings(opts: { + readonly candidate: Candidate; + readonly project: SecretsHygieneScope["project"]; +}): Promise { + const stats = await lstat(opts.candidate.path).catch(() => null); + if (!stats) { + return []; + } + const name = shown(opts.candidate.path, opts.project); + return [ + ...fileFindings({ candidate: opts.candidate, stats, name }), + ...(stats.isSymbolicLink() + ? [] + : await gitFindings({ candidate: opts.candidate, name })), + ]; +} + +/** Target names whose staging leftovers to look for in `directory`. */ +async function stagedNames(opts: { + readonly directory: string; + readonly names: readonly string[]; + readonly accept?: RegExp; +}): Promise { + const found = new Set(opts.names); + if (opts.accept) { + for (const entry of await readdir(opts.directory).catch(() => [])) { + const name = legacyStagingTargetName(entry); + if (name && opts.accept.test(name)) { + found.add(name); + } + } + } + return [...found]; +} + +async function leftoverFindings(opts: { + readonly directory: string; + readonly names: readonly string[]; + readonly accept?: RegExp; + readonly now: number; + readonly project: SecretsHygieneScope["project"]; +}): Promise { + const leftovers = await findStagingLeftovers({ + directory: opts.directory, + names: await stagedNames(opts), + now: opts.now, + }); + return leftovers.map((leftover) => ({ + status: "warn" as const, + path: leftover.path, + message: `${shown(leftover.path, opts.project)} is a staging copy left by an interrupted Hack write, and may hold secret values; --fix removes it.`, + fix: { kind: "remove_leftover" as const, leftover }, + })); +} + +async function storeFindings(opts: { + readonly store: SecretsHygieneScope["stores"][number]; + readonly storeKeyFromEnvironment: boolean; +}): Promise { + const { storePath, keyPath } = opts.store; + const findings: SecretsHygieneFinding[] = []; + for (const candidate of [ + { path: storePath, holds: "an encrypted secret store", ignore: null }, + { path: keyPath, holds: "an encrypted store key", ignore: null }, + ] as const) { + findings.push(...(await candidateFindings({ candidate, project: null }))); + } + const store = await lstat(storePath).catch(() => null); + const key = await lstat(keyPath).catch(() => null); + if ( + store?.isFile() && + store.size > 0 && + !key && + !opts.storeKeyFromEnvironment + ) { + const keychain = + process.platform === "darwin" && !isKeychainDisabled() + ? " Hack can read it only if the system keychain still holds its key." + : ""; + findings.push({ + status: "error", + path: storePath, + message: `Encrypted secret store ${storePath}: store present, key missing, cannot be read (no key file at ${keyPath}; HACK_SECRETS_FILE_KEY is not set).${keychain} Restore the key file to read it. Hack never deletes a store or a key; remove the store yourself once you no longer need its values.`, + fix: null, + }); + } + return findings; +} + +/** All findings for a scope; reads metadata, listings and git only. */ +export async function inspectSecretsHygiene( + scope: SecretsHygieneScope +): Promise { + const now = scope.now ?? Date.now(); + const findings: SecretsHygieneFinding[] = []; + if (scope.project) { + for (const candidate of await projectCandidates(scope.project)) { + findings.push( + ...(await candidateFindings({ candidate, project: scope.project })) + ); + } + findings.push( + ...(await leftoverFindings({ + directory: scope.project.projectDir, + names: [".env", "hack.env.local.yaml"], + accept: HACK_DIR_STAGED, + now, + project: scope.project, + })), + ...(await leftoverFindings({ + directory: scope.project.projectRoot, + names: [PROJECT_ENV_KEY_FILENAME], + now, + project: scope.project, + })) + ); + } + if (scope.sharedKeyPath) { + const candidate: Candidate = { + path: scope.sharedKeyPath, + holds: "the project env key", + ignore: null, + }; + findings.push( + ...(await candidateFindings({ candidate, project: null })), + ...(await leftoverFindings({ + directory: dirname(scope.sharedKeyPath), + names: [basename(scope.sharedKeyPath)], + now, + project: null, + })) + ); + } + const storeDirs = new Map>(); + for (const store of scope.stores) { + findings.push( + ...(await storeFindings({ + store, + storeKeyFromEnvironment: scope.storeKeyFromEnvironment, + })) + ); + for (const path of [store.storePath, store.keyPath]) { + const names = storeDirs.get(dirname(path)) ?? new Set(); + names.add(basename(path)); + storeDirs.set(dirname(path), names); + } + } + for (const [directory, names] of storeDirs) { + findings.push( + ...(await leftoverFindings({ + directory, + names: [...names], + now, + project: null, + })) + ); + } + for (const stale of await findStaleMuxEnvironments({ + ...(scope.tmpDir ? { tmpDir: scope.tmpDir } : {}), + now, + })) { + findings.push({ + status: "warn", + path: stale.root, + message: `${stale.root} holds env values staged for a terminal session that never ran (older than ${MUX_ENV_TTL_MS / 60_000} minutes); --fix removes it.`, + fix: { kind: "remove_mux", stale }, + }); + } + return findings; +} + +/** Tightens a file in place through a no-follow handle, if it is still the one found. */ +async function tightenMode( + fix: Extract +): Promise { + const file = await open(fix.path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const current = await file.stat(); + if ( + !current.isFile() || + current.dev !== fix.stats.dev || + current.ino !== fix.stats.ino + ) { + return false; + } + await file.chmod(fix.mode); + return true; + } finally { + await file.close(); + } +} + +async function addIgnoreEntry( + fix: Extract +): Promise { + // Never write through a linked ignore file. + const existing = await lstat(fix.gitignorePath).catch(() => null); + if (existing && !existing.isFile()) { + return false; + } + return ( + await ensureGitignoreEntry({ + gitignorePath: fix.gitignorePath, + entry: fix.entry, + comment: "# machine-local secrets (hack doctor --fix)", + }) + ).changed; +} + +/** + * Applies every fixable finding and reports each change (or why one was + * left) through `report`; no line holds a value. Returns the counts. + */ +export async function applySecretsHygieneFixes(opts: { + readonly findings: readonly SecretsHygieneFinding[]; + readonly report: (line: string) => void; +}): Promise<{ readonly applied: number; readonly skipped: number }> { + let applied = 0; + let skipped = 0; + const done = new Set(); + for (const finding of opts.findings) { + const fix = finding.fix; + if (!fix) { + continue; + } + const key = + fix.kind === "ignore" + ? `${fix.gitignorePath}\0${fix.entry}` + : finding.path; + if (done.has(key)) { + continue; + } + done.add(key); + let changed = false; + try { + if (fix.kind === "chmod") { + changed = await tightenMode(fix); + } else if (fix.kind === "ignore") { + changed = await addIgnoreEntry(fix); + } else if (fix.kind === "remove_leftover") { + changed = await removeStagingLeftover(fix.leftover); + } else { + changed = await removeStaleMuxEnvironment(fix.stale); + } + } catch { + changed = false; + } + if (changed) { + applied += 1; + opts.report(describeApplied({ fix, path: finding.path })); + } else { + skipped += 1; + opts.report( + `Left unchanged (it changed or could not be updated): ${finding.path}` + ); + } + } + return { applied, skipped }; +} + +function describeApplied(opts: { + readonly fix: SecretsHygieneFix; + readonly path: string; +}): string { + const { fix } = opts; + switch (fix.kind) { + case "chmod": + return `Set mode ${octal(fix.mode)} on ${opts.path}`; + case "ignore": + return `Added '${fix.entry}' to ${fix.gitignorePath}`; + case "remove_leftover": + return `Removed leftover staging copy ${opts.path}`; + default: + return `Removed stale terminal-session env payload ${opts.path}`; + } +} diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts new file mode 100644 index 000000000..9b1f0405f --- /dev/null +++ b/tests/secrets-hygiene.test.ts @@ -0,0 +1,250 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { STAGING_LEFTOVER_MIN_AGE_MS } from "../src/lib/private-staging.ts"; +import { + applySecretsHygieneFixes, + inspectSecretsHygiene, + type SecretsHygieneFinding, + type SecretsHygieneScope, +} from "../src/lib/secrets-hygiene.ts"; +import { stageMuxEnvironment } from "../src/mux/private-environment.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; + +const dirs: string[] = []; + +afterEach(async () => { + for (const dir of dirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +const UUID = "0f0e0d0c-0b0a-4908-8706-050403020100"; +const SENTINEL = "synthetic-hygiene-sentinel"; + +async function git(cwd: string, args: readonly string[]): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + env: fixtureGitEnv({ + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + HOME: cwd, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + }, + }), + stdout: "ignore", + stderr: "ignore", + }); + expect(await proc.exited).toBe(0); +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "hack-secrets-hygiene-")); + dirs.push(root); + const projectRoot = join(root, "repo"); + const projectDir = join(projectRoot, ".hack"); + const home = join(root, "home"); + const muxDir = join(root, "mux"); + await mkdir(projectDir, { recursive: true }); + await mkdir(home); + await mkdir(muxDir); + await git(projectRoot, ["init", "-q", "-b", "main"]); + await writeFile( + join(projectDir, ".gitignore"), + ".env\nhack.env.local.yaml\nhack.env.*.local.yaml\n" + ); + await writeFile(join(projectRoot, ".gitignore"), ".hack.secret.key\n"); + const scope: SecretsHygieneScope = { + project: { projectRoot, projectDir }, + sharedKeyPath: null, + stores: [ + { + storePath: join(home, "secrets.enc.json"), + keyPath: join(home, "secrets-file.key"), + }, + ], + tmpDir: muxDir, + storeKeyFromEnvironment: false, + }; + return { root, projectRoot, projectDir, home, muxDir, scope }; +} + +async function backdate(path: string): Promise { + const old = (Date.now() - STAGING_LEFTOVER_MIN_AGE_MS - 60_000) / 1000; + await utimes(path, old, old); +} + +const paths = (findings: readonly SecretsHygieneFinding[]) => + findings.map((finding) => finding.path).sort(); + +test("a clean project and home report nothing", async () => { + const f = await fixture(); + await writeFile(join(f.projectDir, "hack.env.local.yaml"), "x: 1\n", { + mode: 0o600, + }); + expect(await inspectSecretsHygiene(f.scope)).toEqual([]); +}); + +test("loose modes on env layers, plaintext env, keys and stores are tightened by --fix", async () => { + const f = await fixture(); + const files = [ + join(f.projectDir, "hack.env.local.yaml"), + join(f.projectDir, ".env"), + join(f.projectRoot, ".hack.secret.key"), + join(f.home, "secrets.enc.json"), + join(f.home, "secrets-file.key"), + ]; + for (const path of files) { + await writeFile(path, `${SENTINEL}\n`); + await chmod(path, 0o644); + } + const findings = await inspectSecretsHygiene(f.scope); + expect(paths(findings)).toEqual([...files].sort()); + for (const finding of findings) { + expect(finding.status).toBe("warn"); + expect(finding.message).toContain("mode 0644"); + expect(finding.message).not.toContain(SENTINEL); + } + const lines: string[] = []; + expect( + await applySecretsHygieneFixes({ findings, report: (l) => lines.push(l) }) + ).toEqual({ applied: files.length, skipped: 0 }); + for (const path of files) { + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect(await readFile(path, "utf8")).toBe(`${SENTINEL}\n`); + } + expect(lines).toHaveLength(files.length); + expect(lines.join("\n")).not.toContain(SENTINEL); + expect(await inspectSecretsHygiene(f.scope)).toEqual([]); +}); + +test("an unignored overlay gets an ignore entry; a tracked layer and a link are errors without a fix", async () => { + const f = await fixture(); + const overlay = join(f.projectDir, ".env.qa"); + await writeFile(overlay, `QA=${SENTINEL}\n`, { mode: 0o600 }); + const tracked = join(f.projectDir, "hack.env.qa.local.yaml"); + await writeFile(tracked, "x: 1\n", { mode: 0o600 }); + await git(f.projectRoot, ["add", "-f", "--", tracked]); + const outside = join(f.root, "outside.env"); + await writeFile(outside, `OUT=${SENTINEL}\n`, { mode: 0o600 }); + await symlink(outside, join(f.projectDir, "hack.env.local.yaml")); + + const findings = await inspectSecretsHygiene(f.scope); + const byPath = new Map(findings.map((finding) => [finding.path, finding])); + expect(byPath.get(overlay)?.fix).toEqual({ + kind: "ignore", + gitignorePath: join(f.projectDir, ".gitignore"), + entry: ".env.qa", + }); + expect(byPath.get(tracked)).toMatchObject({ status: "error", fix: null }); + expect(byPath.get(join(f.projectDir, "hack.env.local.yaml"))).toMatchObject({ + status: "error", + fix: null, + }); + + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect(await readFile(join(f.projectDir, ".gitignore"), "utf8")).toContain( + "\n.env.qa\n" + ); + // The link and its target are left alone. + expect( + (await lstat(join(f.projectDir, "hack.env.local.yaml"))).isSymbolicLink() + ).toBe(true); + expect(await readFile(outside, "utf8")).toBe(`OUT=${SENTINEL}\n`); + expect(paths(await inspectSecretsHygiene(f.scope))).toEqual( + [tracked, join(f.projectDir, "hack.env.local.yaml")].sort() + ); +}); + +test("old staging leftovers are removed; recent ones and user files are kept", async () => { + const f = await fixture(); + const oldEnvCopy = join(f.projectDir, `..env.${UUID}.tmp`); + const oldLayerCopy = join(f.projectDir, `.hack.env.local.yaml.${UUID}.tmp`); + const oldKeyCopy = join(f.projectRoot, `..hack.secret.key.${UUID}.tmp`); + const oldStoreCopy = join(f.home, `.secrets-file.key.${UUID}.tmp`); + const recentCopy = join(f.projectDir, `..env.qa.${UUID}.tmp`); + const userFile = join(f.projectDir, `.notes.${UUID}.tmp`); + for (const path of [ + oldEnvCopy, + oldLayerCopy, + oldKeyCopy, + oldStoreCopy, + recentCopy, + userFile, + ]) { + await writeFile(path, `${SENTINEL}\n`, { mode: 0o600 }); + } + for (const path of [ + oldEnvCopy, + oldLayerCopy, + oldKeyCopy, + oldStoreCopy, + userFile, + ]) { + await backdate(path); + } + const findings = await inspectSecretsHygiene(f.scope); + expect(paths(findings)).toEqual( + [oldEnvCopy, oldLayerCopy, oldKeyCopy, oldStoreCopy].sort() + ); + await applySecretsHygieneFixes({ findings, report: () => undefined }); + for (const path of [oldEnvCopy, oldLayerCopy, oldKeyCopy, oldStoreCopy]) { + expect(await lstat(path).catch(() => null)).toBeNull(); + } + expect(await readFile(recentCopy, "utf8")).toBe(`${SENTINEL}\n`); + expect(await readFile(userFile, "utf8")).toBe(`${SENTINEL}\n`); +}); + +test("a store whose key is missing is reported and never deleted", async () => { + const f = await fixture(); + const store = join(f.home, "secrets.enc.json"); + await writeFile(store, `{"sentinel":"${SENTINEL}"}\n`, { mode: 0o644 }); + + const findings = await inspectSecretsHygiene(f.scope); + const missing = findings.find((finding) => + finding.message.includes("store present, key missing, cannot be read") + ); + expect(missing).toMatchObject({ status: "error", path: store, fix: null }); + expect(missing?.message).not.toContain(SENTINEL); + + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect(await readFile(store, "utf8")).toContain(SENTINEL); + expect((await stat(store)).mode & 0o777).toBe(0o600); + + const withEnvKey = await inspectSecretsHygiene({ + ...f.scope, + storeKeyFromEnvironment: true, + }); + expect( + withEnvKey.some((finding) => finding.message.includes("key missing")) + ).toBe(false); +}); + +test("a stale terminal-session payload is reported and removed", async () => { + const f = await fixture(); + const payload = await stageMuxEnvironment( + { FAKE_KEY: SENTINEL }, + { tmpDir: f.muxDir } + ); + const root = payload.path.slice(0, payload.path.lastIndexOf("/")); + expect(await inspectSecretsHygiene(f.scope)).toEqual([]); + const old = (Date.now() - 11 * 60_000) / 1000; + await utimes(root, old, old); + const findings = await inspectSecretsHygiene(f.scope); + expect(paths(findings)).toEqual([root]); + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect(await lstat(root).catch(() => null)).toBeNull(); +}); From d74187c503f5aa357c5de1ab4cad4c985fdf3f34 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:52:49 +0000 Subject: [PATCH 05/42] refactor(fs): split the atomic writer's mode handling into helpers (HACK-2165) Keeps writeTextFileAtomicIfChanged under the complexity limit after tightenExisting; no behavior change. Co-Authored-By: Claude --- src/lib/fs.ts | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/lib/fs.ts b/src/lib/fs.ts index 1236a3026..52258a9e1 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -430,12 +430,11 @@ export async function writeTextFileAtomicIfChanged( throw new AtomicWriteConflictError(); } if (existing.kind === "existing" && existing.text === content) { - if (options.tightenExisting === true && (existing.mode & 0o077) !== 0) { - await tightenUnchangedTarget({ - path: absolutePath, - validatePath: options.validatePath, - }); - } + await tightenUnchangedTarget({ + path: absolutePath, + existing, + options, + }); return { changed: false }; } const target = @@ -469,11 +468,7 @@ export async function writeTextFileAtomicIfChanged( if (existing.kind === "existing") { // chown can clear set-id bits, so the mode is applied after it. await preserveOwnership({ file, existing, path: absolutePath }); - await file.chmod( - options.tightenExisting === true - ? existing.mode & ~0o077 - : existing.mode - ); + await file.chmod(replacementMode({ existing, options })); } await file.writeFile(content, "utf8"); await file.sync(); @@ -502,6 +497,16 @@ export async function writeTextFileAtomicIfChanged( return { changed: true }; } +/** The mode a replacement of an existing target gets (see `tightenExisting`). */ +function replacementMode(opts: { + readonly existing: { readonly mode: number }; + readonly options: { readonly tightenExisting?: boolean }; +}): number { + return opts.options.tightenExisting === true + ? opts.existing.mode & ~0o077 + : opts.existing.mode; +} + /** * `tightenExisting` for a write that changes nothing: drops the group and * other bits of the resolved target in place, through a no-follow handle on @@ -509,10 +514,20 @@ export async function writeTextFileAtomicIfChanged( */ async function tightenUnchangedTarget(opts: { readonly path: string; - readonly validatePath?: (path: string) => Promise; + readonly existing: { readonly mode: number }; + readonly options: { + readonly tightenExisting?: boolean; + readonly validatePath?: (path: string) => Promise; + }; }): Promise { + if ( + opts.options.tightenExisting !== true || + (opts.existing.mode & 0o077) === 0 + ) { + return; + } const target = await realpath(opts.path); - await opts.validatePath?.(target); + await opts.options.validatePath?.(target); const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); try { const info = await file.stat(); From d8719bdac7246cfa7b0fe92c4173695193eb221a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:07:14 +0000 Subject: [PATCH 06/42] fix(env): verify the staging ignore file; unique retired keys; private layer staging (HACK-2165) Own-reviewer round on d74187c5: - An existing `.hack-staging/.gitignore` (for example one a repository committed, checked out 0700 under umask 077) was trusted unread. It is now accepted only as a regular file of the user's holding exactly `*`; anything else refuses the write, so no staging copy can be visible to git. - The rotation remedy's `install` into a fixed name overwrote an earlier retired key. It now copies to a unique mktemp name and removes the key only once the copy is written. - Machine-local layer writes (writeTextFileAtomicIfChanged with tightenExisting) still staged as `...tmp` siblings, which only Hack's managed block ignores. They now stage in the private directory too; shared layers keep sibling staging. - Committed dotenv templates (.env.example, .sample, .template, .dist) are no longer reported as secret-bearing. - Tightening an unchanged layer is best effort (another user's file keeps working), and a missing store key is a warning on macOS while the keychain may still hold it. Co-Authored-By: Claude --- src/lib/doctor-env-key.ts | 5 +- src/lib/env-layer-modes.ts | 15 ++++- src/lib/fs.ts | 64 +++++++++++++++++++-- src/lib/private-staging.ts | 45 +++++++++++++-- src/lib/secrets-hygiene.ts | 19 +++--- tests/doctor-env-key.test.ts | 4 +- tests/env-layer-modes.test.ts | 3 + tests/env-local-layer-mode.test.ts | 20 +++++++ tests/private-staging.test.ts | 22 +++++++ tests/project-env-hydrate.test.ts | 14 ++++- tests/project-env-stable-ciphertext.test.ts | 15 ++++- 11 files changed, 199 insertions(+), 27 deletions(-) diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index f6024d005..c46290e65 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -232,7 +232,10 @@ const READD_SECRET = "re-add each secret with ' | hack env add KEY --secret --value-stdin' (or omit the value in a terminal for a hidden prompt)"; function rotateRemedy(path: (suffix?: string) => string): string { - return `keep a recoverable copy of the old key outside the worktree first ('install -d -m 700 ${RETIRED_KEY_DIR} && install -m 600 ${path()} ${RETIRED_KEY_DIR}/'), then 'rm ${path()}' and ${READD_SECRET}; Hack generates a new 256-bit key. Delete the retired copy only once every secret decrypts under the new key.`; + // mktemp gives each retired copy its own name (0600), so a second rotation, + // here or in another project, never overwrites the first; the key is only + // removed once its copy is written. + return `keep a recoverable copy of the old key outside the worktree, then remove it: 'install -d -m 700 ${RETIRED_KEY_DIR} && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${path()} > "$k" && rm ${path()}'. Then ${READD_SECRET}; Hack generates a new 256-bit key. Delete the retired copy only once every secret decrypts under the new key.`; } async function inspectCandidate(opts: { diff --git a/src/lib/env-layer-modes.ts b/src/lib/env-layer-modes.ts index 1a6872015..1f8ec623f 100644 --- a/src/lib/env-layer-modes.ts +++ b/src/lib/env-layer-modes.ts @@ -13,8 +13,19 @@ import { join } from "node:path"; * stdout stays valid. */ -const LOCAL_LAYER = /^hack\.env(\.[^/]+)?\.local\.yaml$/; -const PLAINTEXT_DOTENV = /^\.env(\.[A-Za-z0-9_-]+)?$/; +/** A machine-local env layer: `hack.env.local.yaml`, `hack.env..local.yaml`. */ +export const MACHINE_LOCAL_LAYER_NAME = /^hack\.env(\.[^/]+)?\.local\.yaml$/; + +/** + * A plaintext `.hack/.env` or `.hack/.env.` file. Committed + * templates (`.env.example`, `.sample`, `.template`, `.dist`) hold no values + * and are excluded; backups (`.bak`, `.old`, ...) may and are not. + */ +export const PLAINTEXT_DOTENV_NAME = + /^\.env(\.(?!(?:example|sample|template|dist)$)[A-Za-z0-9_-]+)?$/; + +const LOCAL_LAYER = MACHINE_LOCAL_LAYER_NAME; +const PLAINTEXT_DOTENV = PLAINTEXT_DOTENV_NAME; const reported = new Set(); diff --git a/src/lib/fs.ts b/src/lib/fs.ts index 52258a9e1..b7bda5014 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -17,6 +17,7 @@ import { HackCliError, type HackErrorCode, } from "./cli-result.ts"; +import { preparePrivateStaging } from "./private-staging.ts"; export async function pathExists(absolutePath: string): Promise { try { @@ -454,10 +455,12 @@ export async function writeTextFileAtomicIfChanged( }); } } - const staging = join( - dirname(target), - `.${basename(target)}.${randomUUID()}.tmp` - ); + const prepared = await stagingForAtomicWrite({ + target, + path: absolutePath, + private: options.tightenExisting === true, + }); + const staging = prepared.path; const file = await openAtomicWriteStaging({ staging, path: absolutePath, @@ -482,6 +485,7 @@ export async function writeTextFileAtomicIfChanged( } await options.validatePath?.(absolutePath); await options.validatePath?.(target); + await prepared.recheck(); await rename(staging, target); committed = true; } catch (error: unknown) { @@ -497,6 +501,46 @@ export async function writeTextFileAtomicIfChanged( return { changed: true }; } +/** + * Where an atomic write stages its contents. A private write (one that + * holds machine-local values, marked by `tightenExisting`) stages in the + * self-ignoring private directory next to the target, so a copy a crash + * leaves is never visible to git, whatever ignore rules the project has + * (see `private-staging.ts`). Other writes keep the `...tmp` + * sibling. + */ +async function stagingForAtomicWrite(opts: { + readonly target: string; + readonly path: string; + readonly private: boolean; +}): Promise<{ + readonly path: string; + readonly recheck: () => Promise; +}> { + if (!opts.private) { + return { + path: join( + dirname(opts.target), + `.${basename(opts.target)}.${randomUUID()}.tmp` + ), + recheck: async () => undefined, + }; + } + const refusal = () => + new AtomicWriteRefusedError({ + path: opts.path, + reason: "directory_not_writable", + }); + try { + return await preparePrivateStaging({ target: opts.target, refusal }); + } catch (error: unknown) { + if (error instanceof AtomicWriteRefusedError) { + throw error; + } + throw atomicWriteStagingError({ error, path: opts.path }); + } +} + /** The mode a replacement of an existing target gets (see `tightenExisting`). */ function replacementMode(opts: { readonly existing: { readonly mode: number }; @@ -528,11 +572,19 @@ async function tightenUnchangedTarget(opts: { } const target = await realpath(opts.path); await opts.options.validatePath?.(target); - const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); + // Best effort: a file another user owns (chmod EPERM) keeps working as it + // did; the read-time report and `hack doctor` still name it. + const file = await open( + target, + constants.O_RDONLY | constants.O_NOFOLLOW + ).catch(() => null); + if (!file) { + return; + } try { const info = await file.stat(); if (info.isFile() && (info.mode & 0o077) !== 0) { - await file.chmod(info.mode & 0o7700); + await file.chmod(info.mode & 0o7700).catch(() => undefined); } } finally { await file.close(); diff --git a/src/lib/private-staging.ts b/src/lib/private-staging.ts index a640e9041..8d82e552c 100644 --- a/src/lib/private-staging.ts +++ b/src/lib/private-staging.ts @@ -187,7 +187,7 @@ export async function preparePrivateStaging(opts: { if (!(identity && isPrivateDirectory(identity))) { throw opts.refusal(); } - await ensureSelfIgnore(directory); + await ensureSelfIgnore({ directory, refusal: opts.refusal }); const leftovers = await findStagingLeftovers({ directory: dirname(opts.target), names: [basename(opts.target)], @@ -211,19 +211,31 @@ export async function preparePrivateStaging(opts: { }; } -/** `*` ignores every entry, the `.gitignore` itself included. */ -async function ensureSelfIgnore(directory: string): Promise { +const SELF_IGNORE = "*\n"; + +/** + * `*` ignores every entry, the `.gitignore` itself included. An existing + * `.gitignore` (for example one a repository committed) is trusted only when + * it is a regular file of this user's holding exactly that line; anything + * else could leave staging copies visible to git, so it is refused. + */ +async function ensureSelfIgnore(opts: { + readonly directory: string; + readonly refusal: () => Error; +}): Promise { + const path = join(opts.directory, ".gitignore"); let file: Awaited> | null = null; try { file = await open( - join(directory, ".gitignore"), + path, constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_WRONLY, 0o600 ); - await file.writeFile("*\n", "utf8"); + await file.writeFile(SELF_IGNORE, "utf8"); + return; } catch (error: unknown) { if (!isExisting(error)) { throw error; @@ -231,4 +243,27 @@ async function ensureSelfIgnore(directory: string): Promise { } finally { await file?.close(); } + if (!(await isOwnSelfIgnore(path))) { + throw opts.refusal(); + } +} + +async function isOwnSelfIgnore(path: string): Promise { + let file: Awaited> | null = null; + try { + file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const info = await file.stat(); + if ( + !info.isFile() || + info.uid !== process.getuid?.() || + info.size !== SELF_IGNORE.length + ) { + return false; + } + return (await file.readFile("utf8")) === SELF_IGNORE; + } catch { + return false; + } finally { + await file?.close(); + } } diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index ef5554ab3..7301c66d5 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -8,6 +8,10 @@ import { removeStaleMuxEnvironment, type StaleMuxEnvironment, } from "../mux/private-environment.ts"; +import { + MACHINE_LOCAL_LAYER_NAME, + PLAINTEXT_DOTENV_NAME, +} from "./env-layer-modes.ts"; import { ensureGitignoreEntry } from "./fs.ts"; import { isKeychainDisabled } from "./keychain.ts"; import { @@ -103,8 +107,8 @@ export type SecretsHygieneScope = { readonly now?: number; }; -const LOCAL_LAYER = /^hack\.env(\.[^/]+)?\.local\.yaml$/; -const PLAINTEXT_DOTENV = /^\.env(\.[A-Za-z0-9_-]+)?$/; +const LOCAL_LAYER = MACHINE_LOCAL_LAYER_NAME; +const PLAINTEXT_DOTENV = PLAINTEXT_DOTENV_NAME; const HACK_DIR_STAGED = /^(\.env(\.[A-Za-z0-9_-]+)?|hack\.env\..*yaml|\.gitignore)$/; @@ -319,12 +323,13 @@ async function storeFindings(opts: { !key && !opts.storeKeyFromEnvironment ) { - const keychain = - process.platform === "darwin" && !isKeychainDisabled() - ? " Hack can read it only if the system keychain still holds its key." - : ""; + const keychainMayHoldKey = + process.platform === "darwin" && !isKeychainDisabled(); + const keychain = keychainMayHoldKey + ? " Hack can read it only if the system keychain still holds its key." + : ""; findings.push({ - status: "error", + status: keychainMayHoldKey ? "warn" : "error", path: storePath, message: `Encrypted secret store ${storePath}: store present, key missing, cannot be read (no key file at ${keyPath}; HACK_SECRETS_FILE_KEY is not set).${keychain} Restore the key file to read it. Hack never deletes a store or a key; remove the store yourself once you no longer need its values.`, fix: null, diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index f47204ffb..c11e18a08 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -181,8 +181,10 @@ describe("env key doctor diagnostic", () => { // HACK-2165: the old key leaves the worktree (never a committable // `.old` sibling) and secrets are re-added without argv values. const remedy = inspection.findings[0]?.remedy ?? ""; + // A unique, private copy (mktemp), so a later rotation never overwrites + // it, and the key is removed only after the copy is written. expect(remedy).toContain( - `install -m 600 ${KEY_FILE} ~/.hack/retired-keys/` + `k="$(mktemp ~/.hack/retired-keys/hack-secret-key.XXXXXXXX)" && cat ${KEY_FILE} > "$k" && rm ${KEY_FILE}` ); expect(remedy).toContain("--value-stdin"); expect(remedy).not.toContain(".old"); diff --git a/tests/env-layer-modes.test.ts b/tests/env-layer-modes.test.ts index 8addc10ee..ce7db6a36 100644 --- a/tests/env-layer-modes.test.ts +++ b/tests/env-layer-modes.test.ts @@ -26,6 +26,9 @@ test("loose machine-local env files are reported once, by name and mode only (HA [".env", 0o640], [".env.state.json", 0o644], ["hack.env.default.yaml", 0o644], + // Committed templates hold no values and are never reported. + [".env.example", 0o644], + [".env.sample", 0o644], ]; for (const [name, mode] of files) { await writeFile(join(projectDir, name), `KEY=${SENTINEL}\n`, { mode }); diff --git a/tests/env-local-layer-mode.test.ts b/tests/env-local-layer-mode.test.ts index c70b7433a..19228a86a 100644 --- a/tests/env-local-layer-mode.test.ts +++ b/tests/env-local-layer-mode.test.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, mkdtemp, + readdir, readFile, rm, stat, @@ -161,6 +162,25 @@ test("a write that changes nothing still tightens a loose machine-local layer (H expect(await readFile(layer, "utf8")).toBe(before); }); +test("a machine-local write stages in the private directory, never as a sibling (HACK-2165)", async () => { + const repo = await layersRepo(); + await setProjectEnvValue({ + ...repo, + envName: null, + scope: "global", + key: "STAGED_PRIVATELY", + value: "synthetic-staged", + secret: false, + local: true, + }); + const names = await readdir(repo.projectDir); + expect(names.filter((name) => name.endsWith(".tmp"))).toEqual([]); + expect(names).toContain(".hack-staging"); + expect(await readdir(resolve(repo.projectDir, ".hack-staging"))).toEqual([ + ".gitignore", + ]); +}); + test("a tracked (shared) layer keeps its mode on a write", async () => { const repo = await layersRepo(); const layer = resolve(repo.projectDir, "hack.env.yaml"); diff --git a/tests/private-staging.test.ts b/tests/private-staging.test.ts index 88b2e0b61..7f5aa2bd1 100644 --- a/tests/private-staging.test.ts +++ b/tests/private-staging.test.ts @@ -201,3 +201,25 @@ test("a linked or shared staging directory is refused before anything is staged" ).rejects.toThrow("synthetic staging refusal"); expect(await readdir(join(shared, PRIVATE_STAGING_DIRNAME))).toEqual([]); }); + +test("a staging directory whose .gitignore does not ignore everything is refused (a committed one included)", async () => { + const root = await tempDir(); + const staging = join(root, PRIVATE_STAGING_DIRNAME); + // As git would check it out under umask 077: private, owned by the user. + await mkdir(staging, { mode: 0o700 }); + await writeFile(join(staging, ".gitignore"), "!*.tmp\n"); + await expect( + preparePrivateStaging({ target: join(root, ".env"), refusal }) + ).rejects.toThrow("synthetic staging refusal"); + expect(await readdir(staging)).toEqual([".gitignore"]); + + const linkedRoot = await tempDir(); + const linkedStaging = join(linkedRoot, PRIVATE_STAGING_DIRNAME); + await mkdir(linkedStaging, { mode: 0o700 }); + const elsewhere = join(linkedRoot, "elsewhere-ignore"); + await writeFile(elsewhere, "*\n"); + await symlink(elsewhere, join(linkedStaging, ".gitignore")); + await expect( + preparePrivateStaging({ target: join(linkedRoot, ".env"), refusal }) + ).rejects.toThrow("synthetic staging refusal"); +}); diff --git a/tests/project-env-hydrate.test.ts b/tests/project-env-hydrate.test.ts index 1f8594395..dde19c666 100644 --- a/tests/project-env-hydrate.test.ts +++ b/tests/project-env-hydrate.test.ts @@ -191,10 +191,20 @@ function ivSegment(ciphertext: string): string { return ciphertext.split(":")[1] ?? ""; } +/** + * Staging copies left in `.hack`: legacy siblings and, for machine-local + * writes, files in the self-ignoring `.hack-staging/` (HACK-2165). + */ async function stagingFiles(repo: Repo): Promise { - return (await readdir(repo.projectDir)).filter((name) => - name.endsWith(".tmp") + const staged = await readdir(resolve(repo.projectDir, ".hack-staging")).catch( + () => [] as string[] ); + return [ + ...(await readdir(repo.projectDir)).filter((name) => name.endsWith(".tmp")), + ...staged + .filter((name) => name.endsWith(".tmp")) + .map((name) => `.hack-staging/${name}`), + ]; } function lockPath(repo: Repo): string { diff --git a/tests/project-env-stable-ciphertext.test.ts b/tests/project-env-stable-ciphertext.test.ts index 1f5d4372a..d33185c5a 100644 --- a/tests/project-env-stable-ciphertext.test.ts +++ b/tests/project-env-stable-ciphertext.test.ts @@ -518,9 +518,18 @@ async function assertKilledWriteIsRecoverable(layer: Layer): Promise { worker.kill("SIGKILL"); await worker.exited; - const abandoned = (await readdir(repo.projectDir)).filter((name) => - name.endsWith(".tmp") - ); + // A machine-local write stages in the self-ignoring `.hack-staging/` + // (HACK-2165); a shared layer write stages as a sibling. + const stagingDir = await readdir( + join(repo.projectDir, ".hack-staging") + ).catch(() => [] as string[]); + const abandoned = [ + ...(await readdir(repo.projectDir)).filter((name) => name.endsWith(".tmp")), + ...stagingDir + .filter((name) => name.endsWith(".tmp")) + .map((name) => `.hack-staging/${name}`), + ]; + expect(abandoned[0]?.startsWith(".hack-staging/")).toBe(layer === "local"); expect(abandoned).toHaveLength(1); const staged = await readFile( join(repo.projectDir, abandoned[0] ?? ""), From 9e060a3e364533793f8bfe1c91e2c85aba5e0e1b Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:17:59 +0000 Subject: [PATCH 07/42] fix(env): publish the staging ignore file atomically; git-check in-project stores (HACK-2165) Bot review on d8719bda: - Concurrent first private writes in one directory could read the new `.hack-staging/.gitignore` before its creator wrote `*` and refuse. The ignore file is now staged complete and linked into place, so it never exists partially and a crash cannot leave an empty one. - An encrypted store or key configured inside the project checkout skipped the git checks. Such files are now checked like the project's own secrets (tracked is an error; unignored gets an anchored root .gitignore entry); files outside it stay exempt. Co-Authored-By: Claude --- src/lib/private-staging.ts | 42 +++++++++++++++++------------ src/lib/secrets-hygiene.ts | 50 ++++++++++++++++++++++++++++++++--- tests/private-staging.test.ts | 17 ++++++++++++ tests/secrets-hygiene.test.ts | 28 ++++++++++++++++++++ 4 files changed, 116 insertions(+), 21 deletions(-) diff --git a/src/lib/private-staging.ts b/src/lib/private-staging.ts index 8d82e552c..d6458a6a2 100644 --- a/src/lib/private-staging.ts +++ b/src/lib/private-staging.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { constants, type Stats } from "node:fs"; import { chmod, lstat, mkdir, open, readdir, unlink } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; +import { publishFileExclusive } from "./exclusive-publish.ts"; import { isRecord } from "./guards.ts"; /** @@ -224,24 +225,31 @@ async function ensureSelfIgnore(opts: { readonly refusal: () => Error; }): Promise { const path = join(opts.directory, ".gitignore"); - let file: Awaited> | null = null; - try { - file = await open( - path, - constants.O_CREAT | - constants.O_EXCL | - constants.O_NOFOLLOW | - constants.O_WRONLY, - 0o600 - ); - await file.writeFile(SELF_IGNORE, "utf8"); - return; - } catch (error: unknown) { - if (!isExisting(error)) { - throw error; + // Published complete (staged, then linked into place), so a concurrent + // first writer never sees an empty or partial file, and a crash leaves + // either no `.gitignore` or the complete one. The staged copy holds only + // `*` and, if a crash leaves it, matches the staging sweep. + if (!(await lstat(path).catch(() => null))) { + const staged = join(opts.directory, `.gitignore.${randomUUID()}.tmp`); + let file: Awaited> | null = null; + try { + file = await open( + staged, + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_WRONLY, + 0o600 + ); + await file.writeFile(SELF_IGNORE, "utf8"); + await file.sync(); + await file.close(); + file = null; + await publishFileExclusive({ source: staged, target: path }); + } finally { + await file?.close(); + await unlink(staged).catch(() => undefined); } - } finally { - await file?.close(); } if (!(await isOwnSelfIgnore(path))) { throw opts.refusal(); diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 7301c66d5..73116f62b 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -1,6 +1,14 @@ import { constants, type Stats } from "node:fs"; import { lstat, open, readdir } from "node:fs/promises"; -import { basename, dirname, join, relative, resolve } from "node:path"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; import { PROJECT_ENV_KEY_FILENAME } from "../constants.ts"; import { findStaleMuxEnvironments, @@ -303,17 +311,50 @@ async function leftoverFindings(opts: { })); } +/** + * Git metadata for a configured store or key file: one inside the project + * checkout must be untracked and ignored like the project's own secrets (an + * anchored entry in the root `.gitignore`); one outside it is exempt. + */ +function inProjectIgnore(opts: { + readonly path: string; + readonly project: SecretsHygieneScope["project"]; +}): Candidate["ignore"] { + if (!opts.project) { + return null; + } + const rel = relative(opts.project.projectRoot, opts.path); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + return null; + } + return { + projectRoot: opts.project.projectRoot, + gitignorePath: join(opts.project.projectRoot, ".gitignore"), + entry: `/${rel.split(sep).join("/")}`, + }; +} + async function storeFindings(opts: { readonly store: SecretsHygieneScope["stores"][number]; readonly storeKeyFromEnvironment: boolean; + readonly project: SecretsHygieneScope["project"]; }): Promise { const { storePath, keyPath } = opts.store; const findings: SecretsHygieneFinding[] = []; for (const candidate of [ - { path: storePath, holds: "an encrypted secret store", ignore: null }, - { path: keyPath, holds: "an encrypted store key", ignore: null }, + { path: storePath, holds: "an encrypted secret store" }, + { path: keyPath, holds: "an encrypted store key" }, ] as const) { - findings.push(...(await candidateFindings({ candidate, project: null }))); + const ignore = inProjectIgnore({ + path: candidate.path, + project: opts.project, + }); + findings.push( + ...(await candidateFindings({ + candidate: { ...candidate, ignore }, + project: opts.project, + })) + ); } const store = await lstat(storePath).catch(() => null); const key = await lstat(keyPath).catch(() => null); @@ -388,6 +429,7 @@ export async function inspectSecretsHygiene( ...(await storeFindings({ store, storeKeyFromEnvironment: scope.storeKeyFromEnvironment, + project: scope.project, })) ); for (const path of [store.storePath, store.keyPath]) { diff --git a/tests/private-staging.test.ts b/tests/private-staging.test.ts index 7f5aa2bd1..edc35f113 100644 --- a/tests/private-staging.test.ts +++ b/tests/private-staging.test.ts @@ -223,3 +223,20 @@ test("a staging directory whose .gitignore does not ignore everything is refused preparePrivateStaging({ target: join(linkedRoot, ".env"), refusal }) ).rejects.toThrow("synthetic staging refusal"); }); + +// Smoke test only: in one process the open/write interleaving that left a +// concurrent first writer reading an empty .gitignore rarely occurs (it was +// seen across worker processes). The guarantee is structural: the file is +// staged complete and linked into place, so it never exists partially. +test("concurrent first writers in a fresh directory all get a complete self-ignore", async () => { + const root = await tempDir(); + const results = await Promise.allSettled( + Array.from({ length: 32 }, () => + preparePrivateStaging({ target: join(root, ".env"), refusal }) + ) + ); + expect(results.filter((result) => result.status === "rejected")).toEqual([]); + const staging = join(root, PRIVATE_STAGING_DIRNAME); + expect(await readdir(staging)).toEqual([".gitignore"]); + expect(await readFile(join(staging, ".gitignore"), "utf8")).toBe("*\n"); +}); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 9b1f0405f..ce3ad4762 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -248,3 +248,31 @@ test("a stale terminal-session payload is reported and removed", async () => { await applySecretsHygieneFixes({ findings, report: () => undefined }); expect(await lstat(root).catch(() => null)).toBeNull(); }); + +test("a store and key configured inside the project are checked against git", async () => { + const f = await fixture(); + const secrets = join(f.projectRoot, "secrets"); + await mkdir(secrets); + const storePath = join(secrets, "store.enc.json"); + const keyPath = join(secrets, "store.key"); + await writeFile(storePath, `{"sentinel":"${SENTINEL}"}\n`, { mode: 0o600 }); + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + await git(f.projectRoot, ["add", "-f", "--", keyPath]); + const scope: SecretsHygieneScope = { + ...f.scope, + stores: [{ storePath, keyPath }], + }; + const findings = await inspectSecretsHygiene(scope); + const byPath = new Map(findings.map((finding) => [finding.path, finding])); + expect(byPath.get(keyPath)).toMatchObject({ status: "error", fix: null }); + expect(byPath.get(storePath)?.fix).toEqual({ + kind: "ignore", + gitignorePath: join(f.projectRoot, ".gitignore"), + entry: "/secrets/store.enc.json", + }); + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect(paths(await inspectSecretsHygiene(scope))).toEqual([keyPath]); + // Neither file is changed or removed. + expect(await readFile(keyPath, "utf8")).toBe(`${SENTINEL}\n`); + expect(await readFile(storePath, "utf8")).toContain(SENTINEL); +}); From d3e799d9295e629d14f8b99eca48e8f60498fc93 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:31:28 +0000 Subject: [PATCH 08/42] fix(env): admit staging on filesystems without Unix permissions; hygiene refuses a linked .hack (HACK-2165) Bot review on 9e060a3e: - On ExFAT/FAT, chmod cannot make the staging directory 0700, so every private write there was refused, regressing the documented first secret on such a drive. A staging directory of the user's own is now made 0700, and accepted at the mount-wide mode when the filesystem ignores chmod; a link or another user's directory is still refused, and the identity is re-checked before publishing. - doctor's secrets hygiene followed a `.hack` linked outside the project, so --fix could chmod or delete files there. A linked or non-directory `.hack` is now reported and not inspected, and every fix carries the identity of the directory it changes, checked again before the change (an ignore entry is never appended through a directory swapped after inspection). Co-Authored-By: Claude --- docs/env.md | 3 +- src/lib/private-staging.ts | 69 +++++++---- src/lib/secrets-hygiene.ts | 111 +++++++++++++++--- .../fixtures/private-staging-noperm-worker.ts | 35 ++++++ tests/private-staging.test.ts | 29 ++++- tests/secrets-hygiene.test.ts | 46 ++++++++ 6 files changed, 247 insertions(+), 46 deletions(-) create mode 100644 tests/fixtures/private-staging-noperm-worker.ts diff --git a/docs/env.md b/docs/env.md index b1f08d140..5f3f0d384 100644 --- a/docs/env.md +++ b/docs/env.md @@ -824,7 +824,8 @@ 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. +`0600` limits it elsewhere. The private staging directory (`.hack-staging/`) is the same: Hack still refuses one +that is a link or belongs to another user, but accepts the drive's mount-wide mode. 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 diff --git a/src/lib/private-staging.ts b/src/lib/private-staging.ts index d6458a6a2..9bb914d91 100644 --- a/src/lib/private-staging.ts +++ b/src/lib/private-staging.ts @@ -66,16 +66,43 @@ function isExisting(error: unknown): boolean { return isRecord(error) && error.code === "EEXIST"; } -/** A directory only the current user can use: real, owned, no group/other bits. */ -function isPrivateDirectory(stats: Stats): boolean { +/** A real directory of this user's: not a link, owned by the current user. */ +function isOwnDirectory(stats: Stats): boolean { return ( stats.isDirectory() && !stats.isSymbolicLink() && - stats.uid === process.getuid?.() && - (stats.mode & 0o077) === 0 + stats.uid === process.getuid?.() ); } +/** + * Admits the staging directory: a real directory of this user's, made + * private (0700). One with a looser mode is the user's own, so its mode is + * set; when that does not take, the filesystem does not store Unix + * permissions (ExFAT, FAT), and the directory is accepted as private as that + * filesystem allows, as the files written there are (HACK-2165). A link, + * another user's directory, or one replaced meanwhile is never admitted. + */ +async function admitStagingDirectory(directory: string): Promise { + const stats = await lstat(directory).catch(() => null); + if (!(stats && isOwnDirectory(stats))) { + return null; + } + if ((stats.mode & 0o777) === 0o700) { + return stats; + } + await chmod(directory, 0o700).catch(() => undefined); + const after = await lstat(directory).catch(() => null); + if ( + !(after && isOwnDirectory(after)) || + after.dev !== stats.dev || + after.ino !== stats.ino + ) { + return null; + } + return after; +} + /** A leftover Hack may remove: a regular file of ours, old enough. */ export function isRemovableLeftover(opts: { readonly stats: Stats; @@ -120,7 +147,7 @@ export async function findStagingLeftovers(opts: { ); const staging = join(opts.directory, PRIVATE_STAGING_DIRNAME); const stagingStats = await lstat(staging).catch(() => null); - if (stagingStats && isPrivateDirectory(stagingStats)) { + if (stagingStats && isOwnDirectory(stagingStats)) { await collect(staging, isStagingFileName); } return found; @@ -158,10 +185,11 @@ export type PreparedStaging = { /** * Prepares the staging directory next to `target` and returns a fresh - * staging path in it. The directory is created private (0700) with its + * staging path in it. The directory is made private (0700), as far as the + * filesystem stores Unix permissions, with its * self-ignoring `.gitignore`; an existing entry there that is a link, not a - * directory, not the user's own or open to group or others is refused with - * `refusal()`, so a staging copy is never created through it. Old leftovers + * directory, or not the user's own is refused with `refusal()`, so a staging + * copy is never created through it (see {@link admitStagingDirectory}). Old leftovers * of this target are swept first. */ export async function preparePrivateStaging(opts: { @@ -170,22 +198,15 @@ export async function preparePrivateStaging(opts: { readonly now?: number; }): Promise { const directory = join(dirname(opts.target), PRIVATE_STAGING_DIRNAME); - const created = await mkdir(directory, { mode: 0o700 }).then( - () => true, - (error: unknown) => { - if (!isExisting(error)) { - throw error; - } - return false; + await mkdir(directory, { mode: 0o700 }).catch((error: unknown) => { + if (!isExisting(error)) { + throw error; } - ); - if (created) { - // The umask can only remove bits: a restrictive one would leave a - // directory the owner cannot create files in. - await chmod(directory, 0o700); - } - const identity = await lstat(directory).catch(() => null); - if (!(identity && isPrivateDirectory(identity))) { + }); + // Sets 0700 on a new directory too: the umask can only remove bits, and + // a restrictive one would leave a directory the owner cannot use. + const identity = await admitStagingDirectory(directory); + if (!identity) { throw opts.refusal(); } await ensureSelfIgnore({ directory, refusal: opts.refusal }); @@ -202,7 +223,7 @@ export async function preparePrivateStaging(opts: { recheck: async () => { const current = await lstat(directory).catch(() => null); if ( - !(current && isPrivateDirectory(current)) || + !(current && isOwnDirectory(current)) || current.dev !== identity.dev || current.ino !== identity.ino ) { diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 73116f62b..c49ca2f39 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -1,5 +1,5 @@ import { constants, type Stats } from "node:fs"; -import { lstat, open, readdir } from "node:fs/promises"; +import { lstat, open, readdir, stat } from "node:fs/promises"; import { basename, dirname, @@ -86,12 +86,25 @@ export type SecretsHygieneFix = | { readonly kind: "remove_leftover"; readonly leftover: StagingLeftover } | { readonly kind: "remove_mux"; readonly stale: StaleMuxEnvironment }; +/** A directory's identity, captured at inspection and checked before a fix. */ +export type DirectoryGuard = { + readonly path: string; + readonly dev: number; + readonly ino: number; +}; + export type SecretsHygieneFinding = { readonly status: "warn" | "error"; readonly path: string; /** Value-free; names the file and what it holds. */ readonly message: string; readonly fix: SecretsHygieneFix | null; + /** + * The directory `fix` changes, as inspected. A fix is skipped when that + * path no longer names the same directory, so a directory replaced by a + * link after inspection is never written through. + */ + readonly guard?: DirectoryGuard; }; /** One file or directory the inspection covers, labelled for messages. */ @@ -143,12 +156,16 @@ function holdsForProjectFile(name: string): Holds | null { return null; } -async function projectCandidates( - project: NonNullable -): Promise { - const { projectRoot, projectDir } = project; +async function projectCandidates(opts: { + readonly project: NonNullable; + /** False when `.hack` is a link: its files are not listed. */ + readonly inspectProjectDir: boolean; +}): Promise { + const { projectRoot, projectDir } = opts.project; const hackGitignore = resolve(projectDir, ".gitignore"); - const names = await readdir(projectDir).catch(() => [] as string[]); + const names = opts.inspectProjectDir + ? await readdir(projectDir).catch(() => [] as string[]) + : []; const candidates: Candidate[] = names.flatMap((name): Candidate[] => { const holds = holdsForProjectFile(name); return holds @@ -386,19 +403,39 @@ export async function inspectSecretsHygiene( const now = scope.now ?? Date.now(); const findings: SecretsHygieneFinding[] = []; if (scope.project) { - for (const candidate of await projectCandidates(scope.project)) { + // Env writers refuse a linked `.hack`; so does this inspection, so no + // fix can reach files outside the project through it. + const projectDir = await lstat(scope.project.projectDir).catch(() => null); + const inspectProjectDir = + projectDir?.isDirectory() === true && !projectDir.isSymbolicLink(); + if (projectDir && !inspectProjectDir) { + findings.push({ + status: "error", + path: scope.project.projectDir, + message: `${shown(scope.project.projectDir, scope.project)} is a symbolic link or not a directory; Hack does not read, write or fix env files through it. Replace it with a regular directory.`, + fix: null, + }); + } + for (const candidate of await projectCandidates({ + project: scope.project, + inspectProjectDir, + })) { findings.push( ...(await candidateFindings({ candidate, project: scope.project })) ); } + if (inspectProjectDir) { + findings.push( + ...(await leftoverFindings({ + directory: scope.project.projectDir, + names: [".env", "hack.env.local.yaml"], + accept: HACK_DIR_STAGED, + now, + project: scope.project, + })) + ); + } findings.push( - ...(await leftoverFindings({ - directory: scope.project.projectDir, - names: [".env", "hack.env.local.yaml"], - accept: HACK_DIR_STAGED, - now, - project: scope.project, - })), ...(await leftoverFindings({ directory: scope.project.projectRoot, names: [PROJECT_ENV_KEY_FILENAME], @@ -459,7 +496,47 @@ export async function inspectSecretsHygiene( fix: { kind: "remove_mux", stale }, }); } - return findings; + return await Promise.all(findings.map(withGuard)); +} + +/** The directory a fix changes. */ +function fixDirectory(fix: SecretsHygieneFix): string { + switch (fix.kind) { + case "chmod": + return dirname(fix.path); + case "ignore": + return dirname(fix.gitignorePath); + case "remove_leftover": + return dirname(fix.leftover.path); + default: + return dirname(fix.stale.root); + } +} + +async function withGuard( + finding: SecretsHygieneFinding +): Promise { + if (!finding.fix) { + return finding; + } + const path = fixDirectory(finding.fix); + const info = await stat(path).catch(() => null); + // A directory that can't be identified gets no fix. + return info?.isDirectory() + ? { ...finding, guard: { path, dev: info.dev, ino: info.ino } } + : { ...finding, fix: null }; +} + +async function guardHolds(guard: DirectoryGuard | undefined): Promise { + if (!guard) { + return false; + } + const info = await stat(guard.path).catch(() => null); + return ( + info?.isDirectory() === true && + info.dev === guard.dev && + info.ino === guard.ino + ); } /** Tightens a file in place through a no-follow handle, if it is still the one found. */ @@ -526,7 +603,9 @@ export async function applySecretsHygieneFixes(opts: { done.add(key); let changed = false; try { - if (fix.kind === "chmod") { + if (!(await guardHolds(finding.guard))) { + changed = false; + } else if (fix.kind === "chmod") { changed = await tightenMode(fix); } else if (fix.kind === "ignore") { changed = await addIgnoreEntry(fix); diff --git a/tests/fixtures/private-staging-noperm-worker.ts b/tests/fixtures/private-staging-noperm-worker.ts new file mode 100644 index 000000000..e489d937b --- /dev/null +++ b/tests/fixtures/private-staging-noperm-worker.ts @@ -0,0 +1,35 @@ +import { mock } from "bun:test"; +import * as fs from "node:fs/promises"; + +/** + * Emulates a filesystem without Unix permissions (ExFAT, FAT): every new + * directory reports a mount-wide 0777 mode, and chmod changes nothing. Runs + * one private staging preparation for the target in argv and prints + * `admitted` or `refused`. Only temporary paths from the parent are used. + */ +const [target] = process.argv.slice(2); +if (!target) { + throw new Error("Missing target"); +} +const { mkdir, chmod } = fs; +mock.module("node:fs/promises", () => ({ + ...fs, + mkdir: async (path: string, options?: { mode?: number }) => { + const made = await mkdir(path, options); + await chmod(path, 0o777); + return made; + }, + chmod: async () => undefined, +})); +const { preparePrivateStaging } = await import( + "../../src/lib/private-staging.ts" +); +try { + await preparePrivateStaging({ + target, + refusal: () => new Error("refused"), + }); + process.stdout.write("admitted\n"); +} catch { + process.stdout.write("refused\n"); +} diff --git a/tests/private-staging.test.ts b/tests/private-staging.test.ts index edc35f113..dd574190c 100644 --- a/tests/private-staging.test.ts +++ b/tests/private-staging.test.ts @@ -181,7 +181,7 @@ test("old leftovers are swept by the next staging write; recent ones are kept", ); }); -test("a linked or shared staging directory is refused before anything is staged", async () => { +test("a linked staging directory is refused; a loose one of the user's own is made private", async () => { const root = await tempDir(); const elsewhere = join(root, "elsewhere"); await mkdir(elsewhere); @@ -193,13 +193,14 @@ test("a linked or shared staging directory is refused before anything is staged" ).rejects.toThrow("synthetic staging refusal"); expect(await readdir(elsewhere)).toEqual([]); + // A loose staging directory of the user's own is made private, then used. const shared = join(root, "shared"); await mkdir(join(shared, PRIVATE_STAGING_DIRNAME), { recursive: true }); await chmod(join(shared, PRIVATE_STAGING_DIRNAME), 0o777); - await expect( - preparePrivateStaging({ target: join(shared, ".env"), refusal }) - ).rejects.toThrow("synthetic staging refusal"); - expect(await readdir(join(shared, PRIVATE_STAGING_DIRNAME))).toEqual([]); + await preparePrivateStaging({ target: join(shared, ".env"), refusal }); + expect( + (await lstat(join(shared, PRIVATE_STAGING_DIRNAME))).mode & 0o777 + ).toBe(0o700); }); test("a staging directory whose .gitignore does not ignore everything is refused (a committed one included)", async () => { @@ -240,3 +241,21 @@ test("concurrent first writers in a fresh directory all get a complete self-igno expect(await readdir(staging)).toEqual([".gitignore"]); expect(await readFile(join(staging, ".gitignore"), "utf8")).toBe("*\n"); }); + +test("on a filesystem without Unix permissions (ExFAT, FAT) a staging directory is still admitted", async () => { + const root = await tempDir(); + const proc = Bun.spawn( + [ + process.execPath, + join(import.meta.dir, "fixtures", "private-staging-noperm-worker.ts"), + join(root, ".env"), + ], + { stdout: "pipe", stderr: "pipe" } + ); + const out = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + expect(out).toBe("admitted\n"); + expect((await lstat(join(root, PRIVATE_STAGING_DIRNAME))).mode & 0o777).toBe( + 0o777 + ); +}); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index ce3ad4762..8ef258fcc 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -276,3 +276,49 @@ test("a store and key configured inside the project are checked against git", as expect(await readFile(keyPath, "utf8")).toBe(`${SENTINEL}\n`); expect(await readFile(storePath, "utf8")).toContain(SENTINEL); }); + +test("a .hack linked outside the project is reported and nothing behind it is inspected or fixed", async () => { + const f = await fixture(); + const outside = join(f.root, "outside-hack"); + await mkdir(outside); + const outsideEnv = join(outside, ".env"); + const outsideLeftover = join(outside, `..env.${UUID}.tmp`); + await writeFile(outsideEnv, `${SENTINEL}\n`); + await chmod(outsideEnv, 0o644); + await writeFile(outsideLeftover, `${SENTINEL}\n`, { mode: 0o600 }); + await backdate(outsideLeftover); + await rm(f.projectDir, { recursive: true }); + await symlink(outside, f.projectDir); + + const findings = await inspectSecretsHygiene(f.scope); + expect(findings).toEqual([ + expect.objectContaining({ status: "error", path: f.projectDir, fix: null }), + ]); + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect((await stat(outsideEnv)).mode & 0o777).toBe(0o644); + expect(await readFile(outsideLeftover, "utf8")).toBe(`${SENTINEL}\n`); +}); + +test("a fix is skipped when its directory was replaced after inspection", async () => { + const f = await fixture(); + // An unignored overlay: the fix appends to .hack/.gitignore. + const overlay = join(f.projectDir, ".env.qa"); + await writeFile(overlay, `QA=${SENTINEL}\n`, { mode: 0o600 }); + const findings = await inspectSecretsHygiene(f.scope); + expect(findings.map((finding) => finding.fix?.kind)).toEqual(["ignore"]); + + // Before --fix, .hack is swapped for a link to a directory elsewhere with + // its own .gitignore. A file-identity check alone would not notice: the + // ignore file is found by path. + const elsewhere = join(f.root, "elsewhere-hack"); + await mkdir(elsewhere); + const otherIgnore = join(elsewhere, ".gitignore"); + await writeFile(otherIgnore, "kept\n"); + await rm(f.projectDir, { recursive: true }); + await symlink(elsewhere, f.projectDir); + + expect( + await applySecretsHygieneFixes({ findings, report: () => undefined }) + ).toEqual({ applied: 0, skipped: 1 }); + expect(await readFile(otherIgnore, "utf8")).toBe("kept\n"); +}); From 9ff7fb08929c85a638eb836f4bd06dda19855122 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:44:44 +0000 Subject: [PATCH 09/42] fix(doctor): treat '..'-prefixed in-project names as inside the project (HACK-2165) Bot security review on 9e060a3e: a configured store or key under an in-project directory whose name begins with `..` (for example `..secrets/`) gave a relative path starting with "..", which was read as outside the project, so its git checks were skipped. Outside now means a `..` path segment or an absolute path. Co-Authored-By: Claude --- src/lib/secrets-hygiene.ts | 9 +++++++-- tests/secrets-hygiene.test.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index c49ca2f39..768d68ef3 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -133,6 +133,11 @@ const PLAINTEXT_DOTENV = PLAINTEXT_DOTENV_NAME; const HACK_DIR_STAGED = /^(\.env(\.[A-Za-z0-9_-]+)?|hack\.env\..*yaml|\.gitignore)$/; +/** Whether a `relative()` result leaves its base (`..` as a path segment). */ +function isOutside(rel: string): boolean { + return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); +} + function octal(mode: number): string { return (mode & 0o777).toString(8).padStart(4, "0"); } @@ -142,7 +147,7 @@ function shown(path: string, project: SecretsHygieneScope["project"]): string { return path; } const rel = relative(project.projectRoot, path); - return rel.startsWith("..") ? path : rel; + return isOutside(rel) ? path : rel; } /** What a file in `.hack` holds, or null when it holds no env values. */ @@ -341,7 +346,7 @@ function inProjectIgnore(opts: { return null; } const rel = relative(opts.project.projectRoot, opts.path); - if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + if (rel === "" || isOutside(rel)) { return null; } return { diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 8ef258fcc..8aed85b39 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -322,3 +322,22 @@ test("a fix is skipped when its directory was replaced after inspection", async ).toEqual({ applied: 0, skipped: 1 }); expect(await readFile(otherIgnore, "utf8")).toBe("kept\n"); }); + +test("an in-project store under a name starting with '..' is still checked against git", async () => { + const f = await fixture(); + const dir = join(f.projectRoot, "..secrets"); + await mkdir(dir); + const storePath = join(dir, "store.enc.json"); + const keyPath = join(dir, "store.key"); + await writeFile(storePath, "{}\n", { mode: 0o600 }); + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + await git(f.projectRoot, ["add", "-f", "--", keyPath, storePath]); + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath, keyPath }], + }); + expect(paths(findings)).toEqual([keyPath, storePath].sort()); + for (const finding of findings) { + expect(finding).toMatchObject({ status: "error", fix: null }); + } +}); From 553731fef3efce6c4872c3472514724ab81fb3e1 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:04:43 +0000 Subject: [PATCH 10/42] fix(doctor): fix project-configured store paths only inside the checkout or Hack home (HACK-2165) Bot security review on 9ff7fb08: a cloned repository's committed hack.config.json can point encryptedFile.path/keyPath at any file of the user's, and `hack doctor --fix` would then chmod it or remove old `...tmp` files beside it. Paths that only the project's configuration chose are now fixed, and their leftovers swept, only when their directory resolves (links followed) inside the checkout or the Hack home; elsewhere the finding is reported without a fix. The global configuration's paths are the user's own and keep their fixes. inspectSecretsHygiene is split into per-part helpers (no behavior change) to stay under the complexity limit. Co-Authored-By: Claude --- src/commands/doctor.ts | 16 ++- src/lib/secrets-hygiene.ts | 218 +++++++++++++++++++++++----------- tests/secrets-hygiene.test.ts | 52 ++++++++ 3 files changed, 215 insertions(+), 71 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 4a785481b..dcb4c0f04 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -3581,14 +3581,24 @@ async function resolveSecretsHygieneScope(opts: { const project = await findDoctorLegacyProjectContext(opts.startDir).catch( () => null ); - const stores = new Map(); + const stores = new Map(); for (const projectDir of [undefined, project?.projectDir]) { const paths = await resolveFileSecretStorePaths({ projectDir }).catch( () => null ); - if (paths) { - stores.set(`${paths.storePath}\0${paths.keyPath}`, paths); + const key = paths ? `${paths.storePath}\0${paths.keyPath}` : null; + if (!(paths && key) || stores.has(key)) { + continue; } + // Paths only a project's configuration chose (a cloned repository's + // may name any file of the user's) are fixed only inside the checkout + // or the Hack home. + stores.set( + key, + projectDir && project + ? { ...paths, fixRoots: [project.projectRoot, resolveGlobalHackDir()] } + : paths + ); } return { project: project diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 768d68ef3..7c814c0d4 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -1,5 +1,5 @@ import { constants, type Stats } from "node:fs"; -import { lstat, open, readdir, stat } from "node:fs/promises"; +import { lstat, open, readdir, realpath, stat } from "node:fs/promises"; import { basename, dirname, @@ -120,6 +120,14 @@ export type SecretsHygieneScope = { readonly stores: readonly { readonly storePath: string; readonly keyPath: string; + /** + * For paths a project's own configuration chose (it may come from a + * cloned repository): fixes and leftover sweeps apply only to files + * whose directory resolves inside one of these roots (the checkout and + * the Hack home); elsewhere the finding is reported without a fix. + * Absent for the user's global configuration. + */ + readonly fixRoots?: readonly string[]; }[]; /** Parent of terminal-session payloads; the OS temporary directory. */ readonly tmpDir?: string; @@ -138,6 +146,31 @@ function isOutside(rel: string): boolean { return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); } +/** + * Whether `path`'s directory resolves (links followed) inside one of + * `roots`; with no roots, any path qualifies. A directory that can't be + * resolved does not. + */ +async function fixAllowed(opts: { + readonly path: string; + readonly roots: readonly string[] | undefined; +}): Promise { + if (!opts.roots) { + return true; + } + const dir = await realpath(dirname(opts.path)).catch(() => null); + if (!dir) { + return false; + } + for (const root of opts.roots) { + const real = await realpath(root).catch(() => null); + if (real && (dir === real || !isOutside(relative(real, dir)))) { + return true; + } + } + return false; +} + function octal(mode: number): string { return (mode & 0o777).toString(8).padStart(4, "0"); } @@ -401,83 +434,115 @@ async function storeFindings(opts: { return findings; } -/** All findings for a scope; reads metadata, listings and git only. */ -export async function inspectSecretsHygiene( - scope: SecretsHygieneScope -): Promise { - const now = scope.now ?? Date.now(); +/** Findings for the project's `.hack`, its root key, and their leftovers. */ +async function projectFindings(opts: { + readonly project: NonNullable; + readonly now: number; +}): Promise { + const { project, now } = opts; const findings: SecretsHygieneFinding[] = []; - if (scope.project) { - // Env writers refuse a linked `.hack`; so does this inspection, so no - // fix can reach files outside the project through it. - const projectDir = await lstat(scope.project.projectDir).catch(() => null); - const inspectProjectDir = - projectDir?.isDirectory() === true && !projectDir.isSymbolicLink(); - if (projectDir && !inspectProjectDir) { - findings.push({ - status: "error", - path: scope.project.projectDir, - message: `${shown(scope.project.projectDir, scope.project)} is a symbolic link or not a directory; Hack does not read, write or fix env files through it. Replace it with a regular directory.`, - fix: null, - }); - } - for (const candidate of await projectCandidates({ - project: scope.project, - inspectProjectDir, - })) { - findings.push( - ...(await candidateFindings({ candidate, project: scope.project })) - ); - } - if (inspectProjectDir) { - findings.push( - ...(await leftoverFindings({ - directory: scope.project.projectDir, - names: [".env", "hack.env.local.yaml"], - accept: HACK_DIR_STAGED, - now, - project: scope.project, - })) - ); - } - findings.push( - ...(await leftoverFindings({ - directory: scope.project.projectRoot, - names: [PROJECT_ENV_KEY_FILENAME], - now, - project: scope.project, - })) - ); + // Env writers refuse a linked `.hack`; so does this inspection, so no fix + // can reach files outside the project through it. + const projectDir = await lstat(project.projectDir).catch(() => null); + const inspectProjectDir = + projectDir?.isDirectory() === true && !projectDir.isSymbolicLink(); + if (projectDir && !inspectProjectDir) { + findings.push({ + status: "error", + path: project.projectDir, + message: `${shown(project.projectDir, project)} is a symbolic link or not a directory; Hack does not read, write or fix env files through it. Replace it with a regular directory.`, + fix: null, + }); + } + for (const candidate of await projectCandidates({ + project, + inspectProjectDir, + })) { + findings.push(...(await candidateFindings({ candidate, project }))); } - if (scope.sharedKeyPath) { - const candidate: Candidate = { - path: scope.sharedKeyPath, - holds: "the project env key", - ignore: null, - }; + if (inspectProjectDir) { findings.push( - ...(await candidateFindings({ candidate, project: null })), ...(await leftoverFindings({ - directory: dirname(scope.sharedKeyPath), - names: [basename(scope.sharedKeyPath)], + directory: project.projectDir, + names: [".env", "hack.env.local.yaml"], + accept: HACK_DIR_STAGED, now, - project: null, + project, })) ); } + findings.push( + ...(await leftoverFindings({ + directory: project.projectRoot, + names: [PROJECT_ENV_KEY_FILENAME], + now, + project, + })) + ); + return findings; +} + +async function sharedKeyFindings(opts: { + readonly path: string; + readonly now: number; +}): Promise { + const candidate: Candidate = { + path: opts.path, + holds: "the project env key", + ignore: null, + }; + return [ + ...(await candidateFindings({ candidate, project: null })), + ...(await leftoverFindings({ + directory: dirname(opts.path), + names: [basename(opts.path)], + now: opts.now, + project: null, + })), + ]; +} + +/** A store finding, without its fix when its path is outside the store's fix roots. */ +async function withinFixRoots(opts: { + readonly finding: SecretsHygieneFinding; + readonly roots: readonly string[] | undefined; +}): Promise { + const { finding } = opts; + if ( + !finding.fix || + (await fixAllowed({ path: finding.path, roots: opts.roots })) + ) { + return finding; + } + return { + ...finding, + message: `${finding.message} --fix leaves it: the project's configuration names this path, which is outside the project and the Hack home.`, + fix: null, + }; +} + +async function allStoreFindings(opts: { + readonly scope: SecretsHygieneScope; + readonly now: number; +}): Promise { + const { scope } = opts; + const findings: SecretsHygieneFinding[] = []; const storeDirs = new Map>(); for (const store of scope.stores) { - findings.push( - ...(await storeFindings({ - store, - storeKeyFromEnvironment: scope.storeKeyFromEnvironment, - project: scope.project, - })) - ); + for (const finding of await storeFindings({ + store, + storeKeyFromEnvironment: scope.storeKeyFromEnvironment, + project: scope.project, + })) { + findings.push(await withinFixRoots({ finding, roots: store.fixRoots })); + } for (const path of [store.storePath, store.keyPath]) { - const names = storeDirs.get(dirname(path)) ?? new Set(); - names.add(basename(path)); - storeDirs.set(dirname(path), names); + // Leftovers are swept only where a fix is allowed. + if (await fixAllowed({ path, roots: store.fixRoots })) { + const names = storeDirs.get(dirname(path)) ?? new Set(); + names.add(basename(path)); + storeDirs.set(dirname(path), names); + } } } for (const [directory, names] of storeDirs) { @@ -485,11 +550,28 @@ export async function inspectSecretsHygiene( ...(await leftoverFindings({ directory, names: [...names], - now, + now: opts.now, project: null, })) ); } + return findings; +} + +/** All findings for a scope; reads metadata, listings and git only. */ +export async function inspectSecretsHygiene( + scope: SecretsHygieneScope +): Promise { + const now = scope.now ?? Date.now(); + const findings: SecretsHygieneFinding[] = [ + ...(scope.project + ? await projectFindings({ project: scope.project, now }) + : []), + ...(scope.sharedKeyPath + ? await sharedKeyFindings({ path: scope.sharedKeyPath, now }) + : []), + ...(await allStoreFindings({ scope, now })), + ]; for (const stale of await findStaleMuxEnvironments({ ...(scope.tmpDir ? { tmpDir: scope.tmpDir } : {}), now, diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 8aed85b39..af9412cca 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -341,3 +341,55 @@ test("an in-project store under a name starting with '..' is still checked again expect(finding).toMatchObject({ status: "error", fix: null }); } }); + +test("project-configured store paths outside the checkout and the Hack home are reported, never fixed", async () => { + const f = await fixture(); + const victim = join(f.root, "victim"); + await mkdir(victim); + const victimKey = join(victim, "id.key"); + const victimLeftover = join(victim, `.id.key.${UUID}.tmp`); + await writeFile(victimKey, `${SENTINEL}\n`); + await chmod(victimKey, 0o644); + await writeFile(victimLeftover, `${SENTINEL}\n`, { mode: 0o600 }); + await backdate(victimLeftover); + // The same directory reached through a link inside the project. + await symlink(victim, join(f.projectRoot, "linked")); + const roots = [f.projectRoot, f.home]; + for (const keyPath of [victimKey, join(f.projectRoot, "linked", "id.key")]) { + const scope: SecretsHygieneScope = { + ...f.scope, + stores: [ + { + storePath: join(victim, "absent.enc.json"), + keyPath, + fixRoots: roots, + }, + ], + }; + const findings = await inspectSecretsHygiene(scope); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ fix: null }); + expect(findings[0]?.message).toContain( + "outside the project and the Hack home" + ); + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect((await stat(victimKey)).mode & 0o777).toBe(0o644); + expect(await readFile(victimLeftover, "utf8")).toBe(`${SENTINEL}\n`); + } + + // Inside the Hack home, the same project-configured store is fixed. + const homeKey = join(f.home, "project.key"); + await writeFile(homeKey, `${SENTINEL}\n`); + await chmod(homeKey, 0o644); + const inside = await inspectSecretsHygiene({ + ...f.scope, + stores: [ + { + storePath: join(f.home, "absent.enc.json"), + keyPath: homeKey, + fixRoots: roots, + }, + ], + }); + expect(inside.map((finding) => finding.fix?.kind)).toEqual(["chmod"]); +}); From f00c17c178a631be33952ab9fc30fa9635ecbcca Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:19:16 +0000 Subject: [PATCH 11/42] fix(doctor): decide in-checkout store paths on resolved paths (HACK-2165) Bot security review on 553731fe: a project-configured store or key inside the checkout but spelled through an alias (a link to the checkout, or another case on a case-insensitive disk) gave a relative path leaving the root, so its git checks were skipped. Containment now compares the resolved file directory with the resolved root, and git is asked about the resolved spelling. Co-Authored-By: Claude --- src/lib/secrets-hygiene.ts | 31 +++++++++++++++++++++++++------ tests/secrets-hygiene.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 7c814c0d4..76a0089d1 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -68,6 +68,11 @@ type Candidate = { readonly projectRoot: string; readonly gitignorePath: string; readonly entry: string; + /** + * The path git is asked about, spelled relative to `projectRoot`, when it + * differs from the candidate's (a resolved alias); defaults to it. + */ + readonly gitPath?: string; } | null; }; @@ -282,7 +287,10 @@ async function gitFindings(opts: { if (!ignore) { return []; } - const target = { projectRoot: ignore.projectRoot, path: candidate.path }; + const target = { + projectRoot: ignore.projectRoot, + path: ignore.gitPath ?? candidate.path, + }; if (await isGitTrackedOrUnknownProjectPath(target)) { return [ { @@ -370,20 +378,31 @@ async function leftoverFindings(opts: { * Git metadata for a configured store or key file: one inside the project * checkout must be untracked and ignored like the project's own secrets (an * anchored entry in the root `.gitignore`); one outside it is exempt. + * Containment is decided on resolved paths (the file's directory and the + * root, links followed and, on a case-insensitive disk, in their stored + * case), so an alias spelling of an in-checkout path is still checked; git + * is then asked about that resolved spelling. */ -function inProjectIgnore(opts: { +async function inProjectIgnore(opts: { readonly path: string; readonly project: SecretsHygieneScope["project"]; -}): Candidate["ignore"] { +}): Promise { if (!opts.project) { return null; } - const rel = relative(opts.project.projectRoot, opts.path); + const root = await realpath(opts.project.projectRoot).catch(() => null); + const dir = await realpath(dirname(opts.path)).catch(() => null); + if (!(root && dir)) { + return null; + } + const resolved = join(dir, basename(opts.path)); + const rel = relative(root, resolved); if (rel === "" || isOutside(rel)) { return null; } return { - projectRoot: opts.project.projectRoot, + projectRoot: root, + gitPath: resolved, gitignorePath: join(opts.project.projectRoot, ".gitignore"), entry: `/${rel.split(sep).join("/")}`, }; @@ -400,7 +419,7 @@ async function storeFindings(opts: { { path: storePath, holds: "an encrypted secret store" }, { path: keyPath, holds: "an encrypted store key" }, ] as const) { - const ignore = inProjectIgnore({ + const ignore = await inProjectIgnore({ path: candidate.path, project: opts.project, }); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index af9412cca..c14d91250 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -393,3 +393,28 @@ test("project-configured store paths outside the checkout and the Hack home are }); expect(inside.map((finding) => finding.fix?.kind)).toEqual(["chmod"]); }); + +test("an in-checkout store configured through an alias spelling is still checked against git", async () => { + const f = await fixture(); + const secrets = join(f.projectRoot, "secrets"); + await mkdir(secrets); + const keyPath = join(secrets, "store.key"); + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + await git(f.projectRoot, ["add", "-f", "--", keyPath]); + // A link outside the checkout that names the checkout root. + const alias = join(f.root, "alias"); + await symlink(f.projectRoot, alias); + const aliasedKey = join(alias, "secrets", "store.key"); + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [ + { + storePath: join(alias, "secrets", "absent.enc.json"), + keyPath: aliasedKey, + }, + ], + }); + expect(findings).toEqual([ + expect.objectContaining({ status: "error", path: aliasedKey, fix: null }), + ]); +}); From 40d6f8ee62a4de48041de1e4e20812379338619d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:20:54 +0000 Subject: [PATCH 12/42] fix(doctor): resolve a configured store file itself, not only its directory (HACK-2165) Own-reviewer round on f00c17c1: on a case-insensitive disk a store or key named `Store.key` in the configuration while git tracks `store.key` kept the configured case, so git's exact-case tracked check missed it. A regular file is now resolved itself (realpath of the file), so git is asked about its stored name; a link at the final component is still not followed. Co-Authored-By: Claude --- src/lib/secrets-hygiene.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 76a0089d1..4db55f1d6 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -378,10 +378,11 @@ async function leftoverFindings(opts: { * Git metadata for a configured store or key file: one inside the project * checkout must be untracked and ignored like the project's own secrets (an * anchored entry in the root `.gitignore`); one outside it is exempt. - * Containment is decided on resolved paths (the file's directory and the - * root, links followed and, on a case-insensitive disk, in their stored - * case), so an alias spelling of an in-checkout path is still checked; git - * is then asked about that resolved spelling. + * Containment is decided on resolved paths (the root, and the file itself + * when it is a regular file, else its directory; links followed and, where + * the platform's realpath returns it, in their stored case), so an alias + * spelling of an in-checkout path is still checked; git is then asked about + * that resolved spelling. */ async function inProjectIgnore(opts: { readonly path: string; @@ -395,7 +396,14 @@ async function inProjectIgnore(opts: { if (!(root && dir)) { return null; } - const resolved = join(dir, basename(opts.path)); + // A regular file is resolved itself, so its name is in its stored case + // too; a link at the final component is not followed (its own name is + // what a commit would carry). + const entry = await lstat(opts.path).catch(() => null); + const resolved = + entry?.isFile() === true + ? await realpath(opts.path).catch(() => join(dir, basename(opts.path))) + : join(dir, basename(opts.path)); const rel = relative(root, resolved); if (rel === "" || isOutside(rel)) { return null; From 41b07277fa0876c13dcbfb8121c72a355e172a22 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:32:45 +0000 Subject: [PATCH 13/42] fix(doctor): literal, verified ignore entries; non-blocking no-follow opens (HACK-2165) Bot review on 40d6f8ee: - A configured in-project path with gitignore metacharacters (for example `secrets[prod]/store.key`) produced an entry git reads as a pattern, so the file stayed unignored while --fix reported it repaired. Entries now escape `\ * ? [ ]`, a leading `#` or `!` and a trailing space, and an ignore fix counts as applied only when git then ignores the file. - A FIFO in place of `.hack-staging/.gitignore` made the read-only no-follow open block, hanging every private write there. That open (and the two other no-follow opens this PR adds) now uses O_NONBLOCK; the regular-file check then refuses it. Co-Authored-By: Claude --- src/lib/fs.ts | 2 +- src/lib/private-staging.ts | 7 ++++- src/lib/secrets-hygiene.ts | 49 +++++++++++++++++++++++++++-------- tests/private-staging.test.ts | 13 ++++++++++ tests/secrets-hygiene.test.ts | 27 +++++++++++++++++-- 5 files changed, 83 insertions(+), 15 deletions(-) diff --git a/src/lib/fs.ts b/src/lib/fs.ts index b7bda5014..fc0c5a738 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -576,7 +576,7 @@ async function tightenUnchangedTarget(opts: { // did; the read-time report and `hack doctor` still name it. const file = await open( target, - constants.O_RDONLY | constants.O_NOFOLLOW + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK ).catch(() => null); if (!file) { return; diff --git a/src/lib/private-staging.ts b/src/lib/private-staging.ts index 9bb914d91..ef2ad8d39 100644 --- a/src/lib/private-staging.ts +++ b/src/lib/private-staging.ts @@ -280,7 +280,12 @@ async function ensureSelfIgnore(opts: { async function isOwnSelfIgnore(path: string): Promise { let file: Awaited> | null = null; try { - file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + // Non-blocking: a FIFO here would otherwise stall the open; it is then + // refused by the regular-file check below. + file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); const info = await file.stat(); if ( !info.isFile() || diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 4db55f1d6..dd85b8914 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -87,6 +87,8 @@ export type SecretsHygieneFix = readonly kind: "ignore"; readonly gitignorePath: string; readonly entry: string; + /** Asked again after the entry is added: the fix counts only if git now ignores it. */ + readonly verify: { readonly projectRoot: string; readonly path: string }; } | { readonly kind: "remove_leftover"; readonly leftover: StagingLeftover } | { readonly kind: "remove_mux"; readonly stale: StaleMuxEnvironment }; @@ -176,6 +178,23 @@ async function fixAllowed(opts: { return false; } +/** + * A file or directory name as a literal `.gitignore` pattern: glob and + * escape characters (`\\ * ? [ ]`) are escaped everywhere, a leading `#` or + * `!` is escaped so the line is neither a comment nor a negation, and a + * trailing space is escaped so git keeps it. + */ +export function gitignoreLiteral(name: string): string { + return name + .replace(GITIGNORE_GLOB, "\\$&") + .replace(GITIGNORE_LEADING, "\\$&") + .replace(GITIGNORE_TRAILING_SPACE, "\\ "); +} + +const GITIGNORE_GLOB = /[\\*?[\]]/g; +const GITIGNORE_LEADING = /^[#!]/; +const GITIGNORE_TRAILING_SPACE = / $/; + function octal(mode: number): string { return (mode & 0o777).toString(8).padStart(4, "0"); } @@ -216,7 +235,11 @@ async function projectCandidates(opts: { { path: join(projectDir, name), holds, - ignore: { projectRoot, gitignorePath: hackGitignore, entry: name }, + ignore: { + projectRoot, + gitignorePath: hackGitignore, + entry: gitignoreLiteral(name), + }, }, ] : []; @@ -227,7 +250,7 @@ async function projectCandidates(opts: { ignore: { projectRoot, gitignorePath: join(projectRoot, ".gitignore"), - entry: PROJECT_ENV_KEY_FILENAME, + entry: gitignoreLiteral(PROJECT_ENV_KEY_FILENAME), }, }); return candidates; @@ -312,6 +335,7 @@ async function gitFindings(opts: { kind: "ignore", gitignorePath: ignore.gitignorePath, entry: ignore.entry, + verify: target, }, }, ]; @@ -412,7 +436,7 @@ async function inProjectIgnore(opts: { projectRoot: root, gitPath: resolved, gitignorePath: join(opts.project.projectRoot, ".gitignore"), - entry: `/${rel.split(sep).join("/")}`, + entry: `/${rel.split(sep).map(gitignoreLiteral).join("/")}`, }; } @@ -657,7 +681,10 @@ async function guardHolds(guard: DirectoryGuard | undefined): Promise { async function tightenMode( fix: Extract ): Promise { - const file = await open(fix.path, constants.O_RDONLY | constants.O_NOFOLLOW); + const file = await open( + fix.path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); try { const current = await file.stat(); if ( @@ -682,13 +709,13 @@ async function addIgnoreEntry( if (existing && !existing.isFile()) { return false; } - return ( - await ensureGitignoreEntry({ - gitignorePath: fix.gitignorePath, - entry: fix.entry, - comment: "# machine-local secrets (hack doctor --fix)", - }) - ).changed; + await ensureGitignoreEntry({ + gitignorePath: fix.gitignorePath, + entry: fix.entry, + comment: "# machine-local secrets (hack doctor --fix)", + }); + // Applied only when git now ignores the file (a later rule may re-include it). + return (await isGitIgnoredProjectEnvPath(fix.verify)) === true; } /** diff --git a/tests/private-staging.test.ts b/tests/private-staging.test.ts index dd574190c..e46504adb 100644 --- a/tests/private-staging.test.ts +++ b/tests/private-staging.test.ts @@ -259,3 +259,16 @@ test("on a filesystem without Unix permissions (ExFAT, FAT) a staging directory 0o777 ); }); + +test("a FIFO in place of the staging .gitignore is refused, not waited on", async () => { + const root = await tempDir(); + const staging = join(root, PRIVATE_STAGING_DIRNAME); + await mkdir(staging, { mode: 0o700 }); + const mkfifo = Bun.spawn(["mkfifo", join(staging, ".gitignore")]); + expect(await mkfifo.exited).toBe(0); + const started = Date.now(); + await expect( + preparePrivateStaging({ target: join(root, ".env"), refusal }) + ).rejects.toThrow("synthetic staging refusal"); + expect(Date.now() - started).toBeLessThan(5000); +}, 10_000); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index c14d91250..1d8a0a2ce 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -144,7 +144,7 @@ test("an unignored overlay gets an ignore entry; a tracked layer and a link are const findings = await inspectSecretsHygiene(f.scope); const byPath = new Map(findings.map((finding) => [finding.path, finding])); - expect(byPath.get(overlay)?.fix).toEqual({ + expect(byPath.get(overlay)?.fix).toMatchObject({ kind: "ignore", gitignorePath: join(f.projectDir, ".gitignore"), entry: ".env.qa", @@ -265,7 +265,7 @@ test("a store and key configured inside the project are checked against git", as const findings = await inspectSecretsHygiene(scope); const byPath = new Map(findings.map((finding) => [finding.path, finding])); expect(byPath.get(keyPath)).toMatchObject({ status: "error", fix: null }); - expect(byPath.get(storePath)?.fix).toEqual({ + expect(byPath.get(storePath)?.fix).toMatchObject({ kind: "ignore", gitignorePath: join(f.projectRoot, ".gitignore"), entry: "/secrets/store.enc.json", @@ -418,3 +418,26 @@ test("an in-checkout store configured through an alias spelling is still checked expect.objectContaining({ status: "error", path: aliasedKey, fix: null }), ]); }); + +test("an ignore entry for a path with glob characters is literal and verified (git ignores the file after --fix)", async () => { + const f = await fixture(); + const dir = join(f.projectRoot, "secrets[prod]"); + await mkdir(dir); + const keyPath = join(dir, "store.key"); + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + const scope: SecretsHygieneScope = { + ...f.scope, + stores: [{ storePath: join(dir, "absent.enc.json"), keyPath }], + }; + const findings = await inspectSecretsHygiene(scope); + expect(findings.map((finding) => finding.fix)).toEqual([ + expect.objectContaining({ + kind: "ignore", + entry: "/secrets\\[prod\\]/store.key", + }), + ]); + expect( + await applySecretsHygieneFixes({ findings, report: () => undefined }) + ).toEqual({ applied: 1, skipped: 0 }); + expect(await inspectSecretsHygiene(scope)).toEqual([]); +}); From 98619482ec8c75e04d2d9026e6ccd72f2bb121ed Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:36:00 +0000 Subject: [PATCH 14/42] fix(doctor): no automatic ignore entry for a name ending in a space (HACK-2165) Own-reviewer round on 41b07277: the ignore-file writer trims entries, which drops an escaped trailing space and would leave a pattern that never matches. Such a store or key is now reported with an "add it by hand" note and no fix. The message also names the ignore file by its absolute path when the project root was reached through a link. Co-Authored-By: Claude --- src/lib/secrets-hygiene.ts | 15 ++++++++++++++- tests/secrets-hygiene.test.ts | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index dd85b8914..de05becee 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -326,11 +326,24 @@ async function gitFindings(opts: { } if ((await isGitIgnoredProjectEnvPath(target)) === false) { const rel = relative(ignore.projectRoot, ignore.gitignorePath); + const where = isOutside(rel) ? ignore.gitignorePath : rel; + // A name ending in a space can't be written as an entry that survives + // the ignore-file writer's trimming; leave that one to the user. + if (ignore.entry.endsWith("\\ ")) { + return [ + { + status: "warn", + path: candidate.path, + message: `git does not ignore ${name}, which holds ${candidate.holds}; its name ends with a space, so add an ignore entry for it to ${where} by hand.`, + fix: null, + }, + ]; + } return [ { status: "warn", path: candidate.path, - message: `git does not ignore ${name}, which holds ${candidate.holds}; --fix adds '${ignore.entry}' to ${rel}.`, + message: `git does not ignore ${name}, which holds ${candidate.holds}; --fix adds '${ignore.entry}' to ${where}.`, fix: { kind: "ignore", gitignorePath: ignore.gitignorePath, diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 1d8a0a2ce..55cd12b6e 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -441,3 +441,22 @@ test("an ignore entry for a path with glob characters is literal and verified (g ).toEqual({ applied: 1, skipped: 0 }); expect(await inspectSecretsHygiene(scope)).toEqual([]); }); + +test("a store whose name ends with a space is reported with no automatic ignore entry", async () => { + const f = await fixture(); + const keyPath = join(f.projectRoot, "store.key "); + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath: join(f.projectRoot, "absent.enc.json"), keyPath }], + }); + expect(findings).toEqual([ + expect.objectContaining({ status: "warn", path: keyPath, fix: null }), + ]); + expect(findings[0]?.message).toContain("by hand"); + const before = await readFile(join(f.projectRoot, ".gitignore"), "utf8"); + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect(await readFile(join(f.projectRoot, ".gitignore"), "utf8")).toBe( + before + ); +}); From e0cbce3beaaeaf92a8fc42619e6cba1551372087 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:43:07 +0000 Subject: [PATCH 15/42] fix(env): set the staged ignore file's mode explicitly (HACK-2165) Bot review on 98619482: under a restrictive umask the staged `.hack-staging/.gitignore` could be created without owner read, so the read-back check refused every private write in that directory. Its mode is now set to 0600 through the open handle before it is written, as the other private files already are. Co-Authored-By: Claude --- src/lib/private-staging.ts | 3 +++ tests/private-staging.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/lib/private-staging.ts b/src/lib/private-staging.ts index ef2ad8d39..65fec2fc0 100644 --- a/src/lib/private-staging.ts +++ b/src/lib/private-staging.ts @@ -262,6 +262,9 @@ async function ensureSelfIgnore(opts: { constants.O_WRONLY, 0o600 ); + // The umask can only remove bits; a restrictive one would leave a file + // its owner cannot read back for the check below. + await file.chmod(0o600); await file.writeFile(SELF_IGNORE, "utf8"); await file.sync(); await file.close(); diff --git a/tests/private-staging.test.ts b/tests/private-staging.test.ts index e46504adb..3fa480738 100644 --- a/tests/private-staging.test.ts +++ b/tests/private-staging.test.ts @@ -272,3 +272,19 @@ test("a FIFO in place of the staging .gitignore is refused, not waited on", asyn ).rejects.toThrow("synthetic staging refusal"); expect(Date.now() - started).toBeLessThan(5000); }, 10_000); + +test.skipIf(process.getuid?.() === 0)( + "a restrictive umask still leaves a readable self-ignore and a usable staging directory", + async () => { + const root = await tempDir(); + const previous = process.umask(0o377); + try { + await preparePrivateStaging({ target: join(root, ".env"), refusal }); + } finally { + process.umask(previous); + } + const ignore = join(root, PRIVATE_STAGING_DIRNAME, ".gitignore"); + expect((await lstat(ignore)).mode & 0o777).toBe(0o600); + expect(await readFile(ignore, "utf8")).toBe("*\n"); + } +); From 1541d203e9475d402c83f3b19124231c0ab57fa9 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:12:54 +0000 Subject: [PATCH 16/42] fix(doctor): never write an ignore entry for a name with a line break (HACK-2165) Bot security review on e0cbce3b: a committed configuration could name an in-project store path containing a newline and `!`, so --fix wrote a second, negating ignore line that un-ignored a real key (verification then failed but the line stayed). A name with any control character now gets no ignore fix, only an "add it by hand" report, so an entry is always exactly one literal pattern line. Co-Authored-By: Claude --- src/lib/secrets-hygiene.ts | 15 +++++++++++---- tests/secrets-hygiene.test.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index de05becee..d9585bc2d 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -191,6 +191,8 @@ export function gitignoreLiteral(name: string): string { .replace(GITIGNORE_TRAILING_SPACE, "\\ "); } +// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what this pattern rejects. +const UNWRITABLE_IGNORE_ENTRY = /[\u0000-\u001f\u007f]/; const GITIGNORE_GLOB = /[\\*?[\]]/g; const GITIGNORE_LEADING = /^[#!]/; const GITIGNORE_TRAILING_SPACE = / $/; @@ -327,14 +329,19 @@ async function gitFindings(opts: { if ((await isGitIgnoredProjectEnvPath(target)) === false) { const rel = relative(ignore.projectRoot, ignore.gitignorePath); const where = isOutside(rel) ? ignore.gitignorePath : rel; - // A name ending in a space can't be written as an entry that survives - // the ignore-file writer's trimming; leave that one to the user. - if (ignore.entry.endsWith("\\ ")) { + // An entry must be exactly one literal pattern line. A name holding a + // line break or other control character (a committed configuration can + // choose one, to add a second, negating line) or ending in a space + // (trimmed by the ignore-file writer) is left to the user. + if ( + UNWRITABLE_IGNORE_ENTRY.test(ignore.entry) || + ignore.entry.endsWith("\\ ") + ) { return [ { status: "warn", path: candidate.path, - message: `git does not ignore ${name}, which holds ${candidate.holds}; its name ends with a space, so add an ignore entry for it to ${where} by hand.`, + message: `git does not ignore ${name}, which holds ${candidate.holds}; its name has a line break, a control character or a trailing space, so add an ignore entry for it to ${where} by hand.`, fix: null, }, ]; diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 55cd12b6e..97364934d 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -12,7 +12,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { STAGING_LEFTOVER_MIN_AGE_MS } from "../src/lib/private-staging.ts"; import { applySecretsHygieneFixes, @@ -460,3 +460,30 @@ test("a store whose name ends with a space is reported with no automatic ignore before ); }); + +test("a configured path with a line break never becomes an ignore entry", async () => { + const f = await fixture(); + const dir = join(f.projectRoot, "secret"); + await mkdir(dir); + await writeFile(join(dir, "store.key"), `${SENTINEL}\n`, { mode: 0o600 }); + await writeFile( + join(f.projectRoot, ".gitignore"), + ".hack.secret.key\n/secret/store.key\n" + ); + // An in-project name that would add a negating line for the real key. + const crafted = join(f.projectRoot, "x\n!", "secret", "store.key"); + await mkdir(dirname(crafted), { recursive: true }); + await writeFile(crafted, `${SENTINEL}\n`, { mode: 0o600 }); + const before = await readFile(join(f.projectRoot, ".gitignore"), "utf8"); + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath: join(dir, "absent.enc.json"), keyPath: crafted }], + }); + expect(findings).toEqual([ + expect.objectContaining({ path: crafted, fix: null }), + ]); + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect(await readFile(join(f.projectRoot, ".gitignore"), "utf8")).toBe( + before + ); +}); From 3da5fd4b26f9cf6ed1a350cb46f34e56fe17c7dd Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:13:00 +0000 Subject: [PATCH 17/42] style(doctor): drop an unused lint suppression (HACK-2165) Co-Authored-By: Claude --- src/lib/secrets-hygiene.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index d9585bc2d..b44ccb906 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -191,7 +191,6 @@ export function gitignoreLiteral(name: string): string { .replace(GITIGNORE_TRAILING_SPACE, "\\ "); } -// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what this pattern rejects. const UNWRITABLE_IGNORE_ENTRY = /[\u0000-\u001f\u007f]/; const GITIGNORE_GLOB = /[\\*?[\]]/g; const GITIGNORE_LEADING = /^[#!]/; From 1fdf767b22d267558a864770515be3fdbdc6ebe3 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:30:39 +0000 Subject: [PATCH 18/42] fix(doctor): no copyable commands with repository-chosen names; escape control characters (HACK-2165) Bot security review on bc1b4930: the read-time loose-layer warning suggested `chmod 600 .hack/` with the name unquoted, so a force-added name such as `hack.env.;cmd;.local.yaml` turned the remedy into command execution when copied. The warning now recommends only `hack doctor --fix`. Every path doctor's secrets hygiene and the warning print has control and formatting characters shown as `\u{..}`, and the one remaining command (`git rm --cached`) shell- quotes the name, or is omitted when the name holds control characters. (Also closes the own-reviewer note about terminal sequences in configured paths.) Co-Authored-By: Claude --- src/lib/env-layer-modes.ts | 25 +++++++++++++++++-- src/lib/secrets-hygiene.ts | 46 +++++++++++++++++++++++++++-------- tests/env-layer-modes.test.ts | 20 +++++++++++++++ tests/secrets-hygiene.test.ts | 18 ++++++++++++++ 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/lib/env-layer-modes.ts b/src/lib/env-layer-modes.ts index 1f8ec623f..d49408a1d 100644 --- a/src/lib/env-layer-modes.ts +++ b/src/lib/env-layer-modes.ts @@ -29,13 +29,34 @@ const PLAINTEXT_DOTENV = PLAINTEXT_DOTENV_NAME; const reported = new Set(); -/** The warning for one loose file; names the file and its mode, never a value. */ +// Control and formatting characters a terminal could act on: C0, DEL, C1, +// line/paragraph separators and bidirectional overrides. +const UNPRINTABLE = + /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g; + +/** + * A file name or path for terminal output: every control or formatting + * character is shown as `\u{…}`, so a name a repository chose can neither + * inject terminal sequences nor fake extra lines (HACK-2165). + */ +export function displayPath(path: string): string { + return path.replace( + UNPRINTABLE, + (char) => `\\u{${(char.codePointAt(0) ?? 0).toString(16)}}` + ); +} + +/** + * The warning for one loose file; names the file and its mode, never a + * value. It offers no copyable shell command: the name may come from a + * repository, so the remedy is `hack doctor --fix`. + */ export function looseEnvLayerWarning(opts: { readonly name: string; readonly mode: number; }): string { const mode = (opts.mode & 0o777).toString(8).padStart(4, "0"); - return `WARN: .hack/${opts.name} holds machine-local env values and is readable by other users (mode ${mode}). Hack makes it 0600 on its next write; to fix it now, run 'hack doctor --fix' or 'chmod 600 .hack/${opts.name}'.\n`; + return `WARN: .hack/${displayPath(opts.name)} holds machine-local env values and is readable by other users (mode ${mode}). Hack makes it 0600 on its next write; to fix it now, run 'hack doctor --fix'.\n`; } /** diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index b44ccb906..0380f3ef5 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -17,6 +17,7 @@ import { type StaleMuxEnvironment, } from "../mux/private-environment.ts"; import { + displayPath, MACHINE_LOCAL_LAYER_NAME, PLAINTEXT_DOTENV_NAME, } from "./env-layer-modes.ts"; @@ -196,11 +197,36 @@ const GITIGNORE_GLOB = /[\\*?[\]]/g; const GITIGNORE_LEADING = /^[#!]/; const GITIGNORE_TRAILING_SPACE = / $/; +/** POSIX shell single-quoting. */ +function shellQuote(text: string): string { + return `'${text.replaceAll("'", "'\\''")}'`; +} + +/** + * How to untrack a file: a `git rm --cached` command with the name + * shell-quoted, or, when the name holds characters no command should carry + * to a terminal, the instruction without a command. + */ +function untrackAdvice(path: string, projectRoot: string): string { + const rel = relative(projectRoot, path); + return displayPath(rel) === rel + ? `untrack it with git rm --cached -- ${shellQuote(rel)} (from the project root)` + : "untrack it with git rm --cached (its name holds control characters, so no command is printed)"; +} + function octal(mode: number): string { return (mode & 0o777).toString(8).padStart(4, "0"); } +/** A path for messages: project-relative when inside, never with control characters. */ function shown(path: string, project: SecretsHygieneScope["project"]): string { + return displayPath(rawShown(path, project)); +} + +function rawShown( + path: string, + project: SecretsHygieneScope["project"] +): string { if (!project) { return path; } @@ -320,7 +346,7 @@ async function gitFindings(opts: { { status: "error", path: candidate.path, - message: `git tracks ${name} (or cannot say whether it does), and it holds ${candidate.holds}: untrack it with 'git rm --cached -- ${name}', then treat what it held as exposed.`, + message: `git tracks ${name} (or cannot say whether it does), and it holds ${candidate.holds}: ${untrackAdvice(candidate.path, ignore.projectRoot)}, then treat what it held as exposed.`, fix: null, }, ]; @@ -340,7 +366,7 @@ async function gitFindings(opts: { { status: "warn", path: candidate.path, - message: `git does not ignore ${name}, which holds ${candidate.holds}; its name has a line break, a control character or a trailing space, so add an ignore entry for it to ${where} by hand.`, + message: `git does not ignore ${name}, which holds ${candidate.holds}; its name has a line break, a control character or a trailing space, so add an ignore entry for it to ${displayPath(where)} by hand.`, fix: null, }, ]; @@ -349,7 +375,7 @@ async function gitFindings(opts: { { status: "warn", path: candidate.path, - message: `git does not ignore ${name}, which holds ${candidate.holds}; --fix adds '${ignore.entry}' to ${where}.`, + message: `git does not ignore ${name}, which holds ${candidate.holds}; --fix adds '${displayPath(ignore.entry)}' to ${displayPath(where)}.`, fix: { kind: "ignore", gitignorePath: ignore.gitignorePath, @@ -497,7 +523,7 @@ async function storeFindings(opts: { findings.push({ status: keychainMayHoldKey ? "warn" : "error", path: storePath, - message: `Encrypted secret store ${storePath}: store present, key missing, cannot be read (no key file at ${keyPath}; HACK_SECRETS_FILE_KEY is not set).${keychain} Restore the key file to read it. Hack never deletes a store or a key; remove the store yourself once you no longer need its values.`, + message: `Encrypted secret store ${displayPath(storePath)}: store present, key missing, cannot be read (no key file at ${displayPath(keyPath)}; HACK_SECRETS_FILE_KEY is not set).${keychain} Restore the key file to read it. Hack never deletes a store or a key; remove the store yourself once you no longer need its values.`, fix: null, }); } @@ -649,7 +675,7 @@ export async function inspectSecretsHygiene( findings.push({ status: "warn", path: stale.root, - message: `${stale.root} holds env values staged for a terminal session that never ran (older than ${MUX_ENV_TTL_MS / 60_000} minutes); --fix removes it.`, + message: `${displayPath(stale.root)} holds env values staged for a terminal session that never ran (older than ${MUX_ENV_TTL_MS / 60_000} minutes); --fix removes it.`, fix: { kind: "remove_mux", stale }, }); } @@ -783,7 +809,7 @@ export async function applySecretsHygieneFixes(opts: { } else { skipped += 1; opts.report( - `Left unchanged (it changed or could not be updated): ${finding.path}` + `Left unchanged (it changed or could not be updated): ${displayPath(finding.path)}` ); } } @@ -797,12 +823,12 @@ function describeApplied(opts: { const { fix } = opts; switch (fix.kind) { case "chmod": - return `Set mode ${octal(fix.mode)} on ${opts.path}`; + return `Set mode ${octal(fix.mode)} on ${displayPath(opts.path)}`; case "ignore": - return `Added '${fix.entry}' to ${fix.gitignorePath}`; + return `Added '${displayPath(fix.entry)}' to ${displayPath(fix.gitignorePath)}`; case "remove_leftover": - return `Removed leftover staging copy ${opts.path}`; + return `Removed leftover staging copy ${displayPath(opts.path)}`; default: - return `Removed stale terminal-session env payload ${opts.path}`; + return `Removed stale terminal-session env payload ${displayPath(opts.path)}`; } } diff --git a/tests/env-layer-modes.test.ts b/tests/env-layer-modes.test.ts index ce7db6a36..50ee9a1bc 100644 --- a/tests/env-layer-modes.test.ts +++ b/tests/env-layer-modes.test.ts @@ -46,3 +46,23 @@ test("loose machine-local env files are reported once, by name and mode only (HA await reportLooseEnvLayerModes({ projectDir, write }); expect(written).toHaveLength(2); }); + +test("a loose layer's name never reaches a copyable command or the terminal raw (HACK-2165)", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "hack-layer-modes-names-")); + dirs.push(projectDir); + const shell = "hack.env.;printf PWNED>proof;.local.yaml"; + const withEscape = "hack.env.\u001b[31mred.local.yaml"; + for (const name of [shell, withEscape]) { + await writeFile(join(projectDir, name), "x: 1\n", { mode: 0o644 }); + } + const written: string[] = []; + await reportLooseEnvLayerModes({ + projectDir, + write: (text) => written.push(text), + }); + const output = written.join(""); + expect(output).not.toContain("chmod"); + expect(output).toContain("hack doctor --fix"); + expect(output).not.toContain("\u001b"); + expect(output).toContain("\\u{1b}[31mred"); +}); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 97364934d..c6a8a9726 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -487,3 +487,21 @@ test("a configured path with a line break never becomes an ignore entry", async before ); }); + +test("names in findings are shell-quoted in commands and never printed with control characters", async () => { + const f = await fixture(); + const quoted = join(f.projectDir, "hack.env.it's;x.local.yaml"); + const escaped = join(f.projectDir, "hack.env.\u001b]0;t\u0007.local.yaml"); + for (const path of [quoted, escaped]) { + await writeFile(path, "x: 1\n", { mode: 0o600 }); + await git(f.projectRoot, ["add", "-f", "--", path]); + } + const findings = await inspectSecretsHygiene(f.scope); + const text = findings.map((finding) => finding.message).join("\n"); + expect(text).toContain( + "git rm --cached -- '.hack/hack.env.it'\\''s;x.local.yaml'" + ); + expect(text).not.toContain("\u001b"); + expect(text).not.toContain("\u0007"); + expect(text).toContain("no command is printed"); +}); From bcc96481b342c17e13aa9f3509a30fe66cd2cc85 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:33:24 +0000 Subject: [PATCH 19/42] fix(doctor): untrack advice names the path git tracks (HACK-2165) Own-reviewer round on 1fdf767b: for a store configured through an alias of the checkout, the untrack command used the alias spelling relative to the resolved root (`../alias/...`), which git rejects. It now uses the resolved path git was asked about. Co-Authored-By: Claude --- src/lib/secrets-hygiene.ts | 2 +- tests/secrets-hygiene.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 0380f3ef5..43429b925 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -346,7 +346,7 @@ async function gitFindings(opts: { { status: "error", path: candidate.path, - message: `git tracks ${name} (or cannot say whether it does), and it holds ${candidate.holds}: ${untrackAdvice(candidate.path, ignore.projectRoot)}, then treat what it held as exposed.`, + message: `git tracks ${name} (or cannot say whether it does), and it holds ${candidate.holds}: ${untrackAdvice(ignore.gitPath ?? candidate.path, ignore.projectRoot)}, then treat what it held as exposed.`, fix: null, }, ]; diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index c6a8a9726..65785fbe1 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -417,6 +417,10 @@ test("an in-checkout store configured through an alias spelling is still checked expect(findings).toEqual([ expect.objectContaining({ status: "error", path: aliasedKey, fix: null }), ]); + // The untrack command names the path git tracks, relative to the root. + expect(findings[0]?.message).toContain( + "git rm --cached -- 'secrets/store.key'" + ); }); test("an ignore entry for a path with glob characters is literal and verified (git ignores the file after --fix)", async () => { From c08593cd74d71a44c4ab91fe6dc4531abdc31e6f Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:46:22 +0000 Subject: [PATCH 20/42] fix(doctor): ignore a configured in-checkout store path before first use (HACK-2165) Bot security review on bcc96481: a repository can configure the encrypted store and key at in-checkout paths that do not exist yet, and the hygiene check only looked at existing files, so doctor reported clean and the first secret write created both files unignored. A configured store or key path inside the checkout now gets the git checks even before the file exists (resolved through its nearest existing ancestor), so `hack doctor --fix` adds the ignore entry ahead of first use. Protecting the writer itself belongs to the encrypted store backend work (HACK-2169). Co-Authored-By: Claude --- src/lib/secrets-hygiene.ts | 37 ++++++++++++++++++++++++++++++++--- tests/secrets-hygiene.test.ts | 33 +++++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 43429b925..d30d6683e 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -391,12 +391,20 @@ async function gitFindings(opts: { async function candidateFindings(opts: { readonly candidate: Candidate; readonly project: SecretsHygieneScope["project"]; + /** + * The file is created later by its writer (a configured store or key): + * when absent, git must already ignore where it will be created, so a + * first write never produces a file git can collect. + */ + readonly beforeCreation?: boolean; }): Promise { const stats = await lstat(opts.candidate.path).catch(() => null); + const name = shown(opts.candidate.path, opts.project); if (!stats) { - return []; + return opts.beforeCreation === true && opts.candidate.ignore + ? await gitFindings({ candidate: opts.candidate, name }) + : []; } - const name = shown(opts.candidate.path, opts.project); return [ ...fileFindings({ candidate: opts.candidate, stats, name }), ...(stats.isSymbolicLink() @@ -453,6 +461,28 @@ async function leftoverFindings(opts: { * spelling of an in-checkout path is still checked; git is then asked about * that resolved spelling. */ +/** + * `path` resolved through its nearest existing ancestor (links followed), + * with the components that do not exist yet appended; null when nothing on + * the way can be resolved. + */ +async function resolveExisting(path: string): Promise { + const missing: string[] = []; + let current = path; + while (true) { + const real = await realpath(current).catch(() => null); + if (real) { + return join(real, ...missing.reverse()); + } + const parent = dirname(current); + if (parent === current) { + return null; + } + missing.push(basename(current)); + current = parent; + } +} + async function inProjectIgnore(opts: { readonly path: string; readonly project: SecretsHygieneScope["project"]; @@ -461,7 +491,7 @@ async function inProjectIgnore(opts: { return null; } const root = await realpath(opts.project.projectRoot).catch(() => null); - const dir = await realpath(dirname(opts.path)).catch(() => null); + const dir = await resolveExisting(dirname(opts.path)); if (!(root && dir)) { return null; } @@ -504,6 +534,7 @@ async function storeFindings(opts: { ...(await candidateFindings({ candidate: { ...candidate, ignore }, project: opts.project, + beforeCreation: true, })) ); } diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 65785fbe1..9b720f854 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -409,7 +409,7 @@ test("an in-checkout store configured through an alias spelling is still checked ...f.scope, stores: [ { - storePath: join(alias, "secrets", "absent.enc.json"), + storePath: join(f.home, "absent.enc.json"), keyPath: aliasedKey, }, ], @@ -431,7 +431,7 @@ test("an ignore entry for a path with glob characters is literal and verified (g await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); const scope: SecretsHygieneScope = { ...f.scope, - stores: [{ storePath: join(dir, "absent.enc.json"), keyPath }], + stores: [{ storePath: join(f.home, "absent.enc.json"), keyPath }], }; const findings = await inspectSecretsHygiene(scope); expect(findings.map((finding) => finding.fix)).toEqual([ @@ -452,7 +452,7 @@ test("a store whose name ends with a space is reported with no automatic ignore await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); const findings = await inspectSecretsHygiene({ ...f.scope, - stores: [{ storePath: join(f.projectRoot, "absent.enc.json"), keyPath }], + stores: [{ storePath: join(f.home, "absent.enc.json"), keyPath }], }); expect(findings).toEqual([ expect.objectContaining({ status: "warn", path: keyPath, fix: null }), @@ -481,7 +481,7 @@ test("a configured path with a line break never becomes an ignore entry", async const before = await readFile(join(f.projectRoot, ".gitignore"), "utf8"); const findings = await inspectSecretsHygiene({ ...f.scope, - stores: [{ storePath: join(dir, "absent.enc.json"), keyPath: crafted }], + stores: [{ storePath: join(f.home, "absent.enc.json"), keyPath: crafted }], }); expect(findings).toEqual([ expect.objectContaining({ path: crafted, fix: null }), @@ -509,3 +509,28 @@ test("names in findings are shell-quoted in commands and never printed with cont expect(text).not.toContain("\u0007"); expect(text).toContain("no command is printed"); }); + +test("an in-checkout store and key that do not exist yet are ignored before first use", async () => { + const f = await fixture(); + // Configured relative to the checkout; neither the files nor their + // directory exist yet. + const storePath = join(f.projectRoot, "secrets", "store.enc.json"); + const keyPath = join(f.projectRoot, "secrets", "store.key"); + const scope: SecretsHygieneScope = { + ...f.scope, + stores: [{ storePath, keyPath }], + }; + const findings = await inspectSecretsHygiene(scope); + expect(findings.map((finding) => finding.fix?.kind).sort()).toEqual([ + "ignore", + "ignore", + ]); + expect( + await applySecretsHygieneFixes({ findings, report: () => undefined }) + ).toEqual({ applied: 2, skipped: 0 }); + expect(await inspectSecretsHygiene(scope)).toEqual([]); + // A file created there later is already ignored. + await mkdir(join(f.projectRoot, "secrets")); + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + expect(await inspectSecretsHygiene(scope)).toEqual([]); +}); From a32574cc17a26b571dd735e27be777ba010d7cd2 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:49:08 +0000 Subject: [PATCH 21/42] fix(doctor): judge a not-yet-created store path's fix roots by where it will be created (HACK-2165) Own-reviewer round on c08593cd: fixAllowed resolved the store file's directory with realpath, which fails for a directory that does not exist yet, so a project-configured in-checkout store lost its ignore fix with a false "outside the project" note. It now resolves through the nearest existing ancestor, like the in-checkout scoping. Co-Authored-By: Claude --- src/lib/secrets-hygiene.ts | 4 +++- tests/secrets-hygiene.test.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index d30d6683e..624bae4d1 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -166,7 +166,9 @@ async function fixAllowed(opts: { if (!opts.roots) { return true; } - const dir = await realpath(dirname(opts.path)).catch(() => null); + // Through the nearest existing ancestor, so a store path whose directory + // does not exist yet is judged by where it will be created. + const dir = await resolveExisting(dirname(opts.path)); if (!dir) { return false; } diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 9b720f854..f4b3a596e 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -534,3 +534,23 @@ test("an in-checkout store and key that do not exist yet are ignored before firs await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); expect(await inspectSecretsHygiene(scope)).toEqual([]); }); + +test("a project-configured store path not created yet keeps its ignore fix (fix roots)", async () => { + const f = await fixture(); + const storePath = join(f.projectRoot, "secrets", "s.enc.json"); + const keyPath = join(f.projectRoot, "secrets", "s.key"); + const scope: SecretsHygieneScope = { + ...f.scope, + stores: [{ storePath, keyPath, fixRoots: [f.projectRoot, f.home] }], + }; + const findings = await inspectSecretsHygiene(scope); + expect(findings.map((finding) => finding.fix?.kind)).toEqual([ + "ignore", + "ignore", + ]); + for (const finding of findings) { + expect(finding.message).not.toContain("outside the project"); + } + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect(await inspectSecretsHygiene(scope)).toEqual([]); +}); From 57e248cee60ce9a41ed07386621397c458a2d70e Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:09:50 +0000 Subject: [PATCH 22/42] docs(doctor): place the gitignore step comment; key staging wording (HACK-2165) Own-reviewer round on 6e636a9c: the threat model's key-provisioning row still said the key is staged as a sibling (it is staged in `.hack-staging/`), and the `.hack/.gitignore` fix step's doc comment sat above maybeFixSecretsHygiene instead of its own function. Co-Authored-By: Claude --- docs/security/env-secrets-threat-model.md | 2 +- src/commands/doctor.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/security/env-secrets-threat-model.md b/docs/security/env-secrets-threat-model.md index 009cabcf2..13a67d9be 100644 --- a/docs/security/env-secrets-threat-model.md +++ b/docs/security/env-secrets-threat-model.md @@ -114,7 +114,7 @@ HACK-2163 hardens three boundaries found in the env audit: | A world-readable env file or store. | `.hack/.env`, overlays and the `encrypted_file` store are written 0600, and a same-content file with group or other access is tightened (#302). Machine-local layers are created 0600, and a write, even one that changes nothing, drops the group and other bits of an existing one; commands that read layers warn about a loose one (HACK-2165). The `hack doctor` env key check reports `env-key-weak-permissions` and `env-key-wrong-owner` (#309), and its "secrets hygiene" section reports loose, linked, tracked or unignored secret-bearing files, staging leftovers, stale terminal-session payloads and a store whose key is missing; `--fix` tightens modes, adds ignore entries and removes Hack's own leftovers, never a store, key or user file (`src/lib/secrets-hygiene.ts`, HACK-2165). | | The Compose backend leaves decrypted values at rest in `.hack/.internal/compose.env.override.yml` after `up`, `down`, a failed `up` or an interrupted one. | The override holds only `${HACK_ENV__}` references; each value reaches `docker compose` through its process environment, so the generated override does not persist resolved values (command output can still contain them) (`renderComposeEnvReferenceOverride` in `src/lib/compose-managed-env.ts`). The file keeps its path because Compose records it in each container's `config_files` label. It is written with `replacePrivateFileAtomic` (mode 0600, symlink refused), and a literal value is refused before any write (`writeComposeEnvOverride` in `src/lib/compose-env-at-rest.ts`). A file left by an earlier version is replaced by the next Compose command that renders the override, removed by one that renders none and by `hack down` (before its hooks, remote target and Compose down, so none of them can skip it), and reported by the `hack doctor` "compose env at rest" check by counts only; `hack doctor --fix` removes it. Removal moves the file aside atomically and classifies what it moved, so a value-free file written concurrently is put back rather than deleted (the path is absent for that moment; see gap 4). An aside left by a crash between the move and the delete is reported by the doctor check by count and removed by a later removal, `hack doctor --fix` or a Compose command that renders the override, once it is more than a minute old. The doctor check, every removal and the aside sweep apply the writer's no-follow check to `.hack` and `.hack/.internal` first (and recheck them before each move or delete): a symlinked or non-directory `.hack` or `.hack/.internal`, or a symlinked or non-regular override, is a closed, value-free refusal, and nothing behind it is listed, read, moved or deleted (HACK-1316). Legacy adoption gives the reference values only to its Compose config-hash probe at preview and preparation; a saved recheck runs without values and binds each container's config-hash label to the hash proven at preparation. `hack config adopt` (with or without `--stop`) and its preview refuse a literal override before preparation, because an adoption pins the file's bytes and inode for its life. Recovery acts on an existing preparation, so the store refuses instead: publication (`--recover --stop`) and switch completion (`--recover`) refuse a generation bound to a literal override before any original moves, and name `hack config adopt --recover --rollback` (HACK-1318). Rollback stays allowed. While a prepared, active or pending adoption exists, no removal touches the file (removal would make the adoption unloadable); an adoption that an earlier version made over a literal file therefore keeps its values at rest until `hack config adopt --rollback`, and the doctor check names that next step (see gap 4). The `HACK_ENV_*` references, shared for global scope and distinct for service overrides, go only to `docker compose` invocations that read the override; the `docker inspect`, `logs` and `events` children for dependency cache progress and reuse get the environment without them (`withoutComposeEnvReferences`). Proven with a stub `docker` by `tests/compose-env-at-rest-cli.test.ts`, `tests/compose-env-at-rest.test.ts`, `tests/compose-env-reference-least-privilege.test.ts`, `tests/doctor-fix-noninteractive.test.ts`, `tests/native-compose-adoption-generation-managed.test.ts`, `tests/native-compose-adoption-generation-recovery.test.ts` and the sentinel scan `tests/env-secret-sentinel-scan.test.ts` (HACK-1273, HACK-1318). | | Two writers of the `encrypted_file` store lose an update. | An in-process queue plus a cooperative `.lock` using the owner protocol from `src/lib/projects-registry-lock.ts`; a symlinked store path is refused for writes (`src/lib/secret-store.ts`, #302). | -| Concurrent first users of the `encrypted_file` store each generate a key; the last key file wins and the store, already encrypted under another key, can no longer be decrypted. | The generated key is published with `publishPrivateFileExclusive` (`src/lib/private-file.ts`): a fsynced 0600 sibling is linked into place only when no key file exists, so losers adopt the published key. A store found non-empty after the first key read re-reads the key file before the keychain fallback. Proven across eight worker processes by `tests/secret-key-provisioning-convergence.test.ts`. This does not cover the process-global cache and explicit provisioning gaps in gap 17. | +| Concurrent first users of the `encrypted_file` store each generate a key; the last key file wins and the store, already encrypted under another key, can no longer be decrypted. | The generated key is published with `publishPrivateFileExclusive` (`src/lib/private-file.ts`): a fsynced 0600 staging file (in `.hack-staging/` next to the key) is linked into place only when no key file exists, so losers adopt the published key. A store found non-empty after the first key read re-reads the key file before the keychain fallback. Proven across eight worker processes by `tests/secret-key-provisioning-convergence.test.ts`. This does not cover the process-global cache and explicit provisioning gaps in gap 17. | | Concurrent first secrets in the primary checkout and a linked worktree create two different project keys (checkout-local and shared), so values do not decrypt across checkouts; or a reader refuses a key file that another process is still publishing. | When no key exists, `ensureProjectEnvSecretKey` (`src/lib/project-env-config.ts`) creates it under a cooperative lock at `/.hack.secret.key.lock` and repeats the lookup under the lock. Key files are published complete with `publishPrivateFileExclusive`, and a key read that overlaps a publication is retried for about a quarter second while the entry is a regular file. Proven by `tests/secret-key-provisioning-convergence.test.ts`. Hack v4 writers do not take the lock. | ### Malformed or hostile ciphertext diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 734c1b859..276483fab 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -5001,13 +5001,6 @@ async function maybeUntrackGeneratedFiles(opts: { note(notes.join("\n"), "generated files"); } -/** - * `hack doctor --fix` step: creates a missing committed `.hack/.gitignore`, or - * restores its managed block, in a git checkout. This is the explicit, - * user-invoked way to add the file to an existing project: runtime commands - * never write it, and `hack init` refuses or rescaffolds an existing project. - * Lines outside the managed markers are kept. An unreadable file is left alone. - */ /** * `hack doctor --fix` step: applies the "secrets hygiene" fixes and prints * each change. Safe by construction (modes, ignore entries, Hack's own @@ -5030,6 +5023,13 @@ async function maybeFixSecretsHygiene(opts: { note(lines.join("\n"), SECRETS_HYGIENE_CHECK); } +/** + * `hack doctor --fix` step: creates a missing committed `.hack/.gitignore`, or + * restores its managed block, in a git checkout. This is the explicit, + * user-invoked way to add the file to an existing project: runtime commands + * never write it, and `hack init` refuses or rescaffolds an existing project. + * Lines outside the managed markers are kept. An unreadable file is left alone. + */ async function maybeEnsureHackDirGitignore(opts: { readonly startDir: string; }): Promise { From 66e28c5ff206eb14c8e9a23506a552aeedbd78a4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:12:29 +0000 Subject: [PATCH 23/42] test(env): write the manual overlay fixture private (HACK-2165) tests/env-backend-command.test.ts parses `env list --json` from a run that shares one terminal stream for stdout and stderr; its fixture wrote a 0644 plaintext `.hack/.env.qa`, which env commands now warn about on stderr. The fixture writes it 0600, as Hack does. Co-Authored-By: Claude --- tests/env-backend-command.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/env-backend-command.test.ts b/tests/env-backend-command.test.ts index dc28c4b4b..0f96f6140 100644 --- a/tests/env-backend-command.test.ts +++ b/tests/env-backend-command.test.ts @@ -1397,9 +1397,13 @@ test( }); expect(baseSet.exitCode).toBe(0); + // Private, as Hack writes these files: a 0644 plaintext overlay makes + // env commands warn on stderr (HACK-2165), and this run shares one + // terminal stream for stdout and stderr while it parses the JSON. await writeFile( resolve(projectDir, ".env.qa"), - "PUBLIC_URL=https://qa-file.test\n" + "PUBLIC_URL=https://qa-file.test\n", + { mode: 0o600 } ); const listResult = await runHack({ From 9e97246288a9db5cce95cf50582f88fa722db9fb Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:48:30 +0000 Subject: [PATCH 24/42] fix(doctor): never remove a live configured key; safe recovery commands; fail closed on inspection (HACK-2165) Second-family review of 66e28c5f (1 blocker, 4 should-fix): - Blocker: the leftover scan could offer a configured, live store key for removal when its name looked like a staging sibling (for example `..hack.secret.key..tmp` chosen during recovery). Every configured store and key, the project key and the shared key, as given and resolved, is now excluded from removal. Writers no longer sweep legacy siblings beside a target at all (they cannot know what is configured); they sweep only their own staging copies inside `.hack-staging/`, and legacy siblings are left to doctor. - F1 commands: the rotation command checks, when it runs, that `~/.hack/retired-keys` is not in a git work tree and stops before copying if it is; the symlink repair no longer uses a `.new` intermediate (resolve, remove the link, copy in under umask 077). Tests run both commands in sandboxes with git controls. - Ignore repair refuses an existing ignore file it cannot read, instead of rewriting it with one entry. - Inspection errors other than "absent" (an unsearchable directory, an unlistable `.hack`) are error findings without fixes, never a clean result; a key that cannot be inspected is not reported missing. - The tmux backend tests stage in a private temporary directory, with a control showing an unrelated stale payload outside it is untouched. Co-Authored-By: Claude --- src/lib/doctor-env-key.ts | 30 +++++- src/lib/private-staging.ts | 31 ++++-- src/lib/secrets-hygiene.ts | 158 ++++++++++++++++++++++++++++++- tests/doctor-env-key.test.ts | 109 ++++++++++++++++++++- tests/private-staging.test.ts | 8 +- tests/secrets-hygiene.test.ts | 85 +++++++++++++++++ tests/tmux-backend.test.ts | 92 +++++++++++------- tests/tmux-private-input.test.ts | 40 ++++++-- 8 files changed, 492 insertions(+), 61 deletions(-) diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index c46290e65..3ba765fc4 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -231,11 +231,31 @@ const RETIRED_KEY_DIR = "~/.hack/retired-keys"; const READD_SECRET = "re-add each secret with ' | hack env add KEY --secret --value-stdin' (or omit the value in a terminal for a hidden prompt)"; +/** + * Copies the key to a new private file under {@link RETIRED_KEY_DIR}, then + * removes it. The command checks, when it runs, that the directory is not + * inside a git work tree (a home directory kept in git, say) and stops + * before copying anything if it is. mktemp gives each copy its own name + * (0600), so a later rotation never overwrites an earlier one; the key is + * removed only once its copy is written. + */ +export function retiredKeyCopyCommand(key: string): string { + return `install -d -m 700 ${RETIRED_KEY_DIR} && ! git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree >/dev/null 2>&1 && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; +} + +/** + * Replaces a key symlink with a private regular copy of its target, with no + * intermediate file (a `.new` left by an interruption would not be + * ignored): the target is resolved, the link removed, and the bytes copied + * in under umask 077. If the copy fails the key's bytes are still at the + * printed target. + */ +export function symlinkKeyRepairCommand(key: string): string { + return `t="$(readlink -f ${key})" && [ -f "$t" ] && rm ${key} && (umask 077 && cp "$t" ${key}) && chmod 600 ${key}`; +} + function rotateRemedy(path: (suffix?: string) => string): string { - // mktemp gives each retired copy its own name (0600), so a second rotation, - // here or in another project, never overwrites the first; the key is only - // removed once its copy is written. - return `keep a recoverable copy of the old key outside the worktree, then remove it: 'install -d -m 700 ${RETIRED_KEY_DIR} && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${path()} > "$k" && rm ${path()}'. Then ${READD_SECRET}; Hack generates a new 256-bit key. Delete the retired copy only once every secret decrypts under the new key.`; + return `keep a recoverable copy of the old key outside every git checkout, then remove it: '${retiredKeyCopyCommand(path())}' (it stops without copying if ${RETIRED_KEY_DIR} is inside a git work tree; then copy the key to a private directory outside any checkout yourself). Then ${READD_SECRET}; Hack generates a new 256-bit key. Delete the retired copy only once every secret decrypts under the new key.`; } async function inspectCandidate(opts: { @@ -273,7 +293,7 @@ async function inspectCandidate(opts: { remedy: remedyFor( role, (p) => - `after confirming the link target is your key: cp -L ${p()} ${p(".new")} && mv -f ${p(".new")} ${p()} && chmod 600 ${p()}` + `after confirming the link target is your key: ${symlinkKeyRepairCommand(p())}` ), }, ], diff --git a/src/lib/private-staging.ts b/src/lib/private-staging.ts index 65fec2fc0..0cc9d38d6 100644 --- a/src/lib/private-staging.ts +++ b/src/lib/private-staging.ts @@ -129,6 +129,14 @@ export async function findStagingLeftovers(opts: { readonly directory: string; readonly names: readonly string[]; readonly now: number; + /** + * Only staging copies of `names` inside the staging directory, and no + * legacy siblings beside them. A writer sweeps this way: it cannot tell a + * sibling an earlier version left from a file someone configured under a + * similar name, so legacy siblings are left to `hack doctor`, which knows + * the configured files (HACK-2165). + */ + readonly stagingOnly?: boolean; }): Promise { const found: StagingLeftover[] = []; const collect = async (dir: string, accept: (name: string) => boolean) => { @@ -141,14 +149,23 @@ export async function findStagingLeftovers(opts: { } } }; - const patterns = opts.names.map(legacyStagingSiblingPattern); - await collect(opts.directory, (name) => - patterns.some((pattern) => pattern.test(name)) - ); + if (opts.stagingOnly !== true) { + const patterns = opts.names.map(legacyStagingSiblingPattern); + await collect(opts.directory, (name) => + patterns.some((pattern) => pattern.test(name)) + ); + } const staging = join(opts.directory, PRIVATE_STAGING_DIRNAME); const stagingStats = await lstat(staging).catch(() => null); if (stagingStats && isOwnDirectory(stagingStats)) { - await collect(staging, isStagingFileName); + await collect( + staging, + opts.stagingOnly === true + ? (name) => + isStagingFileName(name) && + opts.names.some((target) => name.startsWith(`${target}.`)) + : isStagingFileName + ); } return found; } @@ -190,7 +207,8 @@ export type PreparedStaging = { * self-ignoring `.gitignore`; an existing entry there that is a link, not a * directory, or not the user's own is refused with `refusal()`, so a staging * copy is never created through it (see {@link admitStagingDirectory}). Old leftovers - * of this target are swept first. + * of this target in the staging directory are swept first; siblings an + * earlier version left are left to `hack doctor`. */ export async function preparePrivateStaging(opts: { readonly target: string; @@ -214,6 +232,7 @@ export async function preparePrivateStaging(opts: { directory: dirname(opts.target), names: [basename(opts.target)], now: opts.now ?? Date.now(), + stagingOnly: true, }); for (const leftover of leftovers) { await removeStagingLeftover(leftover).catch(() => false); diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 624bae4d1..6e0d3a89d 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -390,6 +390,39 @@ async function gitFindings(opts: { return []; } +/** + * An entry's metadata, `"absent"` only when the path does not exist, or the + * error code when it could not be inspected (for example a directory on the + * way the user cannot search): an inspection failure is never read as + * absence, so it can never produce a clean result (HACK-2165). + */ +async function inspectEntry( + path: string +): Promise { + try { + return await lstat(path); + } catch (error: unknown) { + const code = + error && typeof error === "object" && "code" in error + ? String((error as { code: unknown }).code) + : "unknown"; + return code === "ENOENT" || code === "ENOTDIR" ? "absent" : { error: code }; + } +} + +function uninspectableFinding(opts: { + readonly path: string; + readonly name: string; + readonly code: string; +}): SecretsHygieneFinding { + return { + status: "error", + path: opts.path, + message: `${opts.name} could not be inspected (${displayPath(opts.code)}), so Hack cannot tell whether it is private and kept out of git. Make it and its directories readable by your user, then run hack doctor again.`, + fix: null, + }; +} + async function candidateFindings(opts: { readonly candidate: Candidate; readonly project: SecretsHygieneScope["project"]; @@ -400,8 +433,18 @@ async function candidateFindings(opts: { */ readonly beforeCreation?: boolean; }): Promise { - const stats = await lstat(opts.candidate.path).catch(() => null); + const entry = await inspectEntry(opts.candidate.path); const name = shown(opts.candidate.path, opts.project); + if (typeof entry === "object" && "error" in entry) { + return [ + uninspectableFinding({ + path: opts.candidate.path, + name, + code: entry.error, + }), + ]; + } + const stats = entry === "absent" ? null : entry; if (!stats) { return opts.beforeCreation === true && opts.candidate.ignore ? await gitFindings({ candidate: opts.candidate, name }) @@ -541,11 +584,13 @@ async function storeFindings(opts: { ); } const store = await lstat(storePath).catch(() => null); - const key = await lstat(keyPath).catch(() => null); + // Only a key confirmed absent is "missing"; one that could not be + // inspected is already reported above. + const keyAbsent = (await inspectEntry(keyPath)) === "absent"; if ( store?.isFile() && store.size > 0 && - !key && + keyAbsent && !opts.storeKeyFromEnvironment ) { const keychainMayHoldKey = @@ -583,6 +628,34 @@ async function projectFindings(opts: { fix: null, }); } + if (inspectProjectDir) { + const listed = await readdir(project.projectDir).then( + () => null, + (error: unknown) => + error && typeof error === "object" && "code" in error + ? String((error as { code: unknown }).code) + : "unknown" + ); + if (listed !== null) { + findings.push( + uninspectableFinding({ + path: project.projectDir, + name: shown(project.projectDir, project), + code: listed, + }) + ); + } + } + const projectDirEntry = await inspectEntry(project.projectDir); + if (typeof projectDirEntry === "object" && "error" in projectDirEntry) { + findings.push( + uninspectableFinding({ + path: project.projectDir, + name: shown(project.projectDir, project), + code: projectDirEntry.error, + }) + ); + } for (const candidate of await projectCandidates({ project, inspectProjectDir, @@ -701,6 +774,19 @@ export async function inspectSecretsHygiene( : []), ...(await allStoreFindings({ scope, now })), ]; + // A file the configuration uses is live, whatever its name looks like: it + // is never offered for removal as a leftover (HACK-2165). + const live = await livePaths(scope); + const kept: SecretsHygieneFinding[] = []; + for (const finding of findings) { + if ( + finding.fix?.kind !== "remove_leftover" || + !(await isLive({ path: finding.path, live })) + ) { + kept.push(finding); + } + } + findings.splice(0, findings.length, ...kept); for (const stale of await findStaleMuxEnvironments({ ...(scope.tmpDir ? { tmpDir: scope.tmpDir } : {}), now, @@ -715,6 +801,42 @@ export async function inspectSecretsHygiene( return await Promise.all(findings.map(withGuard)); } +/** + * Files the configuration uses (stores, keys, the project and shared keys), + * as given and resolved. + */ +async function livePaths( + scope: SecretsHygieneScope +): Promise> { + const paths = [ + ...scope.stores.flatMap((store) => [store.storePath, store.keyPath]), + ...(scope.sharedKeyPath ? [scope.sharedKeyPath] : []), + ...(scope.project + ? [join(scope.project.projectRoot, PROJECT_ENV_KEY_FILENAME)] + : []), + ]; + const live = new Set(); + for (const path of paths) { + live.add(resolve(path)); + const real = await realpath(path).catch(() => null); + if (real) { + live.add(real); + } + } + return live; +} + +async function isLive(opts: { + readonly path: string; + readonly live: ReadonlySet; +}): Promise { + if (opts.live.has(resolve(opts.path))) { + return true; + } + const real = await realpath(opts.path).catch(() => null); + return real !== null && opts.live.has(real); +} + /** The directory a fix changes. */ function fixDirectory(fix: SecretsHygieneFix): string { switch (fix.kind) { @@ -779,14 +901,42 @@ async function tightenMode( } } +/** Whether `path` opens (no-follow) and reads as a regular file. */ +async function isReadableRegularFile(path: string): Promise { + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ).catch(() => null); + if (!file) { + return false; + } + try { + const info = await file.stat(); + if (!info.isFile()) { + return false; + } + await file.readFile(); + return true; + } catch { + return false; + } finally { + await file.close(); + } +} + async function addIgnoreEntry( fix: Extract ): Promise { - // Never write through a linked ignore file. + // Never write through a linked ignore file, and never rewrite one whose + // bytes could not be read: the writer treats an unreadable file as absent + // and would replace the user's rules with this one entry. const existing = await lstat(fix.gitignorePath).catch(() => null); if (existing && !existing.isFile()) { return false; } + if (existing && !(await isReadableRegularFile(fix.gitignorePath))) { + return false; + } await ensureGitignoreEntry({ gitignorePath: fix.gitignorePath, entry: fix.entry, diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index c11e18a08..bb6c55bbc 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -2,8 +2,11 @@ import { afterEach, describe, expect, test } from "bun:test"; import { randomBytes } from "node:crypto"; import { chmod, + lstat, mkdir, mkdtemp, + readdir, + readFile, rm, symlink, writeFile, @@ -24,6 +27,8 @@ import { gitListTrackedKeyPaths, type InspectEnvKeyOptions, inspectProjectEnvKey, + retiredKeyCopyCommand, + symlinkKeyRepairCommand, } from "../src/lib/doctor-env-key.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; @@ -144,9 +149,11 @@ describe("env key doctor diagnostic", () => { expect(inspection.status).toBe("error"); expect(codes(inspection)).toEqual(["env-key-symlink"]); + // HACK-2165: no `.new` intermediate, which git could collect. expect(inspection.findings[0]?.remedy).toContain( - `cp -L ${KEY_FILE} ${KEY_FILE}.new && mv -f ${KEY_FILE}.new ${KEY_FILE} && chmod 600 ${KEY_FILE}` + symlinkKeyRepairCommand(KEY_FILE) ); + expect(inspection.findings[0]?.remedy).not.toContain(".new"); expectValueFree({ output: serialized(inspection), root, @@ -802,3 +809,103 @@ describe("gitIsLocalKeyIgnored", () => { }); }); }); + +describe("printed key recovery commands leave nothing git can collect (HACK-2165)", () => { + const KEY = "synthetic-recovery-key-material\n"; + + async function sandbox() { + const root = await mkdtemp(join(tmpdir(), "hack-key-recovery-")); + dirsForRecovery.push(root); + const home = join(root, "home"); + const project = join(root, "project"); + await mkdir(home); + await mkdir(project); + await git(project, ["init", "-q", "-b", "main"]); + await writeFile(join(project, ".gitignore"), ".hack.secret.key\n"); + return { root, home, project }; + } + + async function git(cwd: string, args: readonly string[]): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + env: fixtureGitEnv(), + stdout: "pipe", + stderr: "ignore", + }); + const out = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + return out; + } + + async function run(cwd: string, home: string, command: string) { + const proc = Bun.spawn(["sh", "-c", command], { + cwd, + env: fixtureGitEnv({ env: { HOME: home } }), + stdout: "ignore", + stderr: "ignore", + }); + return await proc.exited; + } + + const untracked = async (dir: string) => + (await git(dir, ["status", "--porcelain", "--untracked-files=all"])) + .split("\n") + .filter(Boolean); + + test("rotation copies the key outside the checkout, then removes it", async () => { + const box = await sandbox(); + await writeFile(join(box.project, KEY_FILE), KEY, { mode: 0o600 }); + expect( + await run(box.project, box.home, retiredKeyCopyCommand(KEY_FILE)) + ).toBe(0); + expect( + await lstat(join(box.project, KEY_FILE)).catch(() => null) + ).toBeNull(); + const retired = await readdir(join(box.home, ".hack", "retired-keys")); + expect(retired).toHaveLength(1); + expect( + await readFile( + join(box.home, ".hack", "retired-keys", retired[0] ?? ""), + "utf8" + ) + ).toBe(KEY); + expect(await untracked(box.project)).toEqual(["?? .gitignore"]); + }); + + test("rotation stops without copying when the retired-key directory is in a git checkout", async () => { + const box = await sandbox(); + await git(box.home, ["init", "-q", "-b", "main"]); + await writeFile(join(box.project, KEY_FILE), KEY, { mode: 0o600 }); + expect( + await run(box.project, box.home, retiredKeyCopyCommand(KEY_FILE)) + ).not.toBe(0); + expect(await readFile(join(box.project, KEY_FILE), "utf8")).toBe(KEY); + expect(await readdir(join(box.home, ".hack", "retired-keys"))).toEqual([]); + expect(await untracked(box.home)).toEqual([]); + }); + + test("the symlink repair leaves a private regular key and no intermediate copy", async () => { + const box = await sandbox(); + const target = join(box.root, "elsewhere.key"); + await writeFile(target, KEY, { mode: 0o600 }); + await symlink(target, join(box.project, KEY_FILE)); + expect( + await run(box.project, box.home, symlinkKeyRepairCommand(KEY_FILE)) + ).toBe(0); + const key = await lstat(join(box.project, KEY_FILE)); + expect(key.isFile()).toBe(true); + expect(key.mode & 0o777).toBe(0o600); + expect(await readFile(join(box.project, KEY_FILE), "utf8")).toBe(KEY); + expect((await readdir(box.project)).sort()).toEqual( + [".git", ".gitignore", KEY_FILE].sort() + ); + expect(await untracked(box.project)).toEqual(["?? .gitignore"]); + }); +}); + +const dirsForRecovery: string[] = []; +afterEach(async () => { + for (const dir of dirsForRecovery.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/private-staging.test.ts b/tests/private-staging.test.ts index 3fa480738..3ec823b67 100644 --- a/tests/private-staging.test.ts +++ b/tests/private-staging.test.ts @@ -146,7 +146,7 @@ test("the sibling option keeps the previous staging location", async () => { expect(await readdir(root)).toEqual(["claim.json"]); }); -test("old leftovers are swept by the next staging write; recent ones are kept", async () => { +test("the next staging write sweeps only its own old staging copies; recent ones and siblings are kept", async () => { const root = await tempDir(); const target = join(root, ".env"); const staging = join(root, PRIVATE_STAGING_DIRNAME); @@ -175,9 +175,11 @@ test("old leftovers are swept by the next staging write; recent ones are kept", expect((await readdir(staging)).sort()).toEqual( [".gitignore", `.env.${UUID.replace("0f", "1f")}.tmp`].sort() ); - // Only this target's legacy sibling is swept; another name is left alone. + // A writer never removes siblings beside the target: it cannot tell an + // earlier version's leftover from a file configured under such a name, so + // those are left to `hack doctor` (HACK-2165). expect((await readdir(root)).sort()).toEqual( - [PRIVATE_STAGING_DIRNAME, `..envx.${UUID}.tmp`].sort() + [PRIVATE_STAGING_DIRNAME, `..env.${UUID}.tmp`, `..envx.${UUID}.tmp`].sort() ); }); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index f4b3a596e..7af821431 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -554,3 +554,88 @@ test("a project-configured store path not created yet keeps its ignore fix (fix await applySecretsHygieneFixes({ findings, report: () => undefined }); expect(await inspectSecretsHygiene(scope)).toEqual([]); }); + +test("a configured key named like a staging leftover is live and never offered for removal", async () => { + const f = await fixture(); + // Recovery pointed the backend at an old staging copy that still works. + const liveKey = join(f.projectRoot, `..hack.secret.key.${UUID}.tmp`); + const store = join(f.home, "secrets.enc.json"); + await writeFile(liveKey, `${SENTINEL}\n`, { mode: 0o600 }); + await writeFile(store, `{"sentinel":"${SENTINEL}"}\n`, { mode: 0o600 }); + await backdate(liveKey); + // The same name reached through a link is the same live file. + const alias = join(f.root, "alias-key"); + await symlink(liveKey, alias); + for (const keyPath of [liveKey, alias]) { + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath: store, keyPath }], + }); + expect( + findings.some((finding) => finding.fix?.kind === "remove_leftover") + ).toBe(false); + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect(await readFile(liveKey, "utf8")).toBe(`${SENTINEL}\n`); + } + // Without that configuration the same file is an ordinary leftover. + const unconfigured = await inspectSecretsHygiene(f.scope); + expect(paths(unconfigured)).toContain(liveKey); +}); + +test.skipIf(process.getuid?.() === 0)( + "an ignore file the user cannot read is never rewritten", + async () => { + const f = await fixture(); + const overlay = join(f.projectDir, ".env.qa"); + await writeFile(overlay, "QA=1\n", { mode: 0o600 }); + const ignore = join(f.projectDir, ".gitignore"); + const before = await readFile(ignore, "utf8"); + const findings = await inspectSecretsHygiene(f.scope); + expect(findings.map((finding) => finding.fix?.kind)).toEqual(["ignore"]); + // Write-only: the repair could append nothing without losing the rules. + await chmod(ignore, 0o200); + try { + expect( + await applySecretsHygieneFixes({ findings, report: () => undefined }) + ).toEqual({ applied: 0, skipped: 1 }); + } finally { + await chmod(ignore, 0o644); + } + expect(await readFile(ignore, "utf8")).toBe(before); + } +); + +test.skipIf(process.getuid?.() === 0)( + "a store or project directory that cannot be inspected is an error, never a clean result", + async () => { + const f = await fixture(); + const locked = join(f.root, "locked"); + await mkdir(locked); + const storePath = join(locked, "secrets.enc.json"); + const keyPath = join(locked, "secrets-file.key"); + await writeFile(storePath, "{}\n", { mode: 0o600 }); + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + await chmod(locked, 0o000); + await chmod(f.projectDir, 0o000); + try { + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath, keyPath }], + }); + const byPath = new Map( + findings.map((finding) => [finding.path, finding]) + ); + for (const path of [storePath, keyPath, f.projectDir]) { + expect(byPath.get(path)).toMatchObject({ status: "error", fix: null }); + expect(byPath.get(path)?.message).toContain("could not be inspected"); + } + // Never reported as a store whose key is missing. + expect( + findings.some((finding) => finding.message.includes("key missing")) + ).toBe(false); + } finally { + await chmod(locked, 0o700); + await chmod(f.projectDir, 0o755); + } + } +); diff --git a/tests/tmux-backend.test.ts b/tests/tmux-backend.test.ts index 8588235e3..916131795 100644 --- a/tests/tmux-backend.test.ts +++ b/tests/tmux-backend.test.ts @@ -1,9 +1,18 @@ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; -import { readFile, rm } from "node:fs/promises"; -import { dirname } from "node:path"; +import { + mkdir, + mkdtemp, + readFile, + rm, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { HACK_KEY_MATERIAL_VARIABLES } from "../src/lib/child-environment.ts"; import type { ExecOptions } from "../src/lib/shell.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; const execCalls: Array<{ @@ -174,40 +183,59 @@ test("tmux keeps only the exact duplicate-session diagnostic from a failed creat }); test("tmux sends command environment values over stdin with tmux quoting", async () => { - const backend = await loadTmuxBackend(); - const fake = "fake-'\"$value\\line\nnext"; - await backend.execInSession({ - name: "demo", - command: "echo done", - env: { FAKE_API_KEY: fake }, - }); - expect(execCalls[0]?.command).toEqual([ - "tmux", - "-u", - "start-server", - ";", - "source-file", - "-", - ]); - const input = execCalls[0]?.options.stdin; - if (!(input instanceof Blob)) { - throw new Error("Expected private stdin payload"); - } - const source = await input.text(); - expect(source).not.toContain("\\$value"); - const path = source.match(/'([^']+\/env\.json)'/)?.[1]; - if (!path) { - throw new Error("Expected a private input path"); - } + // The staging sweep runs against the OS temporary directory: point it at + // a private one so no payload outside this test is ever considered, and + // keep an unrelated stale payload elsewhere as a control (HACK-2165). + const stageDir = await mkdtemp(join(tmpdir(), "tmux-backend-stage-")); + const outside = await mkdtemp(join(tmpdir(), "tmux-backend-outside-")); + const unrelated = join(outside, "hack-mux-env-unrelated"); + await mkdir(unrelated, { mode: 0o700 }); + await writeFile(join(unrelated, "env.json"), "{}", { mode: 0o600 }); + const old = (Date.now() - 60 * 60_000) / 1000; + await utimes(unrelated, old, old); + const savedTmpdir = process.env.TMPDIR; + process.env.TMPDIR = stageDir; try { - expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ - FAKE_API_KEY: fake, + const backend = await loadTmuxBackend(); + const fake = "fake-'\"$value\\line\nnext"; + await backend.execInSession({ + name: "demo", + command: "echo done", + env: { FAKE_API_KEY: fake }, }); + expect(execCalls[0]?.command).toEqual([ + "tmux", + "-u", + "start-server", + ";", + "source-file", + "-", + ]); + const input = execCalls[0]?.options.stdin; + if (!(input instanceof Blob)) { + throw new Error("Expected private stdin payload"); + } + const source = await input.text(); + expect(source).not.toContain("\\$value"); + const path = source.match(/'([^']+\/env\.json)'/)?.[1]; + if (!path) { + throw new Error("Expected a private input path"); + } + try { + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + FAKE_API_KEY: fake, + }); + } finally { + await rm(dirname(path), { recursive: true, force: true }); + } + expect(source.split("\n")).toHaveLength(2); + expect(execCalls[0]?.command.join(" ")).not.toContain(fake); } finally { - await rm(dirname(path), { recursive: true, force: true }); + restoreEnv("TMPDIR", savedTmpdir); + expect(await readFile(join(unrelated, "env.json"), "utf8")).toBe("{}"); + await rm(stageDir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); } - expect(source.split("\n")).toHaveLength(2); - expect(execCalls[0]?.command.join(" ")).not.toContain(fake); }); test("tmux presence uses an exact session target and distinguishes unknown failures", async () => { diff --git a/tests/tmux-private-input.test.ts b/tests/tmux-private-input.test.ts index 17e7d007e..0bcc32385 100644 --- a/tests/tmux-private-input.test.ts +++ b/tests/tmux-private-input.test.ts @@ -5,11 +5,27 @@ import { join } from "node:path"; import type { ExecOptions } from "../src/lib/shell.ts"; import { findExecutableInPath } from "../src/lib/shell.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; const tmux = findExecutableInPath("tmux"); const root = await mkdtemp(join(tmpdir(), "tmux-private-test-")); const socket = join(root, "socket"); + +/** + * Runs a staging call with the OS temporary directory pointed at this test's + * own root, so the stale-payload sweep never looks at the host's real + * temporary directory (HACK-2165). + */ +async function inPrivateTmpdir(run: () => R): Promise> { + const saved = process.env.TMPDIR; + process.env.TMPDIR = root; + try { + return await run(); + } finally { + restoreEnv("TMPDIR", saved); + } +} const calls: (readonly string[])[] = []; async function privateExec( command: readonly string[], @@ -160,11 +176,13 @@ test.skipIf(!tmux)( "leaked: values.some(value => parentArgv.includes(value) || process.argv.some(arg => arg.includes(value))),", "tty: process.stdin.isTTY === true }));", ].join(" "); - const sent = await backend.execInSession({ - name: "readonly", - command: `'${process.execPath}' --eval '${report}' | /bin/cat > '${outputPath}'`, - env: readonlyValues, - }); + const sent = await inPrivateTmpdir(() => + backend.execInSession({ + name: "readonly", + command: `'${process.execPath}' --eval '${report}' | /bin/cat > '${outputPath}'`, + env: readonlyValues, + }) + ); expect(sent.exitCode).toBe(0); const expected = `${JSON.stringify({ values: [readonlyValues.UID, readonlyValues.EUID, value], @@ -199,11 +217,13 @@ test.skipIf(!tmux)( ], ] as const) { await rm(outputPath, { force: true }); - const result = await backend.execInSession({ - name: "readonly", - command, - env: { UID: "fake-uid", NODE_ENV: "selected-value" }, - }); + const result = await inPrivateTmpdir(() => + backend.execInSession({ + name: "readonly", + command, + env: { UID: "fake-uid", NODE_ENV: "selected-value" }, + }) + ); expect(result.exitCode, label).toBe(0); const until = Date.now() + 5000; let actual = ""; From 604134c07e7b6c350e2a39e7ca5d68659e017ee3 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:02:38 +0000 Subject: [PATCH 25/42] fix(env): writers remove no staging leftovers; rotation needs git's "not a repository"; ignore repair merges its own read (HACK-2165) Second-family re-review of 9e972462 (1 blocker, 2 should-fix): - Blocker: the writer's staging sweep matched by name prefix, so a write of `.env` could delete a live key kept at `.hack-staging/.env.key..tmp`. Writers now remove nothing: they cannot tell a leftover from a file configured at a staging-shaped path. `hack doctor --fix` removes leftovers, excluding every configured store and key. - The rotation command copied whenever `git rev-parse` failed. It now copies only when git (LC_ALL=C) answers "not a git repository"; inside a work tree, with git missing, or on any other failure it stops before copying. - The ignore repair re-read the file through a reader that turns a failure into "absent". It now merges into the bytes it read through a no-follow handle and writes with a compare-and-swap against them, so a failed read can never replace the user's rules. Regressions: a live `.env.key..tmp` survives staging for `.env`; a git that fails otherwise stops the rotation; a failing path read during the merge leaves the ignore rules intact. All three fail on 9e972462. Docs updated: writes no longer sweep. Co-Authored-By: Claude --- docs/env.md | 5 ++- docs/security/env-secrets-threat-model.md | 2 +- src/lib/doctor-env-key.ts | 11 ++--- src/lib/fs.ts | 2 +- src/lib/private-staging.ts | 16 +++---- src/lib/secrets-hygiene.ts | 53 +++++++++++++++-------- tests/doctor-env-key.test.ts | 25 +++++++++++ tests/private-staging.test.ts | 24 +++++++--- tests/secrets-hygiene.test.ts | 39 +++++++++++++++++ 9 files changed, 131 insertions(+), 46 deletions(-) diff --git a/docs/env.md b/docs/env.md index 474d4607e..8f3bfa62e 100644 --- a/docs/env.md +++ b/docs/env.md @@ -464,8 +464,9 @@ store, a key or a file you wrote, and never untracks a file. Hack writes a file that holds a secret by staging the complete contents first. Those staging copies live in a private directory next to the file, `.hack-staging/`, which -holds a `.gitignore` of `*`, so git never lists anything in it; the next write there -removes copies older than 10 minutes. A machine-local layer that a write changes, or +holds a `.gitignore` of `*`, so git never lists anything in it. `hack doctor --fix` +removes copies older than 10 minutes (never a file your configuration uses as a store or +key); writes themselves remove nothing there. A machine-local layer that a write changes, or leaves unchanged, is set to 0600 if other users could read it. Commands that read env layers warn on stderr about a machine-local file other users can read. diff --git a/docs/security/env-secrets-threat-model.md b/docs/security/env-secrets-threat-model.md index 3829426ab..5829634ce 100644 --- a/docs/security/env-secrets-threat-model.md +++ b/docs/security/env-secrets-threat-model.md @@ -116,7 +116,7 @@ HACK-2163 hardens three boundaries found in the env audit: | --- | --- | | A repository's contract names a variable exported in the developer's shell, so the migration saves it to disk. | `migrateLegacyProjectEnv` refuses, naming the key only, when a `plain_env` value resolves from the process environment (HACK-1688). A value in a regular `.hack/.env` migrates to the gitignored machine-local layer, never a tracked one (HACK-1712). | | A repository plants `.hack.secret.key` as a symlink (dangling or not) so that Hack reads or writes a file it should not. | Key reads go through `readBoundedPrivateFile` (`lstat`, `O_NOFOLLOW`, descriptor and path identity checks, 1 MiB bound, fatal UTF-8). New keys are created with `createPrivateFileExclusive` (`O_CREAT|O_EXCL|O_NOFOLLOW`, mode 0600, `fchmod`, `fsync`). Both are in `src/lib/private-file.ts`, used by `resolveProjectEnvKey`, `ensureProjectEnvSecretKey` and `resolveInheritedProjectEnvKey` in `src/lib/project-env-config.ts` (#288). | -| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private staging file is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by mutation paths in `src/lib/hack-env.ts` and `materializeProjectEnv`. Legacy inspection still follows links through `readTextFile`; see gap 18. Staging copies of these files and of keys live in `.hack-staging/` next to the target, a private (0700) directory whose `.gitignore` of `*` keeps git from listing a copy a crash leaves; the next write there sweeps copies older than 10 minutes (`src/lib/private-staging.ts`, HACK-2165). | +| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private staging file is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by mutation paths in `src/lib/hack-env.ts` and `materializeProjectEnv`. Legacy inspection still follows links through `readTextFile`; see gap 18. Staging copies of these files and of keys live in `.hack-staging/` next to the target, a private (0700) directory whose `.gitignore` of `*` keeps git from listing a copy a crash leaves. Writers never remove anything there, since a file configured at a staging-shaped path could be live; `hack doctor --fix` removes copies older than 10 minutes and never a configured store or key (`src/lib/private-staging.ts`, `src/lib/secrets-hygiene.ts`, HACK-2165). | | A world-readable env file or store. | `.hack/.env`, overlays and the `encrypted_file` store are written 0600, and a same-content file with group or other access is tightened (#302). Machine-local layers are created 0600, and a write, even one that changes nothing, drops the group and other bits of an existing one; commands that read layers warn about a loose one (HACK-2165). The `hack doctor` env key check reports `env-key-weak-permissions` and `env-key-wrong-owner` (#309), and its "secrets hygiene" section reports loose, linked, tracked or unignored secret-bearing files, staging leftovers, stale terminal-session payloads and a store whose key is missing; `--fix` tightens modes, adds ignore entries and removes Hack's own leftovers, never a store, key or user file (`src/lib/secrets-hygiene.ts`, HACK-2165). | | The Compose backend leaves decrypted values at rest in `.hack/.internal/compose.env.override.yml` after `up`, `down`, a failed `up` or an interrupted one. | The override holds only `${HACK_ENV__}` references; each value reaches `docker compose` through its process environment, so the generated override does not persist resolved values (command output can still contain them) (`renderComposeEnvReferenceOverride` in `src/lib/compose-managed-env.ts`). The file keeps its path because Compose records it in each container's `config_files` label. It is written with `replacePrivateFileAtomic` (mode 0600, symlink refused), and a literal value is refused before any write (`writeComposeEnvOverride` in `src/lib/compose-env-at-rest.ts`). A file left by an earlier version is replaced by the next Compose command that renders the override, removed by one that renders none and by `hack down` (before its hooks, remote target and Compose down, so none of them can skip it), and reported by the `hack doctor` "compose env at rest" check by counts only; `hack doctor --fix` removes it. Removal moves the file aside atomically and classifies what it moved, so a value-free file written concurrently is put back rather than deleted (the path is absent for that moment; see gap 4). An aside left by a crash between the move and the delete is reported by the doctor check by count and removed by a later removal, `hack doctor --fix` or a Compose command that renders the override, once it is more than a minute old. The doctor check, every removal and the aside sweep apply the writer's no-follow check to `.hack` and `.hack/.internal` first (and recheck them before each move or delete): a symlinked or non-directory `.hack` or `.hack/.internal`, or a symlinked or non-regular override, is a closed, value-free refusal, and nothing behind it is listed, read, moved or deleted (HACK-1316). Legacy adoption gives the reference values only to its Compose config-hash probe at preview and preparation; a saved recheck runs without values and binds each container's config-hash label to the hash proven at preparation. `hack config adopt` (with or without `--stop`) and its preview refuse a literal override before preparation, because an adoption pins the file's bytes and inode for its life. Recovery acts on an existing preparation, so the store refuses instead: publication (`--recover --stop`) and switch completion (`--recover`) refuse a generation bound to a literal override before any original moves, and name `hack config adopt --recover --rollback` (HACK-1318). Rollback stays allowed. While a prepared, active or pending adoption exists, no removal touches the file (removal would make the adoption unloadable); an adoption that an earlier version made over a literal file therefore keeps its values at rest until `hack config adopt --rollback`, and the doctor check names that next step (see gap 4). The `HACK_ENV_*` references, shared for global scope and distinct for service overrides, go only to `docker compose` invocations that read the override; the `docker inspect`, `logs` and `events` children for dependency cache progress and reuse get the environment without them (`withoutComposeEnvReferences`). Proven with a stub `docker` by `tests/compose-env-at-rest-cli.test.ts`, `tests/compose-env-at-rest.test.ts`, `tests/compose-env-reference-least-privilege.test.ts`, `tests/doctor-fix-noninteractive.test.ts`, `tests/native-compose-adoption-generation-managed.test.ts`, `tests/native-compose-adoption-generation-recovery.test.ts` and the sentinel scan `tests/env-secret-sentinel-scan.test.ts` (HACK-1273, HACK-1318). | | Two writers of the `encrypted_file` store lose an update. | An in-process queue plus a cooperative `.lock` using the owner protocol from `src/lib/projects-registry-lock.ts`; a symlinked store path is refused for writes (`src/lib/secret-store.ts`, #302). | diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index 3ba765fc4..6a49c5446 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -233,14 +233,15 @@ const READD_SECRET = /** * Copies the key to a new private file under {@link RETIRED_KEY_DIR}, then - * removes it. The command checks, when it runs, that the directory is not - * inside a git work tree (a home directory kept in git, say) and stops - * before copying anything if it is. mktemp gives each copy its own name + * removes it. The command copies only when git itself answers that the + * directory is not in a repository; inside a work tree (a home directory + * kept in git, say), or when git is missing or cannot tell, it stops before + * copying anything. mktemp gives each copy its own name * (0600), so a later rotation never overwrites an earlier one; the key is * removed only once its copy is written. */ export function retiredKeyCopyCommand(key: string): string { - return `install -d -m 700 ${RETIRED_KEY_DIR} && ! git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree >/dev/null 2>&1 && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; + return `install -d -m 700 ${RETIRED_KEY_DIR} && case "$(LC_ALL=C git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree 2>&1)" in *"not a git repository"*) true ;; *) false ;; esac && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; } /** @@ -255,7 +256,7 @@ export function symlinkKeyRepairCommand(key: string): string { } function rotateRemedy(path: (suffix?: string) => string): string { - return `keep a recoverable copy of the old key outside every git checkout, then remove it: '${retiredKeyCopyCommand(path())}' (it stops without copying if ${RETIRED_KEY_DIR} is inside a git work tree; then copy the key to a private directory outside any checkout yourself). Then ${READD_SECRET}; Hack generates a new 256-bit key. Delete the retired copy only once every secret decrypts under the new key.`; + return `keep a recoverable copy of the old key outside every git checkout, then remove it: '${retiredKeyCopyCommand(path())}' (it stops without copying unless git confirms ${RETIRED_KEY_DIR} is outside every repository; then copy the key to a private directory outside any checkout yourself). Then ${READD_SECRET}; Hack generates a new 256-bit key. Delete the retired copy only once every secret decrypts under the new key.`; } async function inspectCandidate(opts: { diff --git a/src/lib/fs.ts b/src/lib/fs.ts index ea02b89fe..aaa3395df 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -953,7 +953,7 @@ const GITIGNORE_TRAILING_SPACES = / +$/; * pattern (` .hack.secret.key` names a file that starts with two spaces). * Escaped trailing spaces (`\ `) are not unescaped: no Hack entry ends in one. */ -function gitignoreLinePattern(line: string): string { +export function gitignoreLinePattern(line: string): string { return line .replace(GITIGNORE_LINE_CR, "") .replace(GITIGNORE_TRAILING_SPACES, ""); diff --git a/src/lib/private-staging.ts b/src/lib/private-staging.ts index 0cc9d38d6..398a93053 100644 --- a/src/lib/private-staging.ts +++ b/src/lib/private-staging.ts @@ -207,8 +207,7 @@ export type PreparedStaging = { * self-ignoring `.gitignore`; an existing entry there that is a link, not a * directory, or not the user's own is refused with `refusal()`, so a staging * copy is never created through it (see {@link admitStagingDirectory}). Old leftovers - * of this target in the staging directory are swept first; siblings an - * earlier version left are left to `hack doctor`. + * are never removed here; `hack doctor --fix` removes them. */ export async function preparePrivateStaging(opts: { readonly target: string; @@ -228,15 +227,10 @@ export async function preparePrivateStaging(opts: { throw opts.refusal(); } await ensureSelfIgnore({ directory, refusal: opts.refusal }); - const leftovers = await findStagingLeftovers({ - directory: dirname(opts.target), - names: [basename(opts.target)], - now: opts.now ?? Date.now(), - stagingOnly: true, - }); - for (const leftover of leftovers) { - await removeStagingLeftover(leftover).catch(() => false); - } + // No sweep here: a writer cannot tell a leftover from a file someone + // configured at a staging-shaped path (a recovered key, say), so removing + // leftovers is left to `hack doctor --fix`, which excludes every configured + // file. Leftovers here are invisible to git meanwhile (HACK-2165). return { path: join(directory, `${basename(opts.target)}.${randomUUID()}.tmp`), recheck: async () => { diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 6e0d3a89d..c8150a050 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -21,7 +21,7 @@ import { MACHINE_LOCAL_LAYER_NAME, PLAINTEXT_DOTENV_NAME, } from "./env-layer-modes.ts"; -import { ensureGitignoreEntry } from "./fs.ts"; +import { gitignoreLinePattern, writeTextFileAtomicIfChanged } from "./fs.ts"; import { isKeychainDisabled } from "./keychain.ts"; import { findStagingLeftovers, @@ -901,47 +901,62 @@ async function tightenMode( } } -/** Whether `path` opens (no-follow) and reads as a regular file. */ -async function isReadableRegularFile(path: string): Promise { +/** + * The text of a regular file read through a no-follow, non-blocking handle; + * null when it cannot be opened or read as one (never "absent": a caller + * checked that it exists). + */ +async function readRegularFileText(path: string): Promise { const file = await open( path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK ).catch(() => null); if (!file) { - return false; + return null; } try { const info = await file.stat(); - if (!info.isFile()) { - return false; - } - await file.readFile(); - return true; + return info.isFile() ? await file.readFile("utf8") : null; } catch { - return false; + return null; } finally { await file.close(); } } +/** + * Adds the entry to the ignore file, merging into exactly the bytes read + * here: an existing file that cannot be read is never rewritten (a reader + * that turned the failure into "absent" would replace the user's rules + * with this one entry), and the write is a compare-and-swap against those + * bytes, so a file that changed meanwhile is left alone. + */ async function addIgnoreEntry( fix: Extract ): Promise { - // Never write through a linked ignore file, and never rewrite one whose - // bytes could not be read: the writer treats an unreadable file as absent - // and would replace the user's rules with this one entry. const existing = await lstat(fix.gitignorePath).catch(() => null); if (existing && !existing.isFile()) { return false; } - if (existing && !(await isReadableRegularFile(fix.gitignorePath))) { + const current = existing + ? await readRegularFileText(fix.gitignorePath) + : null; + if (existing && current === null) { return false; } - await ensureGitignoreEntry({ - gitignorePath: fix.gitignorePath, - entry: fix.entry, - comment: "# machine-local secrets (hack doctor --fix)", - }); + const present = (current ?? "") + .split("\n") + .some((line) => gitignoreLinePattern(line) === fix.entry); + if (!present) { + const base = + current === null || current === "" || current.endsWith("\n") + ? (current ?? "") + : `${current}\n`; + const addition = `${base === "" ? "" : "\n"}# machine-local secrets (hack doctor --fix)\n${fix.entry}\n`; + await writeTextFileAtomicIfChanged(fix.gitignorePath, base + addition, { + expectedExisting: current, + }); + } // Applied only when git now ignores the file (a later rule may re-include it). return (await isGitIgnoredProjectEnvPath(fix.verify)) === true; } diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index bb6c55bbc..1bf8e5aba 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -884,6 +884,31 @@ describe("printed key recovery commands leave nothing git can collect (HACK-2165 expect(await untracked(box.home)).toEqual([]); }); + test("rotation stops without copying when git cannot tell (not a confirmed non-repository)", async () => { + const box = await sandbox(); + await writeFile(join(box.project, KEY_FILE), KEY, { mode: 0o600 }); + // A git that fails for another reason: unavailable metadata, say. + const bin = join(box.root, "bin"); + await mkdir(bin); + await writeFile( + join(bin, "git"), + "#!/bin/sh\necho 'fatal: unable to inspect' >&2\nexit 128\n", + { mode: 0o755 } + ); + const proc = Bun.spawn(["sh", "-c", retiredKeyCopyCommand(KEY_FILE)], { + cwd: box.project, + env: { + ...fixtureGitEnv({ env: { HOME: box.home } }), + PATH: `${bin}:/usr/bin:/bin`, + }, + stdout: "ignore", + stderr: "ignore", + }); + expect(await proc.exited).not.toBe(0); + expect(await readFile(join(box.project, KEY_FILE), "utf8")).toBe(KEY); + expect(await readdir(join(box.home, ".hack", "retired-keys"))).toEqual([]); + }); + test("the symlink repair leaves a private regular key and no intermediate copy", async () => { const box = await sandbox(); const target = join(box.root, "elsewhere.key"); diff --git a/tests/private-staging.test.ts b/tests/private-staging.test.ts index 3ec823b67..ce6105067 100644 --- a/tests/private-staging.test.ts +++ b/tests/private-staging.test.ts @@ -146,7 +146,7 @@ test("the sibling option keeps the previous staging location", async () => { expect(await readdir(root)).toEqual(["claim.json"]); }); -test("the next staging write sweeps only its own old staging copies; recent ones and siblings are kept", async () => { +test("doctor finds old staging copies and siblings; the next staging write removes none of them", async () => { const root = await tempDir(); const target = join(root, ".env"); const staging = join(root, PRIVATE_STAGING_DIRNAME); @@ -171,18 +171,28 @@ test("the next staging write sweeps only its own old staging copies; recent ones [oldSibling, oldStaged].sort() ); + // A writer removes nothing, in the staging directory or beside it: it + // cannot tell a leftover from a file configured at a staging-shaped path, + // so removal is left to `hack doctor --fix` (HACK-2165). + const before = (await readdir(staging)).sort(); await preparePrivateStaging({ target, refusal }); - expect((await readdir(staging)).sort()).toEqual( - [".gitignore", `.env.${UUID.replace("0f", "1f")}.tmp`].sort() - ); - // A writer never removes siblings beside the target: it cannot tell an - // earlier version's leftover from a file configured under such a name, so - // those are left to `hack doctor` (HACK-2165). + expect((await readdir(staging)).sort()).toEqual(before); expect((await readdir(root)).sort()).toEqual( [PRIVATE_STAGING_DIRNAME, `..env.${UUID}.tmp`, `..envx.${UUID}.tmp`].sort() ); }); +test("staging for one target never touches a live file staged under a longer name", async () => { + const root = await tempDir(); + await preparePrivateStaging({ target: join(root, ".env"), refusal }); + // A recovered key kept where it was staged for a target `.env.key`. + const liveKey = join(root, PRIVATE_STAGING_DIRNAME, `.env.key.${UUID}.tmp`); + await writeFile(liveKey, "synthetic-live-key\n", { mode: 0o600 }); + await backdate(liveKey); + await preparePrivateStaging({ target: join(root, ".env"), refusal }); + expect(await readFile(liveKey, "utf8")).toBe("synthetic-live-key\n"); +}); + test("a linked staging directory is refused; a loose one of the user's own is made private", async () => { const root = await tempDir(); const elsewhere = join(root, "elsewhere"); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 7af821431..6aac27404 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -639,3 +639,42 @@ test.skipIf(process.getuid?.() === 0)( } } ); + +test("an ignore file whose read fails while it is merged keeps its rules", async () => { + const f = await fixture(); + const overlay = join(f.projectDir, ".env.qa"); + await writeFile(overlay, "QA=1\n", { mode: 0o600 }); + const ignore = join(f.projectDir, ".gitignore"); + const before = await readFile(ignore, "utf8"); + const findings = await inspectSecretsHygiene(f.scope); + expect(findings.map((finding) => finding.fix?.kind)).toEqual(["ignore"]); + // Every path-based read of the ignore file fails (an I/O error, say); a + // reader that turned that into "absent" would replace the rules. + const original = Bun.file; + const failing = ((path: unknown, ...rest: unknown[]) => { + const file = Reflect.apply(original, Bun, [path, ...rest]) as ReturnType< + typeof Bun.file + >; + if (String(path) !== ignore) { + return file; + } + const error = Object.assign(new Error("synthetic EIO"), { code: "EIO" }); + return Object.assign(Object.create(file), { + text: async () => { + throw error; + }, + }); + }) as typeof Bun.file; + Bun.file = failing; + let result: { applied: number; skipped: number }; + try { + result = await applySecretsHygieneFixes({ + findings, + report: () => undefined, + }); + } finally { + Bun.file = original; + } + expect(await readFile(ignore, "utf8")).toStartWith(before); + expect(result.applied + result.skipped).toBe(1); +}); From 92689defbcc05e9c5b4f7849c23395746716087d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:23:53 +0000 Subject: [PATCH 26/42] fix(env): rotation asks git without discovery overrides; ignore repair never writes through a link (HACK-2165) The printed rotation command now runs its "is this a checkout" probe with GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR and GIT_CEILING_DIRECTORIES removed and discovery across file systems on, so a stale GIT_DIR or a ceiling in the shell can no longer produce the "not a git repository" answer for a directory inside a home checkout. Ignore repair publishes with noFollowTarget, so a link put at the admitted .gitignore after its read (by another user, in a shared directory) is refused instead of written through. Regressions: a home checkout with a stale GIT_DIR, a discovery ceiling or a stray GIT_WORK_TREE in the shell; a link swapped in between admission and publication. The first two and the swap fail without the change. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor-env-key.ts | 7 ++++-- src/lib/secrets-hygiene.ts | 3 +++ tests/doctor-env-key.test.ts | 33 +++++++++++++++++++++++++++ tests/secrets-hygiene.test.ts | 43 +++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index 6a49c5446..bdd28d1e7 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -236,12 +236,15 @@ const READD_SECRET = * removes it. The command copies only when git itself answers that the * directory is not in a repository; inside a work tree (a home directory * kept in git, say), or when git is missing or cannot tell, it stops before - * copying anything. mktemp gives each copy its own name + * copying anything. Git asks with its discovery overrides removed (a stale + * `GIT_DIR`, a work tree or ceiling set in the shell) and across file + * systems, so the answer is about the directory itself as plain discovery in + * any shell would find it. mktemp gives each copy its own name * (0600), so a later rotation never overwrites an earlier one; the key is * removed only once its copy is written. */ export function retiredKeyCopyCommand(key: string): string { - return `install -d -m 700 ${RETIRED_KEY_DIR} && case "$(LC_ALL=C git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree 2>&1)" in *"not a git repository"*) true ;; *) false ;; esac && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; + return `install -d -m 700 ${RETIRED_KEY_DIR} && case "$(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_CEILING_DIRECTORIES GIT_DISCOVERY_ACROSS_FILESYSTEM=1 LC_ALL=C git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree 2>&1)" in *"not a git repository"*) true ;; *) false ;; esac && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; } /** diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index c8150a050..1ffc44885 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -955,6 +955,9 @@ async function addIgnoreEntry( const addition = `${base === "" ? "" : "\n"}# machine-local secrets (hack doctor --fix)\n${fix.entry}\n`; await writeTextFileAtomicIfChanged(fix.gitignorePath, base + addition, { expectedExisting: current, + // Publish onto the entry admitted above, never through a link put there + // since (by another user, in a shared directory). + noFollowTarget: true, }); } // Applied only when git now ignores the file (a later rule may re-include it). diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index 1bf8e5aba..500d0bfc6 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -909,6 +909,39 @@ describe("printed key recovery commands leave nothing git can collect (HACK-2165 expect(await readdir(join(box.home, ".hack", "retired-keys"))).toEqual([]); }); + for (const [label, override] of [ + [ + "a stale GIT_DIR", + (root: string) => ({ GIT_DIR: join(root, "gone.git") }), + ], + [ + "a discovery ceiling", + (root: string) => ({ GIT_CEILING_DIRECTORIES: join(root, "home") }), + ], + ["a stray GIT_WORK_TREE", (root: string) => ({ GIT_WORK_TREE: root })], + ] as const) { + test(`rotation finds a home checkout despite ${label} in the shell`, async () => { + const box = await sandbox(); + await git(box.home, ["init", "-q", "-b", "main"]); + await writeFile(join(box.project, KEY_FILE), KEY, { mode: 0o600 }); + const proc = Bun.spawn(["sh", "-c", retiredKeyCopyCommand(KEY_FILE)], { + cwd: box.project, + env: { + ...fixtureGitEnv({ env: { HOME: box.home } }), + ...override(box.root), + }, + stdout: "ignore", + stderr: "ignore", + }); + expect(await proc.exited).not.toBe(0); + expect(await readFile(join(box.project, KEY_FILE), "utf8")).toBe(KEY); + expect(await readdir(join(box.home, ".hack", "retired-keys"))).toEqual( + [] + ); + expect(await untracked(box.home)).toEqual([]); + }); + } + test("the symlink repair leaves a private regular key and no intermediate copy", async () => { const box = await sandbox(); const target = join(box.root, "elsewhere.key"); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 6aac27404..43285de44 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -678,3 +678,46 @@ test("an ignore file whose read fails while it is merged keeps its rules", async expect(await readFile(ignore, "utf8")).toStartWith(before); expect(result.applied + result.skipped).toBe(1); }); + +test("ignore repair publishes onto the admitted file, never through a link swapped in after", async () => { + const f = await fixture(); + const overlay = join(f.projectDir, ".env.qa"); + await writeFile(overlay, "QA=1\n", { mode: 0o600 }); + const ignore = join(f.projectDir, ".gitignore"); + const before = await readFile(ignore, "utf8"); + // A file outside the project with the same text, so a compare on content + // alone would accept it. + const outside = join(f.root, "outside.txt"); + await writeFile(outside, before); + const findings = await inspectSecretsHygiene(f.scope); + expect(findings.map((finding) => finding.fix?.kind)).toEqual(["ignore"]); + // The admission read uses a descriptor; the writer's own read of the path + // comes after it. Swap the entry for a link at that point, as another user + // with write access to a shared directory could. + const original = Bun.file; + let swapped = false; + const swapping = ((path: unknown, ...rest: unknown[]) => { + const file = Reflect.apply(original, Bun, [path, ...rest]) as ReturnType< + typeof Bun.file + >; + if (String(path) !== ignore || swapped) { + return file; + } + return Object.assign(Object.create(file), { + text: async () => { + swapped = true; + await rm(ignore); + await symlink(outside, ignore); + return before; + }, + }); + }) as typeof Bun.file; + Bun.file = swapping; + try { + await applySecretsHygieneFixes({ findings, report: () => undefined }); + } finally { + Bun.file = original; + } + expect(swapped).toBe(true); + expect(await readFile(outside, "utf8")).toBe(before); +}); From 85ee75ab6257347ababa2077652936c521ae04fb Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:31:57 +0000 Subject: [PATCH 27/42] fix(env): rotation clears every repository-selecting git variable before its checkout probe (HACK-2165) The probe now unsets the set git itself clears when it enters another repository (git rev-parse --local-env-vars: GIT_DIR, GIT_OBJECT_DIRECTORY, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_INDEX_FILE, config parameters and the rest) plus GIT_CEILING_DIRECTORIES, instead of a hand-picked four. A stale GIT_OBJECT_DIRECTORY no longer turns a home checkout into "not a git repository". If git cannot list the variables, the command stops. Regressions: a stale GIT_OBJECT_DIRECTORY, alone and with a stale GIT_DIR; both fail with the previous command. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor-env-key.ts | 13 ++++++++----- tests/doctor-env-key.test.ts | 11 +++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index bdd28d1e7..58665d852 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -236,15 +236,18 @@ const READD_SECRET = * removes it. The command copies only when git itself answers that the * directory is not in a repository; inside a work tree (a home directory * kept in git, say), or when git is missing or cannot tell, it stops before - * copying anything. Git asks with its discovery overrides removed (a stale - * `GIT_DIR`, a work tree or ceiling set in the shell) and across file - * systems, so the answer is about the directory itself as plain discovery in - * any shell would find it. mktemp gives each copy its own name + * copying anything. Git is asked with every repository-selecting variable + * removed: the set git itself clears when it enters another repository + * (`git rev-parse --local-env-vars`: `GIT_DIR`, `GIT_OBJECT_DIRECTORY`, + * `GIT_WORK_TREE` and the rest) plus the discovery ceiling, and with + * discovery across file systems, so a stale value in the shell cannot turn a + * checkout into "not a git repository". If git cannot list those variables + * the command stops. mktemp gives each copy its own name * (0600), so a later rotation never overwrites an earlier one; the key is * removed only once its copy is written. */ export function retiredKeyCopyCommand(key: string): string { - return `install -d -m 700 ${RETIRED_KEY_DIR} && case "$(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_CEILING_DIRECTORIES GIT_DISCOVERY_ACROSS_FILESYSTEM=1 LC_ALL=C git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree 2>&1)" in *"not a git repository"*) true ;; *) false ;; esac && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; + return `install -d -m 700 ${RETIRED_KEY_DIR} && v="$(git rev-parse --local-env-vars)" && [ -n "$v" ] && case "$(unset $v GIT_CEILING_DIRECTORIES; GIT_DISCOVERY_ACROSS_FILESYSTEM=1 LC_ALL=C git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree 2>&1)" in *"not a git repository"*) true ;; *) false ;; esac && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; } /** diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index 500d0bfc6..cd0100418 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -918,6 +918,17 @@ describe("printed key recovery commands leave nothing git can collect (HACK-2165 "a discovery ceiling", (root: string) => ({ GIT_CEILING_DIRECTORIES: join(root, "home") }), ], + [ + "a stale GIT_OBJECT_DIRECTORY", + (root: string) => ({ GIT_OBJECT_DIRECTORY: join(root, "gone-objects") }), + ], + [ + "a stale GIT_DIR and GIT_OBJECT_DIRECTORY together", + (root: string) => ({ + GIT_DIR: join(root, "gone.git"), + GIT_OBJECT_DIRECTORY: join(root, "gone-objects"), + }), + ], ["a stray GIT_WORK_TREE", (root: string) => ({ GIT_WORK_TREE: root })], ] as const) { test(`rotation finds a home checkout despite ${label} in the shell`, async () => { From f485eb596981204cd5950bab316a2dc089a2edf9 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:37:29 +0000 Subject: [PATCH 28/42] fix(env): the printed rotation command reads git's variable list the same way in zsh (HACK-2165) zsh does not word-split an unquoted parameter, so `unset $v` received the whole newline-separated list as one invalid name and the probe failed even outside every checkout. The list is now fetched and split as a command substitution inside the probe's own subshell, which sh, bash and zsh all split; a failed or empty list still stops the command before any copy. Regressions: under sh, bash and zsh -f (each skipped where the shell is missing), a key outside every checkout is retired and a home checkout with a stale GIT_DIR and GIT_OBJECT_DIRECTORY stops. The zsh success case fails with the previous command. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor-env-key.ts | 5 ++-- tests/doctor-env-key.test.ts | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index 58665d852..22b2fad3c 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -242,12 +242,13 @@ const READD_SECRET = * `GIT_WORK_TREE` and the rest) plus the discovery ceiling, and with * discovery across file systems, so a stale value in the shell cannot turn a * checkout into "not a git repository". If git cannot list those variables - * the command stops. mktemp gives each copy its own name + * the command stops. The list is split as a command substitution, never as + * a parameter, so sh, bash and zsh (which does not split `$v`) read it alike. mktemp gives each copy its own name * (0600), so a later rotation never overwrites an earlier one; the key is * removed only once its copy is written. */ export function retiredKeyCopyCommand(key: string): string { - return `install -d -m 700 ${RETIRED_KEY_DIR} && v="$(git rev-parse --local-env-vars)" && [ -n "$v" ] && case "$(unset $v GIT_CEILING_DIRECTORIES; GIT_DISCOVERY_ACROSS_FILESYSTEM=1 LC_ALL=C git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree 2>&1)" in *"not a git repository"*) true ;; *) false ;; esac && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; + return `install -d -m 700 ${RETIRED_KEY_DIR} && case "$(v="$(git rev-parse --local-env-vars)" && [ -n "$v" ] && unset $(echo "$v") GIT_CEILING_DIRECTORIES && GIT_DISCOVERY_ACROSS_FILESYSTEM=1 LC_ALL=C git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree 2>&1)" in *"not a git repository"*) true ;; *) false ;; esac && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; } /** diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index cd0100418..94c63e488 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -953,6 +953,59 @@ describe("printed key recovery commands leave nothing git can collect (HACK-2165 }); } + // The command is printed for a person to paste into their own shell; zsh + // (the macOS default) does not word-split a parameter the way sh does. + for (const shell of [["sh"], ["bash"], ["zsh", "-f"]] as const) { + const name = shell.join(" "); + const runIn = async ( + box: { project: string; home: string; root: string }, + extra: Record + ) => { + const proc = Bun.spawn( + [...shell, "-c", retiredKeyCopyCommand(KEY_FILE)], + { + cwd: box.project, + env: { ...fixtureGitEnv({ env: { HOME: box.home } }), ...extra }, + stdout: "ignore", + stderr: "ignore", + } + ); + return await proc.exited; + }; + test.skipIf(Bun.which(shell[0]) === null)( + `rotation under ${name} retires the key when the directory is outside every checkout`, + async () => { + const box = await sandbox(); + await writeFile(join(box.project, KEY_FILE), KEY, { mode: 0o600 }); + expect(await runIn(box, {})).toBe(0); + expect( + await lstat(join(box.project, KEY_FILE)).catch(() => null) + ).toBeNull(); + expect( + await readdir(join(box.home, ".hack", "retired-keys")) + ).toHaveLength(1); + } + ); + test.skipIf(Bun.which(shell[0]) === null)( + `rotation under ${name} stops for a home checkout despite a stale GIT_DIR and GIT_OBJECT_DIRECTORY`, + async () => { + const box = await sandbox(); + await git(box.home, ["init", "-q", "-b", "main"]); + await writeFile(join(box.project, KEY_FILE), KEY, { mode: 0o600 }); + expect( + await runIn(box, { + GIT_DIR: join(box.root, "gone.git"), + GIT_OBJECT_DIRECTORY: join(box.root, "gone-objects"), + }) + ).not.toBe(0); + expect(await readFile(join(box.project, KEY_FILE), "utf8")).toBe(KEY); + expect(await readdir(join(box.home, ".hack", "retired-keys"))).toEqual( + [] + ); + } + ); + } + test("the symlink repair leaves a private regular key and no intermediate copy", async () => { const box = await sandbox(); const target = join(box.root, "elsewhere.key"); From 800be21a485787f7a42446bd54928152896b2bae Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:42:59 +0000 Subject: [PATCH 29/42] fix(env): rotation looks for a .git marker instead of reading git's error text (HACK-2165) Git answers "not a git repository" for a checkout whose metadata it cannot read, so its diagnostic could not tell no checkout from an uninspectable one. The printed rotation command now resolves the retired-key directory (cd && pwd -P) and walks to /, stopping on any .git entry (directory, file or link, readable or not). Every resolved ancestor was just traversed by cd, so the test there is reliable, and git's environment no longer matters. Regressions: a home checkout whose .git is mode 000 (under sh, bash and zsh -f), a .git file above the directory, and a ~/.hack linked into a checkout. The first two fail with the previous command; the stale-variable cases still pass. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor-env-key.ts | 23 +++++++------- tests/doctor-env-key.test.ts | 58 ++++++++++++++++++++++++------------ 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index 22b2fad3c..cddab8848 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -233,22 +233,19 @@ const READD_SECRET = /** * Copies the key to a new private file under {@link RETIRED_KEY_DIR}, then - * removes it. The command copies only when git itself answers that the - * directory is not in a repository; inside a work tree (a home directory - * kept in git, say), or when git is missing or cannot tell, it stops before - * copying anything. Git is asked with every repository-selecting variable - * removed: the set git itself clears when it enters another repository - * (`git rev-parse --local-env-vars`: `GIT_DIR`, `GIT_OBJECT_DIRECTORY`, - * `GIT_WORK_TREE` and the rest) plus the discovery ceiling, and with - * discovery across file systems, so a stale value in the shell cannot turn a - * checkout into "not a git repository". If git cannot list those variables - * the command stops. The list is split as a command substitution, never as - * a parameter, so sh, bash and zsh (which does not split `$v`) read it alike. mktemp gives each copy its own name + * removes it, but only once no directory from the resolved retired-key + * directory up to `/` holds a `.git` entry (a directory, a file or a link, + * readable or not). Inside a checkout (a home directory kept in git, say) it + * stops before copying anything. The check looks for the marker itself + * rather than asking git: git cannot tell a checkout whose metadata it cannot + * read, or one hidden by a stale `GIT_DIR`, from no checkout at all. Every + * resolved ancestor was just traversed by `cd`, so testing an entry in it + * is reliable. mktemp gives each copy its own name * (0600), so a later rotation never overwrites an earlier one; the key is * removed only once its copy is written. */ export function retiredKeyCopyCommand(key: string): string { - return `install -d -m 700 ${RETIRED_KEY_DIR} && case "$(v="$(git rev-parse --local-env-vars)" && [ -n "$v" ] && unset $(echo "$v") GIT_CEILING_DIRECTORIES && GIT_DISCOVERY_ACROSS_FILESYSTEM=1 LC_ALL=C git -C ${RETIRED_KEY_DIR} rev-parse --is-inside-work-tree 2>&1)" in *"not a git repository"*) true ;; *) false ;; esac && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; + return `install -d -m 700 ${RETIRED_KEY_DIR} && d="$(cd ${RETIRED_KEY_DIR} && pwd -P)" && while [ ! -e "$d/.git" ] && [ ! -L "$d/.git" ] && [ "$d" != / ]; do d="$(dirname "$d")"; done && [ ! -e "$d/.git" ] && [ ! -L "$d/.git" ] && k="$(mktemp ${RETIRED_KEY_DIR}/hack-secret-key.XXXXXXXX)" && cat ${key} > "$k" && rm ${key}`; } /** @@ -263,7 +260,7 @@ export function symlinkKeyRepairCommand(key: string): string { } function rotateRemedy(path: (suffix?: string) => string): string { - return `keep a recoverable copy of the old key outside every git checkout, then remove it: '${retiredKeyCopyCommand(path())}' (it stops without copying unless git confirms ${RETIRED_KEY_DIR} is outside every repository; then copy the key to a private directory outside any checkout yourself). Then ${READD_SECRET}; Hack generates a new 256-bit key. Delete the retired copy only once every secret decrypts under the new key.`; + return `keep a recoverable copy of the old key outside every git checkout, then remove it: '${retiredKeyCopyCommand(path())}' (it stops without copying if ${RETIRED_KEY_DIR} or any directory above it holds a .git entry; then copy the key to a private directory outside any checkout yourself). Then ${READD_SECRET}; Hack generates a new 256-bit key. Delete the retired copy only once every secret decrypts under the new key.`; } async function inspectCandidate(opts: { diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index 94c63e488..8dfdf8a78 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -884,31 +884,33 @@ describe("printed key recovery commands leave nothing git can collect (HACK-2165 expect(await untracked(box.home)).toEqual([]); }); - test("rotation stops without copying when git cannot tell (not a confirmed non-repository)", async () => { + test("rotation stops for a .git file (a linked worktree or submodule) above the directory", async () => { const box = await sandbox(); + await writeFile(join(box.home, ".git"), "gitdir: /elsewhere/.git\n"); await writeFile(join(box.project, KEY_FILE), KEY, { mode: 0o600 }); - // A git that fails for another reason: unavailable metadata, say. - const bin = join(box.root, "bin"); - await mkdir(bin); - await writeFile( - join(bin, "git"), - "#!/bin/sh\necho 'fatal: unable to inspect' >&2\nexit 128\n", - { mode: 0o755 } - ); - const proc = Bun.spawn(["sh", "-c", retiredKeyCopyCommand(KEY_FILE)], { - cwd: box.project, - env: { - ...fixtureGitEnv({ env: { HOME: box.home } }), - PATH: `${bin}:/usr/bin:/bin`, - }, - stdout: "ignore", - stderr: "ignore", - }); - expect(await proc.exited).not.toBe(0); + expect( + await run(box.project, box.home, retiredKeyCopyCommand(KEY_FILE)) + ).not.toBe(0); expect(await readFile(join(box.project, KEY_FILE), "utf8")).toBe(KEY); expect(await readdir(join(box.home, ".hack", "retired-keys"))).toEqual([]); }); + test("rotation checks the resolved directory: a ~/.hack linked into a checkout stops it", async () => { + const box = await sandbox(); + const checkout = join(box.root, "dotfiles"); + await mkdir(checkout); + await git(checkout, ["init", "-q", "-b", "main"]); + await mkdir(join(checkout, "hack")); + await symlink(join(checkout, "hack"), join(box.home, ".hack")); + await writeFile(join(box.project, KEY_FILE), KEY, { mode: 0o600 }); + expect( + await run(box.project, box.home, retiredKeyCopyCommand(KEY_FILE)) + ).not.toBe(0); + expect(await readFile(join(box.project, KEY_FILE), "utf8")).toBe(KEY); + expect(await readdir(join(checkout, "hack", "retired-keys"))).toEqual([]); + expect(await untracked(checkout)).toEqual([]); + }); + for (const [label, override] of [ [ "a stale GIT_DIR", @@ -986,6 +988,24 @@ describe("printed key recovery commands leave nothing git can collect (HACK-2165 ).toHaveLength(1); } ); + test.skipIf(Bun.which(shell[0]) === null || process.getuid?.() === 0)( + `rotation under ${name} stops for a home checkout whose .git cannot be read`, + async () => { + const box = await sandbox(); + await git(box.home, ["init", "-q", "-b", "main"]); + await writeFile(join(box.project, KEY_FILE), KEY, { mode: 0o600 }); + await chmod(join(box.home, ".git"), 0o000); + try { + expect(await runIn(box, {})).not.toBe(0); + } finally { + await chmod(join(box.home, ".git"), 0o755); + } + expect(await readFile(join(box.project, KEY_FILE), "utf8")).toBe(KEY); + expect(await readdir(join(box.home, ".hack", "retired-keys"))).toEqual( + [] + ); + } + ); test.skipIf(Bun.which(shell[0]) === null)( `rotation under ${name} stops for a home checkout despite a stale GIT_DIR and GIT_OBJECT_DIRECTORY`, async () => { From beb4c6c2b03fa3edc1232d9ad9ffa66d15b7190c Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:18:03 +0000 Subject: [PATCH 30/42] fix(env): the store-key tracking git child gets the filtered environment; close threat-model gaps 17 and 18 isKeyFileTrackedByGit (from #766) spawned git with the full environment, so HACK_ENV_SECRET_KEY and other Hack key material reached the child. It now passes childEnvironment(), as #756 requires for tool children. Regression: a fresh process with a recording git first on PATH checks a key inside a work tree with a sentinel project key set; git sees no Hack key variable. It fails without the change. The threat model's gaps 17 (HACK-2169, closed by #766) and 18 (HACK-2165, closed by #762 and this branch) now describe the implemented guards and their remaining limits, and the rows that pointed at them as open agree. Co-Authored-By: Claude Opus 5.5 --- docs/security/env-secrets-threat-model.md | 48 +++++++++++------------ src/lib/secret-store.ts | 3 ++ tests/secret-store.test.ts | 43 ++++++++++++++++++++ 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/docs/security/env-secrets-threat-model.md b/docs/security/env-secrets-threat-model.md index a427548b2..ccbe0bf6b 100644 --- a/docs/security/env-secrets-threat-model.md +++ b/docs/security/env-secrets-threat-model.md @@ -116,11 +116,11 @@ HACK-2163 hardens three boundaries found in the env audit: | --- | --- | | A repository's contract names a variable exported in the developer's shell, so the migration saves it to disk. | `migrateLegacyProjectEnv` refuses, naming the key only, when a `plain_env` value resolves from the process environment (HACK-1688). A value in a regular `.hack/.env` migrates to the gitignored machine-local layer, never a tracked one (HACK-1712). | | A repository plants `.hack.secret.key` as a symlink (dangling or not) so that Hack reads or writes a file it should not. | Key reads go through `readBoundedPrivateFile` (`lstat`, `O_NOFOLLOW`, descriptor and path identity checks, 1 MiB bound, fatal UTF-8). New keys are created with `createPrivateFileExclusive` (`O_CREAT|O_EXCL|O_NOFOLLOW`, mode 0600, `fchmod`, `fsync`). Both are in `src/lib/private-file.ts`, used by `resolveProjectEnvKey`, `ensureProjectEnvSecretKey` and `resolveInheritedProjectEnvKey` in `src/lib/project-env-config.ts` (#288). | -| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private staging file is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by mutation paths in `src/lib/hack-env.ts` and `materializeProjectEnv`. Legacy inspection still follows links through `readTextFile`; see gap 18. Staging copies of these files and of keys live in `.hack-staging/` next to the target, a private (0700) directory whose `.gitignore` of `*` keeps git from listing a copy a crash leaves. Writers never remove anything there, since a file configured at a staging-shaped path could be live; `hack doctor --fix` removes copies older than 10 minutes and never a configured store or key (`src/lib/private-staging.ts`, `src/lib/secrets-hygiene.ts`, HACK-2165). | +| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private staging file is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by mutation paths in `src/lib/hack-env.ts` and `materializeProjectEnv`. Dotenv reads in `src/lib/hack-env.ts` use the no-follow private-file reader (#762; gap 18, closed). Staging copies of these files and of keys live in `.hack-staging/` next to the target, a private (0700) directory whose `.gitignore` of `*` keeps git from listing a copy a crash leaves. Writers never remove anything there, since a file configured at a staging-shaped path could be live; `hack doctor --fix` removes copies older than 10 minutes and never a configured store or key (`src/lib/private-staging.ts`, `src/lib/secrets-hygiene.ts`, HACK-2165). | | A world-readable env file or store. | `.hack/.env`, overlays and the `encrypted_file` store are written 0600, and a same-content file with group or other access is tightened (#302). Machine-local layers are created 0600, and a write, even one that changes nothing, drops the group and other bits of an existing one; commands that read layers warn about a loose one (HACK-2165). The `hack doctor` env key check reports `env-key-weak-permissions` and `env-key-wrong-owner` (#309), and its "secrets hygiene" section reports loose, linked, tracked or unignored secret-bearing files, staging leftovers, stale terminal-session payloads and a store whose key is missing; `--fix` tightens modes, adds ignore entries and removes Hack's own leftovers, never a store, key or user file (`src/lib/secrets-hygiene.ts`, HACK-2165). | | The Compose backend leaves decrypted values at rest in `.hack/.internal/compose.env.override.yml` after `up`, `down`, a failed `up` or an interrupted one. | The override holds only `${HACK_ENV__}` references; each value reaches `docker compose` through its process environment, so the generated override does not persist resolved values (command output can still contain them) (`renderComposeEnvReferenceOverride` in `src/lib/compose-managed-env.ts`). The file keeps its path because Compose records it in each container's `config_files` label. It is written with `replacePrivateFileAtomic` (mode 0600, symlink refused), and a literal value is refused before any write (`writeComposeEnvOverride` in `src/lib/compose-env-at-rest.ts`). A file left by an earlier version is replaced by the next Compose command that renders the override, removed by one that renders none and by `hack down` (before its hooks, remote target and Compose down, so none of them can skip it), and reported by the `hack doctor` "compose env at rest" check by counts only; `hack doctor --fix` removes it. Removal moves the file aside atomically and classifies what it moved, so a value-free file written concurrently is put back rather than deleted (the path is absent for that moment; see gap 4). An aside left by a crash between the move and the delete is reported by the doctor check by count and removed by a later removal, `hack doctor --fix` or a Compose command that renders the override, once it is more than a minute old. The doctor check, every removal and the aside sweep apply the writer's no-follow check to `.hack` and `.hack/.internal` first (and recheck them before each move or delete): a symlinked or non-directory `.hack` or `.hack/.internal`, or a symlinked or non-regular override, is a closed, value-free refusal, and nothing behind it is listed, read, moved or deleted (HACK-1316). Legacy adoption gives the reference values only to its Compose config-hash probe at preview and preparation; a saved recheck runs without values and binds each container's config-hash label to the hash proven at preparation. `hack config adopt` (with or without `--stop`) and its preview refuse a literal override before preparation, because an adoption pins the file's bytes and inode for its life. Recovery acts on an existing preparation, so the store refuses instead: publication (`--recover --stop`) and switch completion (`--recover`) refuse a generation bound to a literal override before any original moves, and name `hack config adopt --recover --rollback` (HACK-1318). Rollback stays allowed. While a prepared, active or pending adoption exists, no removal touches the file (removal would make the adoption unloadable); an adoption that an earlier version made over a literal file therefore keeps its values at rest until `hack config adopt --rollback`, and the doctor check names that next step (see gap 4). The `HACK_ENV_*` references, shared for global scope and distinct for service overrides, go only to `docker compose` invocations that read the override; the `docker inspect`, `logs` and `events` children for dependency cache progress and reuse get the environment without them (`withoutComposeEnvReferences`). Proven with a stub `docker` by `tests/compose-env-at-rest-cli.test.ts`, `tests/compose-env-at-rest.test.ts`, `tests/compose-env-reference-least-privilege.test.ts`, `tests/doctor-fix-noninteractive.test.ts`, `tests/native-compose-adoption-generation-managed.test.ts`, `tests/native-compose-adoption-generation-recovery.test.ts` and the sentinel scan `tests/env-secret-sentinel-scan.test.ts` (HACK-1273, HACK-1318). | | Two writers of the `encrypted_file` store lose an update. | An in-process queue plus a cooperative `.lock` using the owner protocol from `src/lib/projects-registry-lock.ts`; a symlinked store path is refused for writes (`src/lib/secret-store.ts`, #302). | -| Concurrent first users of the `encrypted_file` store each generate a key; the last key file wins and the store, already encrypted under another key, can no longer be decrypted. | The generated key is published with `publishPrivateFileExclusive` (`src/lib/private-file.ts`): a fsynced 0600 staging file (in `.hack-staging/` next to the key) is linked into place only when no key file exists, so losers adopt the published key. A store found non-empty after the first key read re-reads the key file before the keychain fallback. Proven across eight worker processes by `tests/secret-key-provisioning-convergence.test.ts`. This does not cover the process-global cache and explicit provisioning gaps in gap 17. | +| Concurrent first users of the `encrypted_file` store each generate a key; the last key file wins and the store, already encrypted under another key, can no longer be decrypted. | The generated key is published with `publishPrivateFileExclusive` (`src/lib/private-file.ts`): a fsynced 0600 staging file (in `.hack-staging/` next to the key) is linked into place only when no key file exists, so losers adopt the published key. A store found non-empty after the first key read re-reads the key file before the keychain fallback. Proven across eight worker processes by `tests/secret-key-provisioning-convergence.test.ts`. The process-global cache and explicit provisioning are covered separately (gap 17, closed by #766). | | Concurrent first secrets in the primary checkout and a linked worktree create two different project keys (checkout-local and shared), so values do not decrypt across checkouts; or a reader refuses a key file that another process is still publishing. | When no key exists, `ensureProjectEnvSecretKey` (`src/lib/project-env-config.ts`) creates it under a cooperative lock at `/.hack.secret.key.lock` and repeats the lookup under the lock. Key files are published complete with `publishPrivateFileExclusive`, and a key read that overlaps a publication is retried for about a quarter second while the entry is a regular file. Proven by `tests/secret-key-provisioning-convergence.test.ts`. Hack v4 writers do not take the lock. | ### Malformed or hostile ciphertext @@ -298,28 +298,28 @@ planned branch or a passing documentation check is not a mitigation. are not checked for other writers; staged multiplexer payloads are removed by the next staging or `hack doctor --fix` (which also reports them), not by an independent reaper. -17. **Open: secret-store and key integrity (HACK-2169).** In - `src/lib/secret-store.ts`, an unreadable encrypted store can be treated as empty - by `readEncryptedEntries`; a later mutation can replace it with only the new - entry. The process-global key cache is not bound to a store/key path, so a - cached key can be reused for another store whose key is absent. Explicit key - provisioning can replace an existing key without authenticating and re-encrypting - the store under the new one. Separately, project env add/set can create a file - key despite a working environment-only project key; file precedence then hides - that environment key from reads (`src/lib/project-env-config.ts`, - `src/commands/env.ts`). These are integrity and recoverability gaps, not safe - rotation paths. Keep them open until the corresponding fixes merge. -18. **Open: local-layer symlinks, ignores and modes (HACK-2165).** Hydration checks - whether the logical local path is tracked before resolving an allowed - in-project symlink; the writer can then replace its tracked shared target. - Ordinary local add/set and hydrate can warn about failed ignore setup and - still write, and named local layers can already be tracked - (`src/lib/project-env-config.ts`, `src/lib/project-env-layer-path.ts`, - `src/lib/fs.ts`). Existing local-layer modes are preserved. Legacy dotenv - inspection also follows symlinks through `readTextFile`, including links - outside the project (`src/lib/hack-env.ts`); mutation checks do not protect - that read path. Keep these gaps open until the hygiene fixes merge; an - in-project target or local filename is not proof of private storage. +17. **Closed: secret-store and key integrity (HACK-2169, #766).** A store + that exists but cannot be read is refused, never treated as empty; the + process-wide key cache holds only keychain material, so a file or + environment key never stands in for another store's key; explicit + provisioning never replaces a different existing key; and project env + add/set refuse before writing a file key that would shadow a working + `HACK_ENV_SECRET_KEY`. The store key file is read without following a link + and refused when loose or tracked by git (`src/lib/secret-store.ts`, + `src/lib/project-env-config.ts`). Remaining: rotating a key is still a + manual re-encryption (re-add each secret), and `hack doctor`'s secret + integrity check reports damage from before these fixes without repairing it. +18. **Closed: local-layer symlinks, ignores and modes (HACK-2165).** A linked + machine-local layer is refused and writes replace the admitted path itself, + never a link's target (#762); a local write proceeds only once the layer is + confirmed ignored and untracked (#762); dotenv reads in `src/lib/hack-env.ts` + use the no-follow private-file reader (#762); machine-local layers are + created 0600, an existing loose one is tightened by the next write, and + commands that read layers warn about a loose one (#764). Remaining: + permission bits only (macOS extended ACLs are not inspected), and a layer + created by older code under an unusual umask stays as created until a write + or `hack doctor --fix` tightens it. An in-project target or local filename + is still not proof of private storage on another filesystem. 19. **Open: migration cleanup recheck (HACK-2171).** Cleanup removes the current path after an earlier migration/consent decision without rechecking the selected file's current bytes and identity under a lock diff --git a/src/lib/secret-store.ts b/src/lib/secret-store.ts index 543de53e1..78090cdc5 100644 --- a/src/lib/secret-store.ts +++ b/src/lib/secret-store.ts @@ -13,6 +13,7 @@ import type { SecretsConfig, } from "../control-plane/sdk/config.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; +import { childEnvironment } from "./child-environment.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { envParseDiagnostic } from "./env-parse-diagnostic.ts"; import { ensureDir } from "./fs.ts"; @@ -1294,6 +1295,8 @@ async function isKeyFileTrackedByGit(path: string): Promise { "--", basename(path), ], + // Never hand Hack's own key material to the git child (#756). + env: childEnvironment(), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/tests/secret-store.test.ts b/tests/secret-store.test.ts index 62a581c9a..52533cc2a 100644 --- a/tests/secret-store.test.ts +++ b/tests/secret-store.test.ts @@ -1236,3 +1236,46 @@ test("malformed secret-store JSON diagnostic has coordinates and no source text" expect(String(error)).toMatch(/Invalid JSON syntax at line \d+, column \d+/); expect(String(error)).not.toContain(STORE_SENTINEL); }); + +test("the key-tracking git child never receives Hack's key material (#756, HACK-2169)", async () => { + // A fresh process: Bun resolves a spawned command against the PATH it + // started with, so the recording git must be first on that PATH. + if (!tempDir) { + throw new Error("Missing temp dir"); + } + Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); + const storePath = join(tempDir, "k.enc.json"); + const keyPath = join(tempDir, "k.key"); + await encryptedFileConfig({ storePath, keyPath }); + await privateKey(keyPath, "store-key-child"); + // A `.git` above the key, so the tracking check runs git. + await mkdir(join(tempDir, ".git")); + const bin = join(tempDir, "bin"); + await mkdir(bin); + const record = join(tempDir, "git-env.txt"); + await writeFile( + join(bin, "git"), + `#!/bin/sh\nenv | sed -n 's/^\\(HACK_[A-Z_]*\\)=.*/\\1/p' | sort > '${record}'\nexit 1\n`, + { mode: 0o755 } + ); + const script = join(tempDir, "child.ts"); + await writeFile( + script, + `import { resolveSecretStore } from ${JSON.stringify(join(import.meta.dir, "..", "src", "lib", "secret-store.ts"))};\nawait resolveSecretStore({ projectName: "app" }).catch(() => undefined);\n` + ); + const child = Bun.spawn([process.execPath, script], { + cwd: tempDir, + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? "/usr/bin:/bin"}`, + HACK_ENV_SECRET_KEY: "synthetic-project-key-sentinel", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + }, + stdout: "ignore", + stderr: "ignore", + }); + expect(await child.exited).toBe(0); + const seen = (await readFile(record, "utf8")).split("\n").filter(Boolean); + expect(seen).not.toContain("HACK_ENV_SECRET_KEY"); + expect(seen).not.toContain("HACK_SECRETS_FILE_KEY"); +}); From 08275dc1f7fc4302979641525656d408074df604 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:24:54 +0000 Subject: [PATCH 31/42] docs(security): state the markerless-work-tree limit and that local layer modes are tightened (HACK-2165) Gap 18 now says how Hack finds git (a .git entry at or above the path, or GIT_DIR, both failing closed) and that a work tree used only through --git-dir/--work-tree flags or an alias is invisible to Hack as to plain git. The plaintext-on-disk paragraph no longer says writes keep an existing layer's mode. Co-Authored-By: Claude Opus 5.5 --- docs/security/env-secrets-threat-model.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/security/env-secrets-threat-model.md b/docs/security/env-secrets-threat-model.md index ccbe0bf6b..66c45c090 100644 --- a/docs/security/env-secrets-threat-model.md +++ b/docs/security/env-secrets-threat-model.md @@ -319,7 +319,13 @@ planned branch or a passing documentation check is not a mitigation. permission bits only (macOS extended ACLs are not inspected), and a layer created by older code under an unusual umask stays as created until a write or `hack doctor --fix` tightens it. An in-project target or local filename - is still not proof of private storage on another filesystem. + is still not proof of private storage on another filesystem. Git is found + the way git finds it: a `.git` entry at or above the path, or `GIT_DIR` in + Hack's environment (both fail closed when git cannot answer). A work tree + used only through `--git-dir`/`--work-tree` flags or an alias (a bare + dotfiles repository, say) leaves neither, so Hack, like plain `git`, cannot + see it and neither refuses a write there nor reports one; keep stores and + keys out of such a tree, or ignore them in it. 19. **Open: migration cleanup recheck (HACK-2171).** Cleanup removes the current path after an earlier migration/consent decision without rechecking the selected file's current bytes and identity under a lock @@ -330,9 +336,12 @@ planned branch or a passing documentation check is not a mitigation. ### Plaintext on disk outside materialization Local YAML can hold plain values, including values migrated from a v4 dotenv -file. `writeProjectEnvConfigFile` creates new local layers mode 0600 but keeps an -existing layer's mode (`src/lib/project-env-config.ts`). Check older files by -hand; a local filename is not proof of encryption or private permissions. +file. `writeProjectEnvConfigFile` creates new local layers mode 0600 and drops the +group and other bits of an existing one on every write, even one that changes +nothing (`src/lib/project-env-config.ts`); `hack doctor --fix` does the same for +layers no write has touched since. Until then an older layer keeps the mode it +was created with, and commands that read it warn. A local filename is not proof +of encryption. `stageMuxEnvironment` writes resolved values to `$TMPDIR/hack-mux-env-*/env.json` with mode 0600; the pane wrapper consumes and removes that payload From 1d6e26aa33367ce215939c2695639861211bd891 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:37:43 +0000 Subject: [PATCH 32/42] fix(doctor): hygiene cleanup keeps every env input resolution reads, rechecked at removal (HACK-2165) The leftover filter knew configured stores and keys, the shared key and the project key, but not the other inputs current env resolution reads, and it ran only at inspection. It now also keeps the project inputs legacy cleanup keeps (#769: the primary worktree's key, hack.config.json and every YAML layer), now exported as projectEnvLiveInputPaths, each as given and resolved. --fix asks again just before each unlink, and when the project's inputs cannot be inspected nothing counts as removable. Matching is by path, not inode: removing another link to a live file never removes the name resolution reads, and a publication interrupted after its link step leaves exactly such a second name to clean. Regressions: a YAML layer that comes to resolve to a staging-shaped file between inspection and --fix keeps it; an uninspectable hack.config.json blocks every leftover removal. Both fail without the change. Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 6 +- docs/security/env-secrets-threat-model.md | 2 +- src/lib/project-env-config.ts | 14 ++++ src/lib/secrets-hygiene.ts | 81 +++++++++++++++++------ tests/secrets-hygiene.test.ts | 47 +++++++++++++ 5 files changed, 125 insertions(+), 25 deletions(-) diff --git a/docs/env.md b/docs/env.md index 6815d8fea..d409ad8ff 100644 --- a/docs/env.md +++ b/docs/env.md @@ -465,8 +465,10 @@ store, a key or a file you wrote, and never untracks a file. Hack writes a file that holds a secret by staging the complete contents first. Those staging copies live in a private directory next to the file, `.hack-staging/`, which holds a `.gitignore` of `*`, so git never lists anything in it. `hack doctor --fix` -removes copies older than 10 minutes (never a file your configuration uses as a store or -key); writes themselves remove nothing there. A machine-local layer that a write changes, or +removes copies older than 10 minutes, but never a file env resolution reads (a configured +store or key, the project or shared key, `hack.config.json` or a YAML layer, by its own +path or where it resolves), checked again just before each removal; writes themselves +remove nothing there. A machine-local layer that a write changes, or leaves unchanged, is set to 0600 if other users could read it. Commands that read env layers warn on stderr about a machine-local file other users can read. diff --git a/docs/security/env-secrets-threat-model.md b/docs/security/env-secrets-threat-model.md index 66c45c090..61cc6e8a6 100644 --- a/docs/security/env-secrets-threat-model.md +++ b/docs/security/env-secrets-threat-model.md @@ -116,7 +116,7 @@ HACK-2163 hardens three boundaries found in the env audit: | --- | --- | | A repository's contract names a variable exported in the developer's shell, so the migration saves it to disk. | `migrateLegacyProjectEnv` refuses, naming the key only, when a `plain_env` value resolves from the process environment (HACK-1688). A value in a regular `.hack/.env` migrates to the gitignored machine-local layer, never a tracked one (HACK-1712). | | A repository plants `.hack.secret.key` as a symlink (dangling or not) so that Hack reads or writes a file it should not. | Key reads go through `readBoundedPrivateFile` (`lstat`, `O_NOFOLLOW`, descriptor and path identity checks, 1 MiB bound, fatal UTF-8). New keys are created with `createPrivateFileExclusive` (`O_CREAT|O_EXCL|O_NOFOLLOW`, mode 0600, `fchmod`, `fsync`). Both are in `src/lib/private-file.ts`, used by `resolveProjectEnvKey`, `ensureProjectEnvSecretKey` and `resolveInheritedProjectEnvKey` in `src/lib/project-env-config.ts` (#288). | -| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private staging file is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by mutation paths in `src/lib/hack-env.ts` and `materializeProjectEnv`. Dotenv reads in `src/lib/hack-env.ts` use the no-follow private-file reader (#762; gap 18, closed). Staging copies of these files and of keys live in `.hack-staging/` next to the target, a private (0700) directory whose `.gitignore` of `*` keeps git from listing a copy a crash leaves. Writers never remove anything there, since a file configured at a staging-shaped path could be live; `hack doctor --fix` removes copies older than 10 minutes and never a configured store or key (`src/lib/private-staging.ts`, `src/lib/secrets-hygiene.ts`, HACK-2165). | +| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private staging file is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by mutation paths in `src/lib/hack-env.ts` and `materializeProjectEnv`. Dotenv reads in `src/lib/hack-env.ts` use the no-follow private-file reader (#762; gap 18, closed). Staging copies of these files and of keys live in `.hack-staging/` next to the target, a private (0700) directory whose `.gitignore` of `*` keeps git from listing a copy a crash leaves. Writers never remove anything there, since a file configured at a staging-shaped path could be live; `hack doctor --fix` removes copies older than 10 minutes and never a path current resolution reads: configured stores and keys, the shared key and the project inputs legacy cleanup also keeps (`projectEnvLiveInputPaths`), each as given and resolved, rechecked just before the unlink; if those inputs cannot be inspected nothing is removed (`src/lib/private-staging.ts`, `src/lib/secrets-hygiene.ts`, HACK-2165). | | A world-readable env file or store. | `.hack/.env`, overlays and the `encrypted_file` store are written 0600, and a same-content file with group or other access is tightened (#302). Machine-local layers are created 0600, and a write, even one that changes nothing, drops the group and other bits of an existing one; commands that read layers warn about a loose one (HACK-2165). The `hack doctor` env key check reports `env-key-weak-permissions` and `env-key-wrong-owner` (#309), and its "secrets hygiene" section reports loose, linked, tracked or unignored secret-bearing files, staging leftovers, stale terminal-session payloads and a store whose key is missing; `--fix` tightens modes, adds ignore entries and removes Hack's own leftovers, never a store, key or user file (`src/lib/secrets-hygiene.ts`, HACK-2165). | | The Compose backend leaves decrypted values at rest in `.hack/.internal/compose.env.override.yml` after `up`, `down`, a failed `up` or an interrupted one. | The override holds only `${HACK_ENV__}` references; each value reaches `docker compose` through its process environment, so the generated override does not persist resolved values (command output can still contain them) (`renderComposeEnvReferenceOverride` in `src/lib/compose-managed-env.ts`). The file keeps its path because Compose records it in each container's `config_files` label. It is written with `replacePrivateFileAtomic` (mode 0600, symlink refused), and a literal value is refused before any write (`writeComposeEnvOverride` in `src/lib/compose-env-at-rest.ts`). A file left by an earlier version is replaced by the next Compose command that renders the override, removed by one that renders none and by `hack down` (before its hooks, remote target and Compose down, so none of them can skip it), and reported by the `hack doctor` "compose env at rest" check by counts only; `hack doctor --fix` removes it. Removal moves the file aside atomically and classifies what it moved, so a value-free file written concurrently is put back rather than deleted (the path is absent for that moment; see gap 4). An aside left by a crash between the move and the delete is reported by the doctor check by count and removed by a later removal, `hack doctor --fix` or a Compose command that renders the override, once it is more than a minute old. The doctor check, every removal and the aside sweep apply the writer's no-follow check to `.hack` and `.hack/.internal` first (and recheck them before each move or delete): a symlinked or non-directory `.hack` or `.hack/.internal`, or a symlinked or non-regular override, is a closed, value-free refusal, and nothing behind it is listed, read, moved or deleted (HACK-1316). Legacy adoption gives the reference values only to its Compose config-hash probe at preview and preparation; a saved recheck runs without values and binds each container's config-hash label to the hash proven at preparation. `hack config adopt` (with or without `--stop`) and its preview refuse a literal override before preparation, because an adoption pins the file's bytes and inode for its life. Recovery acts on an existing preparation, so the store refuses instead: publication (`--recover --stop`) and switch completion (`--recover`) refuse a generation bound to a literal override before any original moves, and name `hack config adopt --recover --rollback` (HACK-1318). Rollback stays allowed. While a prepared, active or pending adoption exists, no removal touches the file (removal would make the adoption unloadable); an adoption that an earlier version made over a literal file therefore keeps its values at rest until `hack config adopt --rollback`, and the doctor check names that next step (see gap 4). The `HACK_ENV_*` references, shared for global scope and distinct for service overrides, go only to `docker compose` invocations that read the override; the `docker inspect`, `logs` and `events` children for dependency cache progress and reuse get the environment without them (`withoutComposeEnvReferences`). Proven with a stub `docker` by `tests/compose-env-at-rest-cli.test.ts`, `tests/compose-env-at-rest.test.ts`, `tests/compose-env-reference-least-privilege.test.ts`, `tests/doctor-fix-noninteractive.test.ts`, `tests/native-compose-adoption-generation-managed.test.ts`, `tests/native-compose-adoption-generation-recovery.test.ts` and the sentinel scan `tests/env-secret-sentinel-scan.test.ts` (HACK-1273, HACK-1318). | | Two writers of the `encrypted_file` store lose an update. | An in-process queue plus a cooperative `.lock` using the owner protocol from `src/lib/projects-registry-lock.ts`; a symlinked store path is refused for writes (`src/lib/secret-store.ts`, #302). | diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index e19d7f2f3..1c2174240 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -9330,6 +9330,20 @@ async function legacyCleanupLiveInputs(opts: { return { paths, identities }; } +/** + * The paths current env resolution reads for a project: its key, the shared + * key, the primary worktree's key, `hack.config.json` and every YAML layer in + * the project and primary-worktree `.hack` directories (HACK-2165, the same + * set legacy cleanup keeps). Any cleanup that deletes files must keep these. + * Throws when an input cannot be inspected; a caller must then delete nothing. + */ +export async function projectEnvLiveInputPaths(opts: { + readonly projectRoot: string; + readonly projectDir: string; +}): Promise> { + return (await legacyCleanupLiveInputs(opts)).paths; +} + async function legacyCleanupInputStat(opts: { readonly path: string; readonly follow: boolean; diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 656bf70ae..340759370 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -32,6 +32,7 @@ import { import { isGitIgnoredProjectEnvPath, isGitTrackedOrUnknownProjectPath, + projectEnvLiveInputPaths, } from "./project-env-config.ts"; /** @@ -91,7 +92,12 @@ export type SecretsHygieneFix = /** Asked again after the entry is added: the fix counts only if git now ignores it. */ readonly verify: { readonly projectRoot: string; readonly path: string }; } - | { readonly kind: "remove_leftover"; readonly leftover: StagingLeftover } + | { + readonly kind: "remove_leftover"; + readonly leftover: StagingLeftover; + /** Resolved again just before removal: a live input is never removed. */ + readonly scope?: SecretsHygieneScope; + } | { readonly kind: "remove_mux"; readonly stale: StaleMuxEnvironment }; /** A directory's identity, captured at inspection and checked before a fix. */ @@ -779,11 +785,10 @@ export async function inspectSecretsHygiene( const live = await livePaths(scope); const kept: SecretsHygieneFinding[] = []; for (const finding of findings) { - if ( - finding.fix?.kind !== "remove_leftover" || - !(await isLive({ path: finding.path, live })) - ) { + if (finding.fix?.kind !== "remove_leftover") { kept.push(finding); + } else if (!(await isLive({ path: finding.path, live }))) { + kept.push({ ...finding, fix: { ...finding.fix, scope } }); } } findings.splice(0, findings.length, ...kept); @@ -805,16 +810,28 @@ export async function inspectSecretsHygiene( * Files the configuration uses (stores, keys, the project and shared keys), * as given and resolved. */ +/** + * Every path current resolution reads (HACK-2165): configured stores and + * keys, the shared key, and the project's env inputs (its key, the primary + * worktree's key, `hack.config.json` and every YAML layer; see + * `projectEnvLiveInputPaths`), each as given and resolved. "unknown" when the + * project's inputs cannot be inspected: then nothing counts as removable. + */ async function livePaths( scope: SecretsHygieneScope -): Promise> { +): Promise | "unknown"> { const paths = [ ...scope.stores.flatMap((store) => [store.storePath, store.keyPath]), ...(scope.sharedKeyPath ? [scope.sharedKeyPath] : []), - ...(scope.project - ? [join(scope.project.projectRoot, PROJECT_ENV_KEY_FILENAME)] - : []), ]; + if (scope.project) { + paths.push(join(scope.project.projectRoot, PROJECT_ENV_KEY_FILENAME)); + try { + paths.push(...(await projectEnvLiveInputPaths(scope.project))); + } catch { + return "unknown"; + } + } const live = new Set(); for (const path of paths) { live.add(resolve(path)); @@ -826,11 +843,16 @@ async function livePaths( return live; } +/** + * Whether resolution reads `path`, by its own spelling or resolved. A path, + * not an inode: removing another link to a live file never removes the name + * resolution reads. + */ async function isLive(opts: { readonly path: string; - readonly live: ReadonlySet; + readonly live: ReadonlySet | "unknown"; }): Promise { - if (opts.live.has(resolve(opts.path))) { + if (opts.live === "unknown" || opts.live.has(resolve(opts.path))) { return true; } const real = await realpath(opts.path).catch(() => null); @@ -990,17 +1012,7 @@ export async function applySecretsHygieneFixes(opts: { done.add(key); let changed = false; try { - if (!(await guardHolds(finding.guard))) { - changed = false; - } else if (fix.kind === "chmod") { - changed = await tightenMode(fix); - } else if (fix.kind === "ignore") { - changed = await addIgnoreEntry(fix); - } else if (fix.kind === "remove_leftover") { - changed = await removeStagingLeftover(fix.leftover); - } else { - changed = await removeStaleMuxEnvironment(fix.stale); - } + changed = (await guardHolds(finding.guard)) && (await applyFix(fix)); } catch { changed = false; } @@ -1017,6 +1029,31 @@ export async function applySecretsHygieneFixes(opts: { return { applied, skipped }; } +/** Applies one fix; false when it no longer applies. */ +async function applyFix(fix: SecretsHygieneFix): Promise { + switch (fix.kind) { + case "chmod": + return await tightenMode(fix); + case "ignore": + return await addIgnoreEntry(fix); + case "remove_leftover": + // The configuration may have changed since inspection: ask again, and + // never remove a path current resolution reads. + if ( + fix.scope !== undefined && + (await isLive({ + path: fix.leftover.path, + live: await livePaths(fix.scope), + })) + ) { + return false; + } + return await removeStagingLeftover(fix.leftover); + default: + return await removeStaleMuxEnvironment(fix.stale); + } +} + function describeApplied(opts: { readonly fix: SecretsHygieneFix; readonly path: string; diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 43285de44..7951feebc 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -721,3 +721,50 @@ test("ignore repair publishes onto the admitted file, never through a link swapp expect(swapped).toBe(true); expect(await readFile(outside, "utf8")).toBe(before); }); + +test("an env input current resolution reads is never removed, even when its real file looks like a leftover", async () => { + const f = await fixture(); + // A YAML layer whose real file has a staging-shaped name (left by a + // recovery that linked the layer to an old staging copy). + const real = join(f.projectDir, `.hack.env.yaml.${UUID}.tmp`); + await writeFile(real, `values:\n SENTINEL: ${SENTINEL}\n`, { mode: 0o600 }); + await backdate(real); + // Without the layer it is an ordinary leftover. + const before = await inspectSecretsHygiene(f.scope); + expect( + before.some( + (finding) => + finding.fix?.kind === "remove_leftover" && finding.path === real + ) + ).toBe(true); + // The layer appears between inspection and --fix: the removal asks again. + await symlink(real, join(f.projectDir, "hack.env.yaml")); + await applySecretsHygieneFixes({ findings: before, report: () => undefined }); + expect(await readFile(real, "utf8")).toContain(SENTINEL); + // And a fresh inspection never offers it. + const after = await inspectSecretsHygiene(f.scope); + expect( + after.some( + (finding) => + finding.fix?.kind === "remove_leftover" && finding.path === real + ) + ).toBe(false); +}); + +test("when the project's env inputs cannot be inspected, no leftover is removed", async () => { + const f = await fixture(); + const leftover = join(f.projectRoot, `..hack.secret.key.${UUID}.tmp`); + await writeFile(leftover, `${SENTINEL}\n`, { mode: 0o600 }); + await backdate(leftover); + // A configuration path that cannot be resolved (a link to itself). + const config = join(f.projectDir, "hack.config.json"); + await symlink(config, config); + const findings = await inspectSecretsHygiene(f.scope); + expect( + findings.some((finding) => finding.fix?.kind === "remove_leftover") + ).toBe(false); + await rm(config); + // Once it can, the same file is an ordinary leftover again. + const later = await inspectSecretsHygiene(f.scope); + expect(paths(later)).toContain(leftover); +}); From 385b638e64c3da2fdab78e752c0d97823af1dbf4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:50:08 +0000 Subject: [PATCH 33/42] fix(doctor): every hygiene removal checks the current configuration's live inputs (HACK-2165) Stale terminal payloads were offered and removed without the live-input check, so a configured key at /hack-mux-env-*/env.json could be deleted. Both removal kinds now go through one check on the file they unlink, at inspection and again just before the unlink. The recheck before the unlink reused the inspected store and key paths, so a configuration edit between inspection and --fix was missed. doctor now passes its scope resolver; each removal resolves the scope again and the target must be live in neither the inspected nor the current scope. A resolution failure refuses the removal. Regressions: a configured key inside a stale payload directory (inspected with and before the configuration); a leftover the configuration selects after inspection; a current scope that cannot be resolved. The first two fail without the change; with the configuration unchanged the leftover is still removed. Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 9 +- docs/security/env-secrets-threat-model.md | 2 +- src/commands/doctor.ts | 2 + src/lib/secrets-hygiene.ts | 112 ++++++++++++++++------ tests/secrets-hygiene.test.ts | 72 ++++++++++++++ 5 files changed, 164 insertions(+), 33 deletions(-) diff --git a/docs/env.md b/docs/env.md index d409ad8ff..bfccdda72 100644 --- a/docs/env.md +++ b/docs/env.md @@ -465,10 +465,11 @@ store, a key or a file you wrote, and never untracks a file. Hack writes a file that holds a secret by staging the complete contents first. Those staging copies live in a private directory next to the file, `.hack-staging/`, which holds a `.gitignore` of `*`, so git never lists anything in it. `hack doctor --fix` -removes copies older than 10 minutes, but never a file env resolution reads (a configured -store or key, the project or shared key, `hack.config.json` or a YAML layer, by its own -path or where it resolves), checked again just before each removal; writes themselves -remove nothing there. A machine-local layer that a write changes, or +removes copies older than 10 minutes, and stale terminal-session payloads, but never a +file env resolution reads (a configured store or key, the project or shared key, +`hack.config.json` or a YAML layer, by its own path or where it resolves). Before each +removal it reads the configuration again, so an edit made since the check counts, and +it removes nothing it cannot confirm; writes themselves remove nothing there. A machine-local layer that a write changes, or leaves unchanged, is set to 0600 if other users could read it. Commands that read env layers warn on stderr about a machine-local file other users can read. diff --git a/docs/security/env-secrets-threat-model.md b/docs/security/env-secrets-threat-model.md index 61cc6e8a6..699b5ac4a 100644 --- a/docs/security/env-secrets-threat-model.md +++ b/docs/security/env-secrets-threat-model.md @@ -116,7 +116,7 @@ HACK-2163 hardens three boundaries found in the env audit: | --- | --- | | A repository's contract names a variable exported in the developer's shell, so the migration saves it to disk. | `migrateLegacyProjectEnv` refuses, naming the key only, when a `plain_env` value resolves from the process environment (HACK-1688). A value in a regular `.hack/.env` migrates to the gitignored machine-local layer, never a tracked one (HACK-1712). | | A repository plants `.hack.secret.key` as a symlink (dangling or not) so that Hack reads or writes a file it should not. | Key reads go through `readBoundedPrivateFile` (`lstat`, `O_NOFOLLOW`, descriptor and path identity checks, 1 MiB bound, fatal UTF-8). New keys are created with `createPrivateFileExclusive` (`O_CREAT|O_EXCL|O_NOFOLLOW`, mode 0600, `fchmod`, `fsync`). Both are in `src/lib/private-file.ts`, used by `resolveProjectEnvKey`, `ensureProjectEnvSecretKey` and `resolveInheritedProjectEnvKey` in `src/lib/project-env-config.ts` (#288). | -| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private staging file is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by mutation paths in `src/lib/hack-env.ts` and `materializeProjectEnv`. Dotenv reads in `src/lib/hack-env.ts` use the no-follow private-file reader (#762; gap 18, closed). Staging copies of these files and of keys live in `.hack-staging/` next to the target, a private (0700) directory whose `.gitignore` of `*` keeps git from listing a copy a crash leaves. Writers never remove anything there, since a file configured at a staging-shaped path could be live; `hack doctor --fix` removes copies older than 10 minutes and never a path current resolution reads: configured stores and keys, the shared key and the project inputs legacy cleanup also keeps (`projectEnvLiveInputPaths`), each as given and resolved, rechecked just before the unlink; if those inputs cannot be inspected nothing is removed (`src/lib/private-staging.ts`, `src/lib/secrets-hygiene.ts`, HACK-2165). | +| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private staging file is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by mutation paths in `src/lib/hack-env.ts` and `materializeProjectEnv`. Dotenv reads in `src/lib/hack-env.ts` use the no-follow private-file reader (#762; gap 18, closed). Staging copies of these files and of keys live in `.hack-staging/` next to the target, a private (0700) directory whose `.gitignore` of `*` keeps git from listing a copy a crash leaves. Writers never remove anything there, since a file configured at a staging-shaped path could be live; `hack doctor --fix` removes copies older than 10 minutes and never a path current resolution reads: configured stores and keys, the shared key and the project inputs legacy cleanup also keeps (`projectEnvLiveInputPaths`), each as given and resolved; the same holds for stale terminal-session payloads. Just before each unlink the configuration is resolved again and the target must be live in neither it nor the inspected one; if either cannot be resolved nothing is removed. The automatic pre-staging mux sweep (HACK-2170) does not consult configuration (`src/lib/private-staging.ts`, `src/lib/secrets-hygiene.ts`, HACK-2165). | | A world-readable env file or store. | `.hack/.env`, overlays and the `encrypted_file` store are written 0600, and a same-content file with group or other access is tightened (#302). Machine-local layers are created 0600, and a write, even one that changes nothing, drops the group and other bits of an existing one; commands that read layers warn about a loose one (HACK-2165). The `hack doctor` env key check reports `env-key-weak-permissions` and `env-key-wrong-owner` (#309), and its "secrets hygiene" section reports loose, linked, tracked or unignored secret-bearing files, staging leftovers, stale terminal-session payloads and a store whose key is missing; `--fix` tightens modes, adds ignore entries and removes Hack's own leftovers, never a store, key or user file (`src/lib/secrets-hygiene.ts`, HACK-2165). | | The Compose backend leaves decrypted values at rest in `.hack/.internal/compose.env.override.yml` after `up`, `down`, a failed `up` or an interrupted one. | The override holds only `${HACK_ENV__}` references; each value reaches `docker compose` through its process environment, so the generated override does not persist resolved values (command output can still contain them) (`renderComposeEnvReferenceOverride` in `src/lib/compose-managed-env.ts`). The file keeps its path because Compose records it in each container's `config_files` label. It is written with `replacePrivateFileAtomic` (mode 0600, symlink refused), and a literal value is refused before any write (`writeComposeEnvOverride` in `src/lib/compose-env-at-rest.ts`). A file left by an earlier version is replaced by the next Compose command that renders the override, removed by one that renders none and by `hack down` (before its hooks, remote target and Compose down, so none of them can skip it), and reported by the `hack doctor` "compose env at rest" check by counts only; `hack doctor --fix` removes it. Removal moves the file aside atomically and classifies what it moved, so a value-free file written concurrently is put back rather than deleted (the path is absent for that moment; see gap 4). An aside left by a crash between the move and the delete is reported by the doctor check by count and removed by a later removal, `hack doctor --fix` or a Compose command that renders the override, once it is more than a minute old. The doctor check, every removal and the aside sweep apply the writer's no-follow check to `.hack` and `.hack/.internal` first (and recheck them before each move or delete): a symlinked or non-directory `.hack` or `.hack/.internal`, or a symlinked or non-regular override, is a closed, value-free refusal, and nothing behind it is listed, read, moved or deleted (HACK-1316). Legacy adoption gives the reference values only to its Compose config-hash probe at preview and preparation; a saved recheck runs without values and binds each container's config-hash label to the hash proven at preparation. `hack config adopt` (with or without `--stop`) and its preview refuse a literal override before preparation, because an adoption pins the file's bytes and inode for its life. Recovery acts on an existing preparation, so the store refuses instead: publication (`--recover --stop`) and switch completion (`--recover`) refuse a generation bound to a literal override before any original moves, and name `hack config adopt --recover --rollback` (HACK-1318). Rollback stays allowed. While a prepared, active or pending adoption exists, no removal touches the file (removal would make the adoption unloadable); an adoption that an earlier version made over a literal file therefore keeps its values at rest until `hack config adopt --rollback`, and the doctor check names that next step (see gap 4). The `HACK_ENV_*` references, shared for global scope and distinct for service overrides, go only to `docker compose` invocations that read the override; the `docker inspect`, `logs` and `events` children for dependency cache progress and reuse get the environment without them (`withoutComposeEnvReferences`). Proven with a stub `docker` by `tests/compose-env-at-rest-cli.test.ts`, `tests/compose-env-at-rest.test.ts`, `tests/compose-env-reference-least-privilege.test.ts`, `tests/doctor-fix-noninteractive.test.ts`, `tests/native-compose-adoption-generation-managed.test.ts`, `tests/native-compose-adoption-generation-recovery.test.ts` and the sentinel scan `tests/env-secret-sentinel-scan.test.ts` (HACK-1273, HACK-1318). | | Two writers of the `encrypted_file` store lose an update. | An in-process queue plus a cooperative `.lock` using the owner protocol from `src/lib/projects-registry-lock.ts`; a symlinked store path is refused for writes (`src/lib/secret-store.ts`, #302). | diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 1306057a8..f00beea89 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -5132,6 +5132,8 @@ async function maybeFixSecretsHygiene(opts: { await applySecretsHygieneFixes({ findings, report: (line) => lines.push(line), + // Removals check the configuration as it is then, not as inspected. + currentScope: () => resolveSecretsHygieneScope(opts), }); note(lines.join("\n"), SECRETS_HYGIENE_CHECK); } diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 340759370..df20a9561 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -98,7 +98,12 @@ export type SecretsHygieneFix = /** Resolved again just before removal: a live input is never removed. */ readonly scope?: SecretsHygieneScope; } - | { readonly kind: "remove_mux"; readonly stale: StaleMuxEnvironment }; + | { + readonly kind: "remove_mux"; + readonly stale: StaleMuxEnvironment; + /** As for `remove_leftover`. */ + readonly scope?: SecretsHygieneScope; + }; /** A directory's identity, captured at inspection and checked before a fix. */ export type DirectoryGuard = { @@ -780,18 +785,6 @@ export async function inspectSecretsHygiene( : []), ...(await allStoreFindings({ scope, now })), ]; - // A file the configuration uses is live, whatever its name looks like: it - // is never offered for removal as a leftover (HACK-2165). - const live = await livePaths(scope); - const kept: SecretsHygieneFinding[] = []; - for (const finding of findings) { - if (finding.fix?.kind !== "remove_leftover") { - kept.push(finding); - } else if (!(await isLive({ path: finding.path, live }))) { - kept.push({ ...finding, fix: { ...finding.fix, scope } }); - } - } - findings.splice(0, findings.length, ...kept); for (const stale of await findStaleMuxEnvironments({ ...(scope.tmpDir ? { tmpDir: scope.tmpDir } : {}), now, @@ -803,6 +796,19 @@ export async function inspectSecretsHygiene( fix: { kind: "remove_mux", stale }, }); } + // A file current resolution reads is live, whatever its name or place + // looks like: it is never offered for removal (HACK-2165). + const live = await livePaths(scope); + const kept: SecretsHygieneFinding[] = []; + for (const finding of findings) { + const fix = finding.fix; + if (fix?.kind !== "remove_leftover" && fix?.kind !== "remove_mux") { + kept.push(finding); + } else if (!(await isLive({ path: removalTarget(fix), live }))) { + kept.push({ ...finding, fix: { ...fix, scope } }); + } + } + findings.splice(0, findings.length, ...kept); return await Promise.all(findings.map(withGuard)); } @@ -993,6 +999,13 @@ async function addIgnoreEntry( export async function applySecretsHygieneFixes(opts: { readonly findings: readonly SecretsHygieneFinding[]; readonly report: (line: string) => void; + /** + * The scope as configured now. Before each removal it is resolved again + * and the target must be live in neither it nor the inspection's scope; a + * removal is refused when it cannot be resolved. Without it, only the + * inspection's scope is asked again (its files, not its configuration). + */ + readonly currentScope?: () => Promise; }): Promise<{ readonly applied: number; readonly skipped: number }> { let applied = 0; let skipped = 0; @@ -1012,7 +1025,9 @@ export async function applySecretsHygieneFixes(opts: { done.add(key); let changed = false; try { - changed = (await guardHolds(finding.guard)) && (await applyFix(fix)); + changed = + (await guardHolds(finding.guard)) && + (await applyFix({ fix, currentScope: opts.currentScope })); } catch { changed = false; } @@ -1029,28 +1044,69 @@ export async function applySecretsHygieneFixes(opts: { return { applied, skipped }; } +/** The file a removal unlinks. */ +function removalTarget( + fix: Extract +): string { + return fix.kind === "remove_leftover" + ? fix.leftover.path + : join(fix.stale.root, "env.json"); +} + +/** + * Whether a removal may still go ahead: its target is live in neither the + * inspection's scope nor the current one (resolved again here, so a + * configuration edit since inspection counts). Any failure to resolve + * refuses the removal. + */ +async function removalStillSafe(opts: { + readonly fix: Extract< + SecretsHygieneFix, + { kind: "remove_leftover" | "remove_mux" } + >; + readonly currentScope?: () => Promise; +}): Promise { + const scopes: SecretsHygieneScope[] = []; + if (opts.fix.scope) { + scopes.push(opts.fix.scope); + } + if (opts.currentScope) { + const current = await opts.currentScope().catch(() => null); + if (current === null) { + return false; + } + scopes.push(current); + } + const path = removalTarget(opts.fix); + for (const scope of scopes) { + if (await isLive({ path, live: await livePaths(scope) })) { + return false; + } + } + return true; +} + /** Applies one fix; false when it no longer applies. */ -async function applyFix(fix: SecretsHygieneFix): Promise { +async function applyFix(opts: { + readonly fix: SecretsHygieneFix; + readonly currentScope?: () => Promise; +}): Promise { + const { fix } = opts; switch (fix.kind) { case "chmod": return await tightenMode(fix); case "ignore": return await addIgnoreEntry(fix); case "remove_leftover": - // The configuration may have changed since inspection: ask again, and - // never remove a path current resolution reads. - if ( - fix.scope !== undefined && - (await isLive({ - path: fix.leftover.path, - live: await livePaths(fix.scope), - })) - ) { - return false; - } - return await removeStagingLeftover(fix.leftover); + return ( + (await removalStillSafe({ fix, currentScope: opts.currentScope })) && + (await removeStagingLeftover(fix.leftover)) + ); default: - return await removeStaleMuxEnvironment(fix.stale); + return ( + (await removalStillSafe({ fix, currentScope: opts.currentScope })) && + (await removeStaleMuxEnvironment(fix.stale)) + ); } } diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 7951feebc..b9b68d267 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -768,3 +768,75 @@ test("when the project's env inputs cannot be inspected, no leftover is removed" const later = await inspectSecretsHygiene(f.scope); expect(paths(later)).toContain(leftover); }); + +test("a configured key inside a stale terminal payload directory is never removed", async () => { + const f = await fixture(); + const tmpDir = f.scope.tmpDir ?? ""; + const payload = await stageMuxEnvironment( + { FAKE_KEY: "synthetic-mux" }, + { tmpDir } + ); + // Recovery pointed the backend at that file as its key. + const store = join(f.home, "secrets.enc.json"); + await writeFile(store, `{"sentinel":"${SENTINEL}"}\n`, { mode: 0o600 }); + await backdate(dirname(payload.path)); + const configured = { + ...f.scope, + stores: [{ storePath: store, keyPath: payload.path }], + }; + // Inspected with that configuration: no removal is offered. + const findings = await inspectSecretsHygiene(configured); + expect(findings.some((finding) => finding.fix?.kind === "remove_mux")).toBe( + false + ); + // Inspected before the configuration chose it: --fix asks the current + // configuration again and keeps the file. + const earlier = await inspectSecretsHygiene(f.scope); + expect(earlier.some((finding) => finding.fix?.kind === "remove_mux")).toBe( + true + ); + await applySecretsHygieneFixes({ + findings: earlier, + report: () => undefined, + currentScope: async () => configured, + }); + expect(JSON.parse(await readFile(payload.path, "utf8"))).toEqual({ + FAKE_KEY: "synthetic-mux", + }); + await payload.cleanup(); +}); + +test("a leftover the configuration selects after inspection is kept by --fix", async () => { + const f = await fixture(); + const leftover = join(f.projectRoot, `..hack.secret.key.${UUID}.tmp`); + await writeFile(leftover, `${SENTINEL}\n`, { mode: 0o600 }); + await backdate(leftover); + const findings = await inspectSecretsHygiene(f.scope); + expect(paths(findings)).toContain(leftover); + // A configuration edit between inspection and --fix makes it the key. + const store = join(f.home, "secrets.enc.json"); + const current = { + ...f.scope, + stores: [{ storePath: store, keyPath: leftover }], + }; + await applySecretsHygieneFixes({ + findings, + report: () => undefined, + currentScope: async () => current, + }); + expect(await readFile(leftover, "utf8")).toBe(`${SENTINEL}\n`); + // A configuration that cannot be resolved now refuses the removal too. + await applySecretsHygieneFixes({ + findings, + report: () => undefined, + currentScope: () => Promise.reject(new Error("synthetic config error")), + }); + expect(await readFile(leftover, "utf8")).toBe(`${SENTINEL}\n`); + // With the configuration unchanged, the leftover is removed. + await applySecretsHygieneFixes({ + findings, + report: () => undefined, + currentScope: async () => f.scope, + }); + expect(await lstat(leftover).catch(() => null)).toBeNull(); +}); From 734e799c29e432ead6241eec3ce87013f38931c7 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:00:40 +0000 Subject: [PATCH 34/42] fix(doctor): a hygiene removal resolves the current configuration strictly (HACK-2165) The scope doctor resolves again before a removal tolerated what it could not read: discovery and shared-key errors became missing entries, a config parseError fell back to the default store paths, and a config file that existed but could not be read was an empty layer. Any of these could make an unknown configuration look like one naming nothing, and approve the unlink of a file it now names. readControlPlaneConfig and resolveFileSecretStorePaths take an opt-in strict mode (only a missing file is an empty layer; a read or parse failure rejects), and resolveSecretsHygieneScope({ strict: true }) lets every discovery, read and parse failure reject. doctor's removal check uses it; a rejection refuses the removal. The report keeps its tolerant resolution. Regressions (tests/secrets-hygiene-scope.test.ts, through the production resolver): a malformed and an unreadable global configuration reject in strict mode; an absent one resolves; --fix removes nothing while the configuration is unreadable, and removes the leftover once it is readable and unchanged. Three fail without the change. Co-Authored-By: Claude Opus 5.5 --- src/commands/doctor.ts | 26 +++--- src/control-plane/sdk/config.ts | 23 ++++- src/lib/secret-store.ts | 11 +++ tests/secrets-hygiene-scope.test.ts | 133 ++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 tests/secrets-hygiene-scope.test.ts diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f00beea89..b29b86c5f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -3660,18 +3660,22 @@ const SECRETS_HYGIENE_CHECK = "secrets hygiene"; * What the "secrets hygiene" section covers from `startDir`: the project, when * there is a legacy (not adopted) one, its shared key, and the encrypted-file * stores the global and project configuration name. Reads configuration and - * git only. + * git only. A report tolerates what it cannot read; with `strict` (used to + * authorize a removal) any discovery, read or parse failure rejects instead, + * so an unknown configuration never looks like one that names nothing. */ -async function resolveSecretsHygieneScope(opts: { +export async function resolveSecretsHygieneScope(opts: { readonly startDir: string; + readonly strict?: boolean; }): Promise { - const project = await findDoctorLegacyProjectContext(opts.startDir).catch( - () => null - ); + const strict = opts.strict === true; + const tolerate = (promise: Promise): Promise => + strict ? promise : promise.catch(() => null); + const project = await tolerate(findDoctorLegacyProjectContext(opts.startDir)); const stores = new Map(); for (const projectDir of [undefined, project?.projectDir]) { - const paths = await resolveFileSecretStorePaths({ projectDir }).catch( - () => null + const paths = await tolerate( + resolveFileSecretStorePaths({ projectDir, strict }) ); const key = paths ? `${paths.storePath}\0${paths.keyPath}` : null; if (!(paths && key) || stores.has(key)) { @@ -3692,9 +3696,9 @@ async function resolveSecretsHygieneScope(opts: { ? { projectRoot: project.projectRoot, projectDir: project.projectDir } : null, sharedKeyPath: project - ? await resolveProjectEnvSharedKeyPath({ - projectRoot: project.projectRoot, - }).catch(() => null) + ? await tolerate( + resolveProjectEnvSharedKeyPath({ projectRoot: project.projectRoot }) + ) : null, stores: [...stores.values()], storeKeyFromEnvironment: @@ -5133,7 +5137,7 @@ async function maybeFixSecretsHygiene(opts: { findings, report: (line) => lines.push(line), // Removals check the configuration as it is then, not as inspected. - currentScope: () => resolveSecretsHygieneScope(opts), + currentScope: () => resolveSecretsHygieneScope({ ...opts, strict: true }), }); note(lines.join("\n"), SECRETS_HYGIENE_CHECK); } diff --git a/src/control-plane/sdk/config.ts b/src/control-plane/sdk/config.ts index cff7409ee..ece56e3cf 100644 --- a/src/control-plane/sdk/config.ts +++ b/src/control-plane/sdk/config.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { z } from "zod"; @@ -407,16 +408,24 @@ export function createDefaultControlPlaneConfig(): ControlPlaneConfig { */ export async function readControlPlaneConfig(opts: { readonly projectDir?: string; + /** + * Throw when a configuration file exists but cannot be read, instead of + * treating it as empty (HACK-2165): for callers that must tell an absent + * setting from an unknown one, such as a removal deciding what is live. + */ + readonly strict?: boolean; }): Promise { const globalLayer = await readControlPlaneLayer({ path: resolveGlobalConfigPath(), label: "Global config", + strict: opts.strict === true, }); const projectLayer = opts.projectDir ? await readControlPlaneLayer({ path: resolve(opts.projectDir, PROJECT_CONFIG_FILENAME), label: "Project config", + strict: opts.strict === true, }) : { config: {} }; @@ -440,8 +449,20 @@ type ControlPlaneConfigLayer = { async function readControlPlaneLayer(opts: { readonly path: string; readonly label: string; + readonly strict?: boolean; }): Promise { - const text = await readTextFile(opts.path); + const text = opts.strict + ? await readFile(opts.path, "utf8").catch((error: unknown) => { + // Only a missing file is an empty layer; anything else is unknown. + if ( + isRecord(error) && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ) { + return null; + } + throw new Error(`${opts.label} could not be read (${opts.path})`); + }) + : await readTextFile(opts.path); if (text === null) { return { config: {} }; } diff --git a/src/lib/secret-store.ts b/src/lib/secret-store.ts index 78090cdc5..8b6d1eb30 100644 --- a/src/lib/secret-store.ts +++ b/src/lib/secret-store.ts @@ -1675,10 +1675,21 @@ function buildEncryptedBackendKeyRecoveryMessage(input: { */ export async function resolveFileSecretStorePaths(input: { readonly projectDir?: string; + /** + * Reject, instead of falling back to the default paths, when the + * configuration cannot be read or does not parse (HACK-2165). + */ + readonly strict?: boolean; }): Promise<{ readonly storePath: string; readonly keyPath: string }> { const controlPlane = await readControlPlaneConfig({ projectDir: input.projectDir, + strict: input.strict === true, }); + if (input.strict === true && controlPlane.parseError) { + throw new Error( + "Encrypted store paths are unknown: the configuration does not parse" + ); + } const configured = controlPlane.config.secrets.encryptedFile; return { storePath: resolveConfiguredPath({ diff --git a/tests/secrets-hygiene-scope.test.ts b/tests/secrets-hygiene-scope.test.ts new file mode 100644 index 000000000..67c39c6fb --- /dev/null +++ b/tests/secrets-hygiene-scope.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + utimes, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveSecretsHygieneScope } from "../src/commands/doctor.ts"; +import { STAGING_LEFTOVER_MIN_AGE_MS } from "../src/lib/private-staging.ts"; +import { resolveFileSecretStorePaths } from "../src/lib/secret-store.ts"; +import { + applySecretsHygieneFixes, + inspectSecretsHygiene, +} from "../src/lib/secrets-hygiene.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +// The scope a hygiene removal is authorized against (HACK-2165): a report +// tolerates a configuration it cannot read, but a removal must not, or an +// unreadable or malformed configuration would look like one naming nothing. + +const SENTINEL = "synthetic-scope-sentinel"; +const UUID = "0f0e0d0c-0b0a-4908-8706-050403020100"; +let root = ""; +let saved: { home?: string; config?: string } = {}; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-hygiene-scope-")); + saved = { + home: process.env.HOME, + config: process.env.HACK_GLOBAL_CONFIG_PATH, + }; + process.env.HOME = join(root, "home"); + process.env.HACK_GLOBAL_CONFIG_PATH = join(root, "hack.config.json"); + await mkdir(join(root, "home")); + await mkdir(join(root, "start")); +}); + +afterEach(async () => { + restoreEnv("HOME", saved.home); + restoreEnv("HACK_GLOBAL_CONFIG_PATH", saved.config); + await chmod(join(root, "hack.config.json"), 0o600).catch(() => undefined); + await rm(root, { recursive: true, force: true }); +}); + +const config = () => join(root, "hack.config.json"); +const strictScope = () => + resolveSecretsHygieneScope({ startDir: join(root, "start"), strict: true }); + +test("a configuration that does not parse is unknown to a removal, not empty", async () => { + await writeFile(config(), "{ not json", { mode: 0o600 }); + // A report still resolves (with the default paths)... + await resolveFileSecretStorePaths({}); + await resolveSecretsHygieneScope({ startDir: join(root, "start") }); + // ...but a removal's scope does not. + await expect(resolveFileSecretStorePaths({ strict: true })).rejects.toThrow( + "does not parse" + ); + await expect(strictScope()).rejects.toThrow(); +}); + +test.skipIf(process.getuid?.() === 0)( + "a configuration that cannot be read is unknown to a removal, not empty", + async () => { + await writeFile(config(), "{}\n", { mode: 0o600 }); + await chmod(config(), 0o000); + await resolveFileSecretStorePaths({}); + await expect(resolveFileSecretStorePaths({ strict: true })).rejects.toThrow( + "could not be read" + ); + await expect(strictScope()).rejects.toThrow(); + } +); + +test("an absent configuration is confirmed empty, so removals proceed", async () => { + const scope = await strictScope(); + expect(scope.stores.length).toBeGreaterThan(0); +}); + +test.skipIf(process.getuid?.() === 0)( + "--fix removes nothing while the configuration that would name a file cannot be read", + async () => { + // A leftover next to the configured key's directory. + const keyDir = join(root, "keys"); + await mkdir(keyDir, { mode: 0o700 }); + const leftover = join(keyDir, `.secrets-file.key.${UUID}.tmp`); + await writeFile(leftover, `${SENTINEL}\n`, { mode: 0o600 }); + const old = (Date.now() - STAGING_LEFTOVER_MIN_AGE_MS - 60_000) / 1000; + await utimes(leftover, old, old); + const keyPath = join(keyDir, "secrets-file.key"); + await writeFile( + config(), + `${JSON.stringify({ + controlPlane: { + secrets: { + backend: "encrypted_file", + encryptedFile: { path: join(keyDir, "s.enc.json"), keyPath }, + }, + }, + })}\n`, + { mode: 0o600 } + ); + const findings = await inspectSecretsHygiene(await strictScope()); + expect( + findings.some( + (finding) => + finding.fix?.kind === "remove_leftover" && finding.path === leftover + ) + ).toBe(true); + // The configuration (which might now name that file) becomes unreadable. + await chmod(config(), 0o000); + const result = await applySecretsHygieneFixes({ + findings, + report: () => undefined, + currentScope: strictScope, + }); + expect(result.applied).toBe(0); + expect(await readFile(leftover, "utf8")).toBe(`${SENTINEL}\n`); + // Readable again and unchanged: the leftover is removed. + await chmod(config(), 0o600); + await applySecretsHygieneFixes({ + findings, + report: () => undefined, + currentScope: strictScope, + }); + expect(await lstat(leftover).catch(() => null)).toBeNull(); + } +); From adcc1a52d7ef9934b8b6d84d5ccfc9194661857a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:39:35 +0000 Subject: [PATCH 35/42] fix(env): unknown discovery and git state never shrink the live set; git probes read paths literally (HACK-2165) Project discovery decided whether a present Compose entry supplies a project with the tolerant pathExists, so a stat failure (EACCES, EIO) dropped the project instead of raising, against inspectProjectInputsAtRoot's own rule that only ENOENT means absence. A missing target (a dangling link) is still absent; any other failure is now a project input access fault, so a strict hygiene scope rejects and no removal proceeds. projectEnvLiveInputPaths throws when a .git marker shows a checkout but git cannot name its shared location, so the live set is unknown rather than missing the shared and primary-worktree inputs; a project outside git is unaffected. The tracked and ignored probes pass a ./-prefixed path (ls-files also with --literal-pathspecs), so a configured name such as ':(literal)legacy.key' is never pathspec magic: check-ignore no longer exits 128 for it and ls-files matches the file itself, not a glob. The production-scope test now points TMPDIR at its fixture and asserts no finding lies outside it. Regressions: an uninspectable Compose target rejects discovery and the strict scope, while a dangling one stays absent; a checkout whose git cannot be read removes no leftover; a pathspec-magic name gets ignore fixes and is reported when tracked. Each fails without the change. Co-Authored-By: Claude Opus 5.5 --- src/lib/project-env-config.ts | 29 +++++++++++++++-- src/lib/project-input-selection.ts | 25 +++++++++++++-- tests/secrets-hygiene-scope.test.ts | 46 +++++++++++++++++++++++++- tests/secrets-hygiene.test.ts | 50 +++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 1c2174240..a505dfbd1 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -752,17 +752,31 @@ function toGitRelativePath(opts: { return relative(opts.projectRoot, opts.path).split("\\").join("/"); } +/** + * The path as git must read it in a probe: `./`-prefixed, so a configured + * name that starts with `:` (`:(literal)x`, say) is never pathspec magic + * (HACK-2165). `ls-files` also gets `--literal-pathspecs` for glob characters. + */ +function toGitProbePath(opts: { + readonly projectRoot: string; + readonly path: string; +}): string { + const relativePath = toGitRelativePath(opts); + return relativePath.startsWith("../") ? relativePath : `./${relativePath}`; +} + async function isGitTrackedProjectEnvPath(opts: { readonly projectRoot: string; readonly path: string; }): Promise { - const relativePath = toGitRelativePath(opts); + const relativePath = toGitProbePath(opts); let proc: Bun.Subprocess<"ignore", "ignore", "ignore">; try { proc = Bun.spawn({ env: childEnvironment(), cmd: [ "git", + "--literal-pathspecs", "-C", opts.projectRoot, "ls-files", @@ -810,7 +824,7 @@ export async function isGitIgnoredProjectEnvPath(opts: { "-q", "--no-index", "--", - toGitRelativePath(opts), + toGitProbePath(opts), ], stderr: "ignore", stdin: "ignore", @@ -9341,6 +9355,17 @@ export async function projectEnvLiveInputPaths(opts: { readonly projectRoot: string; readonly projectDir: string; }): Promise> { + // Git names the shared and primary-worktree inputs. In a checkout (a `.git` + // marker, failing closed) where git cannot name them, the set is unknown, + // not smaller; a project outside git has none to name. + if ( + (await resolveProjectEnvSharedKeyLocation(opts)) === null && + (await isInsideGitCheckoutMarker({ startDir: opts.projectRoot })) + ) { + throw new Error( + "Project env inputs are unknown: git could not name this checkout's shared inputs" + ); + } return (await legacyCleanupLiveInputs(opts)).paths; } diff --git a/src/lib/project-input-selection.ts b/src/lib/project-input-selection.ts index 0d38126a4..0be05963c 100644 --- a/src/lib/project-input-selection.ts +++ b/src/lib/project-input-selection.ts @@ -1,4 +1,4 @@ -import { lstat } from "node:fs/promises"; +import { lstat, stat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { HACK_PROJECT_DIR_LEGACY, @@ -8,7 +8,6 @@ import { PROJECT_CONFIG_LEGACY_FILENAME, } from "../constants.ts"; import { HackCliError } from "./cli-result.ts"; -import { pathExists } from "./fs.ts"; import { isRecord } from "./guards.ts"; import { assertLegacyComposeAdoptionSelectionStable } from "./native-compose-adoption-marker.ts"; import { @@ -156,7 +155,7 @@ export async function inspectProjectInputsAtRoot(opts: { legacyFiles.push(file); // Presence still conflicts with native input, but legacy discovery keeps // its existing stat-based behavior for dangling Compose links. - if (name === PROJECT_COMPOSE_FILENAME && (await pathExists(file))) { + if (name === PROJECT_COMPOSE_FILENAME && (await targetPresent(file))) { composeDirectories.push(directory); } } @@ -253,6 +252,26 @@ export async function assertLegacyProjectDiscovery(opts: { } } +/** + * Whether a present entry's target exists: a dangling link is absent, but a + * target that cannot be inspected is an access fault, never absence + * (HACK-2165), so it cannot drop a project from discovery. + */ +async function targetPresent(path: string): Promise { + try { + await stat(path); + return true; + } catch (error: unknown) { + if ( + isRecord(error) && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ) { + return false; + } + throw projectInputAccessFault(error); + } +} + async function inputPresent(path: string): Promise { try { await lstat(path); diff --git a/tests/secrets-hygiene-scope.test.ts b/tests/secrets-hygiene-scope.test.ts index 67c39c6fb..a8cb3c588 100644 --- a/tests/secrets-hygiene-scope.test.ts +++ b/tests/secrets-hygiene-scope.test.ts @@ -6,6 +6,7 @@ import { mkdtemp, readFile, rm, + symlink, utimes, writeFile, } from "node:fs/promises"; @@ -13,6 +14,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveSecretsHygieneScope } from "../src/commands/doctor.ts"; import { STAGING_LEFTOVER_MIN_AGE_MS } from "../src/lib/private-staging.ts"; +import { discoverProjectInputs } from "../src/lib/project-input-selection.ts"; import { resolveFileSecretStorePaths } from "../src/lib/secret-store.ts"; import { applySecretsHygieneFixes, @@ -27,16 +29,21 @@ import { restoreEnv } from "./helpers/env.ts"; const SENTINEL = "synthetic-scope-sentinel"; const UUID = "0f0e0d0c-0b0a-4908-8706-050403020100"; let root = ""; -let saved: { home?: string; config?: string } = {}; +let saved: { home?: string; config?: string; tmp?: string } = {}; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "hack-hygiene-scope-")); saved = { home: process.env.HOME, config: process.env.HACK_GLOBAL_CONFIG_PATH, + tmp: process.env.TMPDIR, }; process.env.HOME = join(root, "home"); process.env.HACK_GLOBAL_CONFIG_PATH = join(root, "hack.config.json"); + // Inspection looks for stale terminal payloads in the temporary + // directory: point it at this fixture, never the caller's. + process.env.TMPDIR = join(root, "tmp"); + await mkdir(join(root, "tmp")); await mkdir(join(root, "home")); await mkdir(join(root, "start")); }); @@ -44,6 +51,7 @@ beforeEach(async () => { afterEach(async () => { restoreEnv("HOME", saved.home); restoreEnv("HACK_GLOBAL_CONFIG_PATH", saved.config); + restoreEnv("TMPDIR", saved.tmp); await chmod(join(root, "hack.config.json"), 0o600).catch(() => undefined); await rm(root, { recursive: true, force: true }); }); @@ -106,6 +114,10 @@ test.skipIf(process.getuid?.() === 0)( { mode: 0o600 } ); const findings = await inspectSecretsHygiene(await strictScope()); + // Nothing outside this fixture is ever a candidate. + for (const finding of findings) { + expect(finding.path.startsWith(`${root}/`)).toBe(true); + } expect( findings.some( (finding) => @@ -131,3 +143,35 @@ test.skipIf(process.getuid?.() === 0)( expect(await lstat(leftover).catch(() => null)).toBeNull(); } ); + +test.skipIf(process.getuid?.() === 0)( + "a project whose Compose file cannot be inspected is unknown, not absent", + async () => { + const project = join(root, "start", "app"); + const hackDir = join(project, ".hack"); + await mkdir(hackDir, { recursive: true }); + const hidden = join(root, "hidden"); + await mkdir(hidden); + await writeFile(join(hidden, "docker-compose.yml"), "services: {}\n"); + // Present as a link, but its target is behind a directory this user + // cannot search: stat fails with EACCES, not ENOENT. + await symlink( + join(hidden, "docker-compose.yml"), + join(hackDir, "docker-compose.yml") + ); + await chmod(hidden, 0o000); + try { + await expect( + discoverProjectInputs({ startDir: project }) + ).rejects.toThrow(); + await expect( + resolveSecretsHygieneScope({ startDir: project, strict: true }) + ).rejects.toThrow(); + } finally { + await chmod(hidden, 0o700); + } + // A dangling Compose link stays an absent project, as before. + await rm(join(hidden, "docker-compose.yml")); + expect(await discoverProjectInputs({ startDir: project })).toBeNull(); + } +); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index b9b68d267..40d2e0f1d 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -840,3 +840,53 @@ test("a leftover the configuration selects after inspection is kept by --fix", a }); expect(await lstat(leftover).catch(() => null)).toBeNull(); }); + +test("in a checkout whose git cannot name its shared inputs, no leftover is removed", async () => { + const f = await fixture(); + const leftover = join(f.projectRoot, `..hack.secret.key.${UUID}.tmp`); + await writeFile(leftover, `${SENTINEL}\n`, { mode: 0o600 }); + await backdate(leftover); + expect(paths(await inspectSecretsHygiene(f.scope))).toContain(leftover); + // Still a checkout (a .git marker), but git cannot read it. + await rm(join(f.projectRoot, ".git"), { recursive: true, force: true }); + await writeFile( + join(f.projectRoot, ".git"), + `gitdir: ${join(f.root, "missing-git-dir")}\n` + ); + const findings = await inspectSecretsHygiene(f.scope); + expect( + findings.some((finding) => finding.fix?.kind === "remove_leftover") + ).toBe(false); + await applySecretsHygieneFixes({ findings, report: () => undefined }); + expect(await readFile(leftover, "utf8")).toBe(`${SENTINEL}\n`); +}); + +test("a configured name that looks like git pathspec magic is checked as a literal path", async () => { + const f = await fixture(); + // `:(literal)…` would be pathspec magic if passed as is: check-ignore + // refuses it (unknown) and ls-files matches nothing. + const storePath = join(f.projectRoot, ":(literal)store.enc.json"); + const keyPath = join(f.projectRoot, ":(literal)legacy.key"); + const scope: SecretsHygieneScope = { + ...f.scope, + stores: [{ storePath, keyPath }], + }; + const findings = await inspectSecretsHygiene(scope); + expect(findings.map((finding) => finding.fix?.kind).sort()).toEqual([ + "ignore", + "ignore", + ]); + expect( + await applySecretsHygieneFixes({ findings, report: () => undefined }) + ).toEqual({ applied: 2, skipped: 0 }); + expect(await inspectSecretsHygiene(scope)).toEqual([]); + // Tracked under that name: reported as tracked, never as clean. + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + await git(f.projectRoot, ["add", "-f", "--", `./${":(literal)legacy.key"}`]); + const tracked = await inspectSecretsHygiene(scope); + expect( + tracked.some( + (finding) => finding.path === keyPath && finding.status === "error" + ) + ).toBe(true); +}); From 54610ae76ea99a9dc7853cca8f607dce7d73d7c4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:50:14 +0000 Subject: [PATCH 36/42] fix(env): the live-input set takes the git family from one lookup, failing closed (HACK-2165) projectEnvLiveInputPaths checked git first and then let the collector look the family up again; a later git failure silently dropped the shared key and the primary worktree's inputs. The collector now takes the family from a single lookup (liveInputGitFamily): the shared key location, and the primary worktree derived from the same common directory, as resolveGitPrimaryWorktreeRoot derives it. With strictGit (the hygiene path) a checkout whose family git cannot name throws. Legacy cleanup's callers keep their behaviour. Regression: with git answering its first two calls and failing every later one, the set still names the shared key (or the call rejects). It fails with the previous collector. Co-Authored-By: Claude Opus 5.5 --- src/lib/project-env-config.ts | 62 ++++++++++++++++++++++++++--------- tests/secrets-hygiene.test.ts | 44 ++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index a505dfbd1..c517f0ad6 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -9291,21 +9291,62 @@ async function readLegacyDotenvText(opts: { } /** Keys and YAML inputs remain live after legacy migration, even if a backend named them. */ +/** + * The git-named inputs' locations: the shared key and the primary worktree. + * With the family known, the primary worktree comes from the same common + * directory (as resolveGitPrimaryWorktreeRoot derives it), never from a second + * git call that could fail on its own. With `strictGit`, a checkout whose + * family git cannot name throws instead of naming nothing. + */ +async function liveInputGitFamily(opts: { + readonly projectRoot: string; + readonly strictGit?: boolean; +}): Promise<{ + readonly shared: ProjectEnvSharedKeyLocation | null; + readonly primaryRoot: string | null; +}> { + const shared = await resolveProjectEnvSharedKeyLocation(opts); + if (shared) { + const commonDir = dirname(shared.path); + return { + shared, + primaryRoot: commonDir.endsWith("/.git") ? dirname(commonDir) : null, + }; + } + if (opts.strictGit !== true) { + return { + shared: null, + primaryRoot: await resolveGitPrimaryWorktreeRoot({ + repoRoot: opts.projectRoot, + }), + }; + } + if (await isInsideGitCheckoutMarker({ startDir: opts.projectRoot })) { + throw new Error( + "Project env inputs are unknown: git could not name this checkout's shared inputs" + ); + } + return { shared: null, primaryRoot: null }; +} + async function legacyCleanupLiveInputs(opts: { readonly projectRoot: string; readonly projectDir: string; + /** + * Refuse instead of omitting git-named inputs (HACK-2165): in a checkout (a + * `.git` marker, failing closed) git must name the repository family, and + * that one answer names both the shared key and the primary worktree. + */ + readonly strictGit?: boolean; }): Promise<{ readonly paths: ReadonlySet; readonly identities: ReadonlySet; }> { const paths = new Set([resolveProjectEnvKeyPath(opts)]); - const shared = await resolveProjectEnvSharedKeyLocation(opts); + const { shared, primaryRoot } = await liveInputGitFamily(opts); if (shared) { paths.add(shared.path); } - const primaryRoot = await resolveGitPrimaryWorktreeRoot({ - repoRoot: opts.projectRoot, - }); const directories = new Set([ opts.projectDir, resolve(opts.projectRoot, HACK_PROJECT_DIR_PRIMARY), @@ -9355,18 +9396,7 @@ export async function projectEnvLiveInputPaths(opts: { readonly projectRoot: string; readonly projectDir: string; }): Promise> { - // Git names the shared and primary-worktree inputs. In a checkout (a `.git` - // marker, failing closed) where git cannot name them, the set is unknown, - // not smaller; a project outside git has none to name. - if ( - (await resolveProjectEnvSharedKeyLocation(opts)) === null && - (await isInsideGitCheckoutMarker({ startDir: opts.projectRoot })) - ) { - throw new Error( - "Project env inputs are unknown: git could not name this checkout's shared inputs" - ); - } - return (await legacyCleanupLiveInputs(opts)).paths; + return (await legacyCleanupLiveInputs({ ...opts, strictGit: true })).paths; } async function legacyCleanupInputStat(opts: { diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 40d2e0f1d..cb5173d49 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -1,10 +1,11 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { chmod, lstat, mkdir, mkdtemp, readFile, + realpath, rm, stat, symlink, @@ -14,12 +15,14 @@ import { import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { STAGING_LEFTOVER_MIN_AGE_MS } from "../src/lib/private-staging.ts"; +import { projectEnvLiveInputPaths } from "../src/lib/project-env-config.ts"; import { applySecretsHygieneFixes, inspectSecretsHygiene, type SecretsHygieneFinding, type SecretsHygieneScope, } from "../src/lib/secrets-hygiene.ts"; +import * as shell from "../src/lib/shell.ts"; import { stageMuxEnvironment } from "../src/mux/private-environment.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; @@ -890,3 +893,42 @@ test("a configured name that looks like git pathspec magic is checked as a liter ) ).toBe(true); }); + +test("git lookups that fail after the first ones never shrink the live set", async () => { + const f = await fixture(); + const project = f.scope.project; + if (!project) { + throw new Error("Missing project"); + } + const sharedKey = join( + await realpath(join(f.projectRoot, ".git")), + ".hack.secret.key" + ); + expect(await projectEnvLiveInputPaths(project)).toContain(sharedKey); + // Let the first two git calls answer, then fail every later one (a + // transient child failure): the set either names the shared key or the + // call rejects, never a smaller set. + const original = shell.exec; + let gitCalls = 0; + const spy = spyOn(shell, "exec").mockImplementation((async ( + cmd: readonly string[], + options?: Parameters[1] + ) => { + if (cmd[0] === "git") { + gitCalls += 1; + if (gitCalls > 2) { + return { exitCode: 128, stdout: "", stderr: "" }; + } + } + return await original(cmd, options); + }) as typeof shell.exec); + try { + const live = await projectEnvLiveInputPaths(project).catch(() => null); + expect(gitCalls).toBeGreaterThan(0); + if (live !== null) { + expect(live).toContain(sharedKey); + } + } finally { + spy.mockRestore(); + } +}); From 0a5c6b5374e78cd403e782073e93d95a76d8e5d7 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:09:09 +0000 Subject: [PATCH 37/42] fix(doctor): check stores and keys in any containing git work tree; a tracked store is a warning (HACK-2165) A configured store or key outside the project root but inside another git work tree (a home directory kept in git, say) got no git check, so doctor reported it clean. Hygiene now finds the containing work tree (the fail-closed marker walk, then git rev-parse from the nearest existing directory) and asks git there; an unignored file is reported for the user to ignore by hand (hygiene never edits another repository), a tracked one as tracked, and a checkout git cannot name is an error. A path inside a git directory itself is in no work tree. A tracked encrypted store is now a warning rather than an error: it holds ciphertext only, the key's own check stays an error, and projects may commit the store (next's HACK-1715 cleanup test tracks one; with the error, doctor --migrate-env-config exited 1 there after the merge). Regressions: a store and key in another work tree (unignored, ignored by hand, tracked, unnameable); a store inside a bare git directory; the '..secrets' test now expects an error for the tracked key and a warning for the tracked store. Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 8 +- src/lib/project-env-config.ts | 2 +- src/lib/secrets-hygiene.ts | 152 +++++++++++++++++++++++++++++++++- tests/secrets-hygiene.test.ts | 74 ++++++++++++++++- 4 files changed, 229 insertions(+), 7 deletions(-) diff --git a/docs/env.md b/docs/env.md index 3b0d1cb59..fb9c3633c 100644 --- a/docs/env.md +++ b/docs/env.md @@ -451,8 +451,12 @@ global Hack home, in its "secrets hygiene" section. It names files, never values `.hack/.env` files, the project key (checkout-local and shared) and the encrypted store and its key, when other users can read them, when they are links, or when they belong to another user; -- those files when git tracks them, or (inside the project) when git does not ignore - them; +- those files when git tracks them (an error, except for the encrypted store: it holds + only ciphertext, so a tracked store is a warning while its key stays out of git), or + when git does not ignore them. Inside the project `--fix` adds the ignore entry; a + store or key in another git work tree (a home directory kept in git, say) is + checked there and reported for you to ignore by hand, and one in a checkout git + cannot name is an error; - staging copies an interrupted write left behind, and env values staged for a terminal session that never ran (older than 10 minutes); - an encrypted store whose key file is missing: "store present, key missing, cannot be diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 345094eef..8aa2843fe 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -863,7 +863,7 @@ export async function isGitIgnoredProjectEnvPath(opts: { * but "absent"), counts as inside, since only confirmed absence may exempt a * write from git's privacy checks. */ -async function isInsideGitCheckoutMarker(opts: { +export async function isInsideGitCheckoutMarker(opts: { readonly startDir: string; }): Promise { if ((process.env.GIT_DIR ?? "").trim().length > 0) { diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index df20a9561..40a1f1491 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -16,6 +16,7 @@ import { removeStaleMuxEnvironment, type StaleMuxEnvironment, } from "../mux/private-environment.ts"; +import { childEnvironment } from "./child-environment.ts"; import { displayPath, MACHINE_LOCAL_LAYER_NAME, @@ -32,6 +33,7 @@ import { import { isGitIgnoredProjectEnvPath, isGitTrackedOrUnknownProjectPath, + isInsideGitCheckoutMarker, projectEnvLiveInputPaths, } from "./project-env-config.ts"; @@ -75,6 +77,11 @@ type Candidate = { * differs from the candidate's (a resolved alias); defaults to it. */ readonly gitPath?: string; + /** + * Another repository's work tree (not the project's): reported, never + * repaired, since the hygiene check does not edit other repositories. + */ + readonly manual?: boolean; } | null; }; @@ -355,6 +362,18 @@ async function gitFindings(opts: { path: ignore.gitPath ?? candidate.path, }; if (await isGitTrackedOrUnknownProjectPath(target)) { + if (candidate.holds === "an encrypted secret store") { + // Ciphertext only: a committed store is exposed while its key is too + // (the key's own check is an error); some projects commit the store. + return [ + { + status: "warn", + path: candidate.path, + message: `git tracks ${name} (or cannot say whether it does), an encrypted secret store: anyone with the repository has its ciphertext, so keep its key out of git; to stop sharing it, ${untrackAdvice(ignore.gitPath ?? candidate.path, ignore.projectRoot)}.`, + fix: null, + }, + ]; + } return [ { status: "error", @@ -384,6 +403,16 @@ async function gitFindings(opts: { }, ]; } + if (ignore.manual === true) { + return [ + { + status: "warn", + path: candidate.path, + message: `${name} holds ${candidate.holds} inside the git work tree at ${displayPath(ignore.projectRoot)}, which does not ignore it; add '${displayPath(ignore.entry)}' to ${displayPath(ignore.gitignorePath)} by hand, or move it out of that tree.`, + fix: null, + }, + ]; + } return [ { status: "warn", @@ -571,6 +600,114 @@ async function inProjectIgnore(opts: { }; } +/** + * The git work tree containing `path` (as git finds it from its nearest + * existing directory), null when there is none, or "unknown" when a checkout + * marker shows one but git cannot name it (HACK-2165). A path inside a git + * directory itself is in no work tree. + */ +async function containingWorkTree( + path: string +): Promise { + const dir = await nearestExistingDirectory(dirname(path)); + if (dir === null) { + return "unknown"; + } + if (!(await isInsideGitCheckoutMarker({ startDir: dir }))) { + return null; + } + const answer = await gitAnswer(dir, [ + "rev-parse", + "--is-inside-work-tree", + "--is-inside-git-dir", + ]); + const [inWorkTree, inGitDir] = answer?.split("\n") ?? []; + if (inGitDir === "true" && inWorkTree === "false") { + return null; + } + if (inWorkTree !== "true") { + return "unknown"; + } + const top = await gitAnswer(dir, ["rev-parse", "--show-toplevel"]); + return top + ? ((await realpath(top).catch(() => null)) ?? "unknown") + : "unknown"; +} + +/** The resolved nearest directory of `path` that exists (git runs there). */ +async function nearestExistingDirectory(path: string): Promise { + let current = path; + while (true) { + const real = await realpath(current).catch(() => null); + if (real !== null) { + return real; + } + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +async function gitAnswer( + dir: string, + args: readonly string[] +): Promise { + try { + const proc = Bun.spawn(["git", "-C", dir, ...args], { + env: childEnvironment(), + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }); + const [out, code] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + return code === 0 && out.trim() !== "" ? out.trim() : null; + } catch { + return null; + } +} + +/** + * Git metadata for a configured store or key outside the project tree that + * another work tree contains (a home directory kept in git, say): checked + * there, reported without a repair. An error finding when git cannot name it. + */ +async function otherWorkTreeIgnore(opts: { + readonly path: string; + readonly name: string; + readonly holds: Holds; +}): Promise { + const root = await containingWorkTree(opts.path); + if (root === null) { + return null; + } + if (root === "unknown") { + return { + status: "error", + path: opts.path, + message: `${opts.name} holds ${opts.holds} inside a git checkout, but git could not name its work tree to check whether it tracks or ignores it; make git work there, or move it out of the checkout.`, + fix: null, + }; + } + const dir = await resolveExisting(dirname(opts.path)); + const resolved = join(dir ?? dirname(opts.path), basename(opts.path)); + const rel = relative(root, resolved); + if (rel === "" || isOutside(rel)) { + return null; + } + return { + projectRoot: root, + gitPath: resolved, + gitignorePath: join(root, ".gitignore"), + entry: `/${rel.split(sep).map(gitignoreLiteral).join("/")}`, + manual: true, + }; +} + async function storeFindings(opts: { readonly store: SecretsHygieneScope["stores"][number]; readonly storeKeyFromEnvironment: boolean; @@ -582,10 +719,23 @@ async function storeFindings(opts: { { path: storePath, holds: "an encrypted secret store" }, { path: keyPath, holds: "an encrypted store key" }, ] as const) { - const ignore = await inProjectIgnore({ + let ignore = await inProjectIgnore({ path: candidate.path, project: opts.project, }); + if (ignore === null) { + const other = await otherWorkTreeIgnore({ + path: candidate.path, + name: shown(candidate.path, opts.project), + holds: candidate.holds, + }); + if (other !== null && "status" in other) { + // Unknown: reported, and its other checks still run. + findings.push(other); + } else { + ignore = other; + } + } findings.push( ...(await candidateFindings({ candidate: { ...candidate, ignore }, diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index cb5173d49..8f09d4522 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -340,9 +340,12 @@ test("an in-project store under a name starting with '..' is still checked again stores: [{ storePath, keyPath }], }); expect(paths(findings)).toEqual([keyPath, storePath].sort()); - for (const finding of findings) { - expect(finding).toMatchObject({ status: "error", fix: null }); - } + // A tracked key is an error; a tracked store is ciphertext only, so a + // warning (some projects commit it), never repaired automatically. + const byPath = new Map(findings.map((finding) => [finding.path, finding])); + expect(byPath.get(keyPath)).toMatchObject({ status: "error", fix: null }); + expect(byPath.get(storePath)).toMatchObject({ status: "warn", fix: null }); + expect(byPath.get(storePath)?.message).toContain("keep its key out of git"); }); test("project-configured store paths outside the checkout and the Hack home are reported, never fixed", async () => { @@ -932,3 +935,68 @@ test("git lookups that fail after the first ones never shrink the live set", asy spy.mockRestore(); } }); + +test("a store and key in another git work tree are checked there and reported, never repaired", async () => { + const f = await fixture(); + // A home directory kept in git, say: outside the project, inside a tree. + const outer = join(f.root, "dotfiles"); + await mkdir(outer); + await git(outer, ["init", "-q", "-b", "main"]); + const storePath = join(outer, "secrets", "store.enc.json"); + const keyPath = join(outer, "secrets", "store.key"); + const scope: SecretsHygieneScope = { + ...f.scope, + stores: [{ storePath, keyPath }], + }; + const unignored = await inspectSecretsHygiene(scope); + expect(paths(unignored)).toEqual([keyPath, storePath].sort()); + for (const finding of unignored) { + expect(finding).toMatchObject({ status: "warn", fix: null }); + expect(finding.message).toContain("by hand"); + } + // Ignored there by hand: clean. + await writeFile(join(outer, ".gitignore"), "/secrets/\n"); + expect(await inspectSecretsHygiene(scope)).toEqual([]); + // Tracked there: an error. + await mkdir(join(outer, "secrets")); + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + await git(outer, ["add", "-f", "--", "secrets/store.key"]); + const tracked = await inspectSecretsHygiene(scope); + expect( + tracked.some( + (finding) => finding.path === keyPath && finding.status === "error" + ) + ).toBe(true); + // A checkout git cannot name: an error, never clean. + await rm(join(outer, ".git"), { recursive: true, force: true }); + await writeFile( + join(outer, ".git"), + `gitdir: ${join(f.root, "missing-git-dir")}\n` + ); + const unknown = await inspectSecretsHygiene(scope); + expect( + unknown.some( + (finding) => + finding.path === storePath && + finding.status === "error" && + finding.message.includes("could not name") + ) + ).toBe(true); +}); + +test("a store inside a git directory itself is not in a work tree", async () => { + const f = await fixture(); + const gitDir = join(f.root, "bare.git"); + await mkdir(gitDir); + await git(gitDir, ["init", "-q", "--bare"]); + const scope: SecretsHygieneScope = { + ...f.scope, + stores: [ + { + storePath: join(gitDir, "store.enc.json"), + keyPath: join(gitDir, "store.key"), + }, + ], + }; + expect(await inspectSecretsHygiene(scope)).toEqual([]); +}); From 4a43194ad1c3745debdd5aad957052d722aacb71 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:28:19 +0000 Subject: [PATCH 38/42] fix(doctor): git checks keep literal names, fail closed on lookups, and use the nearest work tree (HACK-2165) - toGitRelativePath rewrites only the platform separator: on POSIX a backslash is part of a name, and turning it into "/" asked git about a different (possibly ignored) file. - The nearest-directory walks (resolveExisting, nearestExistingDirectory) climb only past a directory confirmed missing; any other realpath failure is unknown, so a containing repository whose root cannot be resolved is reported, not skipped. - The printed untrack command is git --literal-pathspecs rm --cached, so a name that looks like a pathspec untracks only that file. - In another work tree an existing regular key is checked under its stored case (realpath of the file), as in the project. - A store or key in a repository nested inside the project is checked against that repository (report only); a project inside a larger repository keeps its own ignore repair. - Discovery's Compose target check goes through Bun.file(...).stat() as pathExists did, so callers that observe file access (the adoption probes) see the same access; only ENOENT/ENOTDIR are absence. Regressions: a tracked key\prod.key with an ignored slash-spelled decoy; realpath EIO at the containing repository root; the printed command run against a ':(glob)*.key' name next to another tracked key; a nested repository; a monorepo project; a case-folding check that runs on case-insensitive filesystems. Each new one fails without the change (the case-folding one needs such a filesystem). Co-Authored-By: Claude Opus 5.5 --- src/lib/project-env-config.ts | 5 +- src/lib/project-input-selection.ts | 6 +- src/lib/secrets-hygiene.ts | 80 ++++++++++++- tests/secrets-hygiene.test.ts | 183 ++++++++++++++++++++++++++++- 4 files changed, 263 insertions(+), 11 deletions(-) diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 8aa2843fe..25aa07ac7 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -756,7 +756,10 @@ function toGitRelativePath(opts: { readonly projectRoot: string; readonly path: string; }): string { - return relative(opts.projectRoot, opts.path).split("\\").join("/"); + const rel = relative(opts.projectRoot, opts.path); + // Only the platform separator is one: on POSIX a backslash is part of the + // name, and rewriting it would ask git about a different file (HACK-2165). + return sep === "\\" ? rel.split(sep).join("/") : rel; } /** diff --git a/src/lib/project-input-selection.ts b/src/lib/project-input-selection.ts index 0be05963c..3424036a8 100644 --- a/src/lib/project-input-selection.ts +++ b/src/lib/project-input-selection.ts @@ -1,4 +1,4 @@ -import { lstat, stat } from "node:fs/promises"; +import { lstat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { HACK_PROJECT_DIR_LEGACY, @@ -259,7 +259,9 @@ export async function assertLegacyProjectDiscovery(opts: { */ async function targetPresent(path: string): Promise { try { - await stat(path); + // Through Bun.file, as pathExists did, so callers observe the same + // access (the adoption probes count it). + await Bun.file(path).stat(); return true; } catch (error: unknown) { if ( diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 40a1f1491..879f84c6f 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -230,7 +230,7 @@ function shellQuote(text: string): string { function untrackAdvice(path: string, projectRoot: string): string { const rel = relative(projectRoot, path); return displayPath(rel) === rel - ? `untrack it with git rm --cached -- ${shellQuote(rel)} (from the project root)` + ? `untrack it with git --literal-pathspecs rm --cached -- ${shellQuote(rel)} (from the project root)` : "untrack it with git rm --cached (its name holds control characters, so no command is printed)"; } @@ -555,7 +555,10 @@ async function resolveExisting(path: string): Promise { const missing: string[] = []; let current = path; while (true) { - const real = await realpath(current).catch(() => null); + const real = await realpathOrMissing(current); + if (real === "unknown") { + return null; + } if (real) { return join(real, ...missing.reverse()); } @@ -634,11 +637,36 @@ async function containingWorkTree( : "unknown"; } -/** The resolved nearest directory of `path` that exists (git runs there). */ +/** + * `path` resolved, null when it (or a directory on the way) does not exist, + * or "unknown" for any other failure: only confirmed absence lets a caller + * look at the parent instead (HACK-2165). + */ +async function realpathOrMissing( + path: string +): Promise { + try { + return await realpath(path); + } catch (error: unknown) { + const code = + typeof error === "object" && error !== null && "code" in error + ? (error as { readonly code?: unknown }).code + : undefined; + return code === "ENOENT" || code === "ENOTDIR" ? null : "unknown"; + } +} + +/** + * The resolved nearest directory of `path` that exists (git runs there); null + * when a directory on the way cannot be inspected. + */ async function nearestExistingDirectory(path: string): Promise { let current = path; while (true) { - const real = await realpath(current).catch(() => null); + const real = await realpathOrMissing(current); + if (real === "unknown") { + return null; + } if (real !== null) { return real; } @@ -694,7 +722,15 @@ async function otherWorkTreeIgnore(opts: { }; } const dir = await resolveExisting(dirname(opts.path)); - const resolved = join(dir ?? dirname(opts.path), basename(opts.path)); + // As in the project: an existing regular file is resolved itself, so its + // name is in its stored case; a final link is not followed. + const entry = await lstat(opts.path).catch(() => null); + const resolved = + entry?.isFile() === true + ? await realpath(opts.path).catch(() => + join(dir ?? dirname(opts.path), basename(opts.path)) + ) + : join(dir ?? dirname(opts.path), basename(opts.path)); const rel = relative(root, resolved); if (rel === "" || isOutside(rel)) { return null; @@ -708,6 +744,35 @@ async function otherWorkTreeIgnore(opts: { }; } +/** + * Whether `path` is in a git work tree nested inside the project (a separate + * repository below the project root): then that repository, not the + * project's, decides whether git collects it. A work tree at or above the + * project root (the project itself, or a monorepo around it) is not nested. + * An unknown answer counts as nested, so the caller's checks report it. + */ +async function inNestedWorkTree(opts: { + readonly path: string; + readonly project: SecretsHygieneScope["project"]; +}): Promise { + if (!opts.project) { + return false; + } + const top = await containingWorkTree(opts.path); + if (top === null) { + return false; + } + if (top === "unknown") { + return true; + } + const root = await realpath(opts.project.projectRoot).catch(() => null); + if (root === null) { + return true; + } + const rel = relative(root, top); + return rel !== "" && !isOutside(rel); +} + async function storeFindings(opts: { readonly store: SecretsHygieneScope["stores"][number]; readonly storeKeyFromEnvironment: boolean; @@ -723,7 +788,10 @@ async function storeFindings(opts: { path: candidate.path, project: opts.project, }); - if (ignore === null) { + if ( + ignore === null || + (await inNestedWorkTree({ path: candidate.path, project: opts.project })) + ) { const other = await otherWorkTreeIgnore({ path: candidate.path, name: shown(candidate.path, opts.project), diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 8f09d4522..691b022a4 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, spyOn, test } from "bun:test"; +import * as fsPromises from "node:fs/promises"; import { chmod, lstat, @@ -425,7 +426,7 @@ test("an in-checkout store configured through an alias spelling is still checked ]); // The untrack command names the path git tracks, relative to the root. expect(findings[0]?.message).toContain( - "git rm --cached -- 'secrets/store.key'" + "git --literal-pathspecs rm --cached -- 'secrets/store.key'" ); }); @@ -509,7 +510,7 @@ test("names in findings are shell-quoted in commands and never printed with cont const findings = await inspectSecretsHygiene(f.scope); const text = findings.map((finding) => finding.message).join("\n"); expect(text).toContain( - "git rm --cached -- '.hack/hack.env.it'\\''s;x.local.yaml'" + "git --literal-pathspecs rm --cached -- '.hack/hack.env.it'\\''s;x.local.yaml'" ); expect(text).not.toContain("\u001b"); expect(text).not.toContain("\u0007"); @@ -1000,3 +1001,181 @@ test("a store inside a git directory itself is not in a work tree", async () => }; expect(await inspectSecretsHygiene(scope)).toEqual([]); }); + +test.skipIf(process.platform === "win32")( + "a backslash in a configured name is part of it, never a separator, when git is asked", + async () => { + const f = await fixture(); + // Tracked under its literal name; a slash spelling would be a different, + // ignored path (the decoy). + const keyPath = join(f.projectRoot, "key\\prod.key"); + await writeFile(keyPath, `${SENTINEL}\n`, { mode: 0o600 }); + await writeFile( + join(f.projectRoot, ".gitignore"), + ".hack.secret.key\n/key/\n" + ); + await git(f.projectRoot, ["add", "-f", "--", "./key\\prod.key"]); + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath: join(f.home, "secrets.enc.json"), keyPath }], + }); + expect( + findings.some( + (finding) => finding.path === keyPath && finding.status === "error" + ) + ).toBe(true); + } +); + +test("a containing repository whose root cannot be resolved is unknown, never skipped", async () => { + const f = await fixture(); + const outer = join(f.root, "dotfiles"); + await mkdir(outer); + await git(outer, ["init", "-q", "-b", "main"]); + const storePath = join(outer, "secrets", "store.enc.json"); + const keyPath = join(outer, "secrets", "store.key"); + // Resolving the repository root fails (an I/O error, say); its parent and + // everything else still resolve. + const original = fsPromises.realpath; + const spy = spyOn(fsPromises, "realpath").mockImplementation((async ( + path: Parameters[0], + ...rest: unknown[] + ) => { + if (String(path) === outer) { + throw Object.assign(new Error("synthetic EIO"), { code: "EIO" }); + } + return await Reflect.apply(original, fsPromises, [path, ...rest]); + }) as typeof original); + let findings: readonly SecretsHygieneFinding[]; + try { + findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath, keyPath }], + }); + } finally { + spy.mockRestore(); + } + for (const path of [storePath, keyPath]) { + expect( + findings.some( + (finding) => finding.path === path && finding.status === "error" + ) + ).toBe(true); + } +}); + +test("the printed untrack command removes only the named file, even one that looks like a pathspec", async () => { + const f = await fixture(); + const name = ":(glob)*.key"; + const keyPath = join(f.projectRoot, name); + const other = join(f.projectRoot, "other.key"); + for (const path of [keyPath, other]) { + await writeFile(path, `${SENTINEL}\n`, { mode: 0o600 }); + } + await git(f.projectRoot, ["add", "-f", "--", `./${name}`, "other.key"]); + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath: join(f.home, "secrets.enc.json"), keyPath }], + }); + const message = + findings.find( + (finding) => finding.path === keyPath && finding.status === "error" + )?.message ?? ""; + const command = /untrack it with (git .*?) \(from the project root\)/.exec( + message + )?.[1]; + expect(command).toStartWith("git --literal-pathspecs rm --cached -- "); + const proc = Bun.spawn(["sh", "-c", command ?? "false"], { + cwd: f.projectRoot, + env: fixtureGitEnv(), + stdout: "ignore", + stderr: "ignore", + }); + expect(await proc.exited).toBe(0); + const listed = Bun.spawn(["git", "ls-files"], { + cwd: f.projectRoot, + env: fixtureGitEnv(), + stdout: "pipe", + stderr: "ignore", + }); + const tracked = (await new Response(listed.stdout).text()).split("\n"); + expect(tracked).toContain("other.key"); + expect(tracked).not.toContain(name); +}); + +async function caseInsensitive(dir: string): Promise { + const probe = join(dir, "CaseProbe"); + await writeFile(probe, ""); + const folded = await lstat(join(dir, "caseprobe")).then( + () => true, + () => false + ); + await rm(probe); + return folded; +} + +test("in another work tree an existing key is checked under its stored case", async () => { + const f = await fixture(); + const outer = join(f.root, "dotfiles"); + await mkdir(join(outer, "secrets"), { recursive: true }); + if (!(await caseInsensitive(outer))) { + // Needs a case-insensitive filesystem (macOS by default). + return; + } + await git(outer, ["init", "-q", "-b", "main"]); + await writeFile(join(outer, "secrets", "Store.Key"), `${SENTINEL}\n`, { + mode: 0o600, + }); + await git(outer, ["add", "-f", "--", "secrets/Store.Key"]); + await writeFile(join(outer, ".gitignore"), "/secrets/store.key\n"); + const keyPath = join(outer, "secrets", "store.key"); + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath: join(f.home, "secrets.enc.json"), keyPath }], + }); + expect( + findings.some( + (finding) => finding.path === keyPath && finding.status === "error" + ) + ).toBe(true); +}); + +test("a store in a repository nested inside the project is checked against that repository", async () => { + const f = await fixture(); + const inner = join(f.projectRoot, "vendor-app"); + await mkdir(inner); + await git(inner, ["init", "-q", "-b", "main"]); + // The project ignores the nested repository; the nested one ignores + // nothing, so its own `git add -A` would collect the store and key. + await writeFile( + join(f.projectRoot, ".gitignore"), + ".hack.secret.key\n/vendor-app/\n" + ); + const storePath = join(inner, "secrets", "store.enc.json"); + const keyPath = join(inner, "secrets", "store.key"); + const findings = await inspectSecretsHygiene({ + ...f.scope, + stores: [{ storePath, keyPath }], + }); + expect(paths(findings)).toEqual([keyPath, storePath].sort()); + for (const finding of findings) { + expect(finding).toMatchObject({ status: "warn", fix: null }); + expect(finding.message).toContain("by hand"); + } +}); + +test("a project inside a larger repository keeps its own ignore repair", async () => { + const f = await fixture(); + const mono = join(f.root, "mono"); + const app = join(mono, "apps", "web"); + await mkdir(join(app, ".hack"), { recursive: true }); + await git(mono, ["init", "-q", "-b", "main"]); + const keyPath = join(app, "secrets", "store.key"); + const findings = await inspectSecretsHygiene({ + ...f.scope, + project: { projectRoot: app, projectDir: join(app, ".hack") }, + stores: [{ storePath: join(app, "secrets", "store.enc.json"), keyPath }], + }); + const key = findings.find((finding) => finding.path === keyPath); + expect(key?.fix?.kind).toBe("ignore"); +}); From 6172232d30173831f31ecab6edc03a860ef42f5e Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:40:33 +0000 Subject: [PATCH 39/42] fix(mux): name stale payloads by the resolved path staging returns (HACK-2165) findStaleMuxEnvironments built roots from the temp directory as given, while stageMuxEnvironment returns realpath'd paths, so on macOS (/var -> /private/var) the two named the same payload differently and two tests comparing them failed. The finder now resolves its base once, so hack doctor reports and removes payloads under the same spelling staging uses. The live-input check was already by resolved path (isLive compares the target as given and resolved against inputs as given and resolved); a new regression configures a key inside a stale payload by a linked temp directory spelling and checks it is neither offered nor removed. Regressions (a linked temp directory stands in for macOS /var on Linux): stale roots equal the resolved staged path; a key configured through the link survives inspection and --fix. Both fail without the change (the second only on the reported spelling; its protection held before). Co-Authored-By: Claude Opus 5.5 --- src/mux/private-environment.ts | 9 ++++-- tests/mux-private-environment.test.ts | 25 ++++++++++++++++ tests/secrets-hygiene.test.ts | 42 ++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/mux/private-environment.ts b/src/mux/private-environment.ts index 5e6fc8f00..61f8b334c 100644 --- a/src/mux/private-environment.ts +++ b/src/mux/private-environment.ts @@ -92,13 +92,18 @@ export type StaleMuxEnvironment = { * The doctor reports these and removes them on `--fix`; staging itself uses * {@link sweepStaleMuxEnvironments}. Only * private directories with Hack's prefix and owned by this user qualify; - * nothing is read. + * nothing is read. Roots are reported under the resolved `tmpDir`, the same + * spelling {@link stageMuxEnvironment} returns. */ export async function findStaleMuxEnvironments(opts?: { readonly tmpDir?: string; readonly now?: number; }): Promise { - const base = opts?.tmpDir ?? tmpdir(); + // Resolved once, as staging resolves the directory it creates, so a + // payload has one spelling whichever function names it (macOS keeps the + // temporary directory behind /var -> /private/var). + const given = opts?.tmpDir ?? tmpdir(); + const base = await realpath(given).catch(() => given); const now = opts?.now ?? Date.now(); const names = await readdir(base).catch(() => [] as string[]); const stale: StaleMuxEnvironment[] = []; diff --git a/tests/mux-private-environment.test.ts b/tests/mux-private-environment.test.ts index ab920fc00..32c353039 100644 --- a/tests/mux-private-environment.test.ts +++ b/tests/mux-private-environment.test.ts @@ -6,6 +6,7 @@ import { mkdtemp, readdir, readFile, + realpath, rm, symlink, unlink, @@ -215,3 +216,27 @@ test("the next staging sweeps payloads of sessions that never ran; recent ones s await rm(dir, { recursive: true, force: true }); } }); + +test("stale payloads are named by the resolved path staging returns, even through a linked temp directory", async () => { + // macOS keeps the temporary directory behind /var -> /private/var; a link + // here stands in for it. + const real = await mkdtemp(join(tmpdir(), "mux-env-real-")); + const linked = `${real}-link`; + await symlink(real, linked); + try { + const staged = await stageMuxEnvironment( + { FAKE_KEY: "synthetic-linked" }, + { tmpDir: linked } + ); + await expire(dirname(staged.path)); + const found = await findStaleMuxEnvironments({ tmpDir: linked }); + expect(found.map((entry) => entry.root)).toEqual([dirname(staged.path)]); + expect(dirname(staged.path).startsWith(`${await realpath(real)}/`)).toBe( + true + ); + expect(await removeStaleMuxEnvironment(found[0]!)).toBe(true); + } finally { + await rm(linked, { force: true }); + await rm(real, { recursive: true, force: true }); + } +}); diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 691b022a4..4a989e692 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -14,7 +14,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { STAGING_LEFTOVER_MIN_AGE_MS } from "../src/lib/private-staging.ts"; import { projectEnvLiveInputPaths } from "../src/lib/project-env-config.ts"; import { @@ -1179,3 +1179,43 @@ test("a project inside a larger repository keeps its own ignore repair", async ( const key = findings.find((finding) => finding.path === keyPath); expect(key?.fix?.kind).toBe("ignore"); }); + +test("a configured key in a stale payload reached through a linked temp directory is never removed", async () => { + const f = await fixture(); + // The temporary directory is reached through a link (macOS: /var -> + // /private/var); the key is configured by the linked spelling, while + // hygiene names payloads by the resolved one. + const linked = join(f.root, "tmp-link"); + await symlink(f.muxDir, linked); + const payload = await stageMuxEnvironment( + { FAKE_KEY: "synthetic-linked" }, + { tmpDir: linked } + ); + await backdate(dirname(payload.path)); + const store = join(f.home, "secrets.enc.json"); + await writeFile(store, `{"sentinel":"${SENTINEL}"}\n`, { mode: 0o600 }); + const keyByLink = join(linked, basename(dirname(payload.path)), "env.json"); + const configured = { + ...f.scope, + tmpDir: linked, + stores: [{ storePath: store, keyPath: keyByLink }], + }; + expect( + (await inspectSecretsHygiene(configured)).some( + (finding) => finding.fix?.kind === "remove_mux" + ) + ).toBe(false); + const earlier = await inspectSecretsHygiene({ ...f.scope, tmpDir: linked }); + expect( + earlier + .filter((finding) => finding.fix?.kind === "remove_mux") + .map((finding) => finding.path) + ).toEqual([dirname(payload.path)]); + await applySecretsHygieneFixes({ + findings: earlier, + report: () => undefined, + currentScope: async () => configured, + }); + expect(await readFile(payload.path, "utf8")).toContain("synthetic-linked"); + await payload.cleanup(); +}); From 334b3c134d2803b47dbca85a687146c02487ebcc Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:56:44 +0000 Subject: [PATCH 40/42] fix(doctor): hygiene never removes anything in a retired store (HACK-2165, HACK-2177) A retired store (.retired-/, from #779) may hold the only copy of values its key still opens. Hygiene's detectors never look inside one, but the removal check now refuses any target whose path, as given or resolved, has a retired-store component, both when findings are built and just before the unlink, with or without a scope. Regression: a store retired with retireEncryptedStoreFile survives inspection and --fix, and a guarded removal offered for its archived file (with and without a scope) is refused. It fails without the check. Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 3 +- src/lib/secrets-hygiene.ts | 23 +++++++++++++++- tests/secrets-hygiene.test.ts | 52 +++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/env.md b/docs/env.md index 4786d2697..1d2094968 100644 --- a/docs/env.md +++ b/docs/env.md @@ -471,7 +471,8 @@ staging copies live in a private directory next to the file, `.hack-staging/`, w holds a `.gitignore` of `*`, so git never lists anything in it. `hack doctor --fix` removes copies older than 10 minutes, and stale terminal-session payloads, but never a file env resolution reads (a configured store or key, the project or shared key, -`hack.config.json` or a YAML layer, by its own path or where it resolves). Before each +`hack.config.json` or a YAML layer, by its own path or where it resolves), and never +anything in a retired store (`.retired-/`). Before each removal it reads the configuration again, so an edit made since the check counts, and it removes nothing it cannot confirm; writes themselves remove nothing there. A machine-local layer that a write changes, or leaves unchanged, is set to 0600 if other users could read it. Commands that read env diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 879f84c6f..68ae3aa38 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -36,6 +36,7 @@ import { isInsideGitCheckoutMarker, projectEnvLiveInputPaths, } from "./project-env-config.ts"; +import { RETIRED_STORE_INFIX } from "./secret-store.ts"; /** * `hack doctor`'s "secrets hygiene" section (HACK-2165): files at rest that @@ -1080,7 +1081,19 @@ async function isLive(opts: { return true; } const real = await realpath(opts.path).catch(() => null); - return real !== null && opts.live.has(real); + if (real !== null && opts.live.has(real)) { + return true; + } + // A retired store (HACK-2177) is kept on purpose: it may hold the only + // copy of values its key still opens. Nothing in or under one is removed. + return [resolve(opts.path), real].some( + (spelling) => spelling !== null && inRetiredStore(spelling) + ); +} + +/** Whether a path component is a retired-store name (`.retired-…`). */ +function inRetiredStore(path: string): boolean { + return path.split(sep).some((part) => part.includes(RETIRED_STORE_INFIX)); } /** The directory a fix changes. */ @@ -1296,6 +1309,14 @@ async function removalStillSafe(opts: { scopes.push(current); } const path = removalTarget(opts.fix); + // Whatever the scopes say, a retired store is never removed. + const real = await realpath(path).catch(() => null); + if ( + inRetiredStore(resolve(path)) || + (real !== null && inRetiredStore(real)) + ) { + return false; + } for (const scope of scopes) { if (await isLive({ path, live: await livePaths(scope) })) { return false; diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 4a989e692..fda8a0872 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -17,6 +17,7 @@ import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { STAGING_LEFTOVER_MIN_AGE_MS } from "../src/lib/private-staging.ts"; import { projectEnvLiveInputPaths } from "../src/lib/project-env-config.ts"; +import { retireEncryptedStoreFile } from "../src/lib/secret-store.ts"; import { applySecretsHygieneFixes, inspectSecretsHygiene, @@ -1219,3 +1220,54 @@ test("a configured key in a stale payload reached through a linked temp director expect(await readFile(payload.path, "utf8")).toContain("synthetic-linked"); await payload.cleanup(); }); + +test("a retired store (HACK-2177) is never removed by hygiene, whatever offers it", async () => { + const f = await fixture(); + const storePath = join(f.home, "secrets.enc.json"); + const keyPath = join(f.home, "secrets-file.key"); + await writeFile(storePath, `{"sentinel":"${SENTINEL}"}\n`, { mode: 0o600 }); + const retired = await retireEncryptedStoreFile({ storePath, keyPath }); + const archive = retired.to; + expect(archive).toContain(".retired-"); + await backdate(archive); + // Ordinary inspection and --fix leave it alone. + const scope = { ...f.scope, stores: [{ storePath, keyPath }] }; + const findings = await inspectSecretsHygiene(scope); + expect(paths(findings)).not.toContain(archive); + await applySecretsHygieneFixes({ + findings, + report: () => undefined, + currentScope: async () => scope, + }); + expect(await readFile(archive, "utf8")).toContain(SENTINEL); + // A removal offered for it (by any detector, with or without a scope) + // is refused at the unlink. + const stats = await lstat(archive); + // A guard that holds, so only the removal check can refuse it. + const directory = await lstat(dirname(archive)); + const guard = { + path: dirname(archive), + dev: directory.dev, + ino: directory.ino, + }; + for (const withScope of [true, false]) { + const result = await applySecretsHygieneFixes({ + findings: [ + { + status: "warn", + path: archive, + message: "synthetic", + guard, + fix: { + kind: "remove_leftover", + leftover: { path: archive, stats }, + ...(withScope ? { scope } : {}), + }, + }, + ], + report: () => undefined, + }); + expect(result).toEqual({ applied: 0, skipped: 1 }); + } + expect(await readFile(archive, "utf8")).toContain(SENTINEL); +}); From f81a8ea59d81664994241586c1a0218d555484c2 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:07:56 +0000 Subject: [PATCH 41/42] fix(doctor): a removal target that cannot be resolved is never removed (HACK-2165) The retired-store and live-input checks treated a failed realpath of the target as "no resolved spelling", so an alias into a retired store whose resolution failed (EIO, say) passed both. Both now use realpathOrMissing: only a missing target has no resolved spelling; any other failure makes the target live and the removal is refused. Regression: an alias directory to a retired store, realpath failing with EIO for the target only, and a holding directory guard: the offered removal is refused with and without a scope. It fails without the change. Co-Authored-By: Claude Opus 5.5 --- src/lib/secrets-hygiene.ts | 13 ++++++--- tests/secrets-hygiene.test.ts | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index 68ae3aa38..e0eca7162 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -1080,7 +1080,12 @@ async function isLive(opts: { if (opts.live === "unknown" || opts.live.has(resolve(opts.path))) { return true; } - const real = await realpath(opts.path).catch(() => null); + // Only a missing target has no resolved spelling; one that cannot be + // resolved for another reason might be live (or retired), so it is. + const real = await realpathOrMissing(opts.path); + if (real === "unknown") { + return true; + } if (real !== null && opts.live.has(real)) { return true; } @@ -1309,9 +1314,11 @@ async function removalStillSafe(opts: { scopes.push(current); } const path = removalTarget(opts.fix); - // Whatever the scopes say, a retired store is never removed. - const real = await realpath(path).catch(() => null); + // Whatever the scopes say, a retired store is never removed, and neither + // is a target whose resolved path (so its membership) is unknown. + const real = await realpathOrMissing(path); if ( + real === "unknown" || inRetiredStore(resolve(path)) || (real !== null && inRetiredStore(real)) ) { diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index fda8a0872..171fdad19 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -1271,3 +1271,54 @@ test("a retired store (HACK-2177) is never removed by hygiene, whatever offers i } expect(await readFile(archive, "utf8")).toContain(SENTINEL); }); + +test("a removal whose target cannot be resolved is refused, even through an alias to a retired store", async () => { + const f = await fixture(); + const storePath = join(f.home, "secrets.enc.json"); + const keyPath = join(f.home, "secrets-file.key"); + await writeFile(storePath, `{"sentinel":"${SENTINEL}"}\n`, { mode: 0o600 }); + const archive = (await retireEncryptedStoreFile({ storePath, keyPath })).to; + // An alias with no retired marker in its spelling, whose resolution fails + // for a reason other than absence. + const alias = join(f.root, "alias"); + await symlink(dirname(archive), alias); + const target = join(alias, basename(archive)); + const stats = await lstat(target); + const directory = await fsPromises.stat(alias); + const guard = { path: alias, dev: directory.dev, ino: directory.ino }; + const scope = { ...f.scope, stores: [{ storePath, keyPath }] }; + const original = fsPromises.realpath; + const spy = spyOn(fsPromises, "realpath").mockImplementation((async ( + path: Parameters[0], + ...rest: unknown[] + ) => { + if (String(path) === target) { + throw Object.assign(new Error("synthetic EIO"), { code: "EIO" }); + } + return await Reflect.apply(original, fsPromises, [path, ...rest]); + }) as typeof original); + try { + for (const withScope of [true, false]) { + const result = await applySecretsHygieneFixes({ + findings: [ + { + status: "warn", + path: target, + message: "synthetic", + guard, + fix: { + kind: "remove_leftover", + leftover: { path: target, stats }, + ...(withScope ? { scope } : {}), + }, + }, + ], + report: () => undefined, + }); + expect(result).toEqual({ applied: 0, skipped: 1 }); + } + } finally { + spy.mockRestore(); + } + expect(await readFile(archive, "utf8")).toContain(SENTINEL); +}); From cbcaf530e141540988eb7b7d4db0945404f89b15 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:15:08 +0000 Subject: [PATCH 42/42] fix(doctor): an input alias that cannot be resolved makes the live set unknown (HACK-2165) livePaths added each input's realpath when it could and silently skipped it otherwise, so an alias whose resolution failed (EIO, say) left the resolved file unprotected. It now uses realpathOrMissing: a missing input has no resolved spelling, and any other failure makes the whole set unknown, so nothing is removed. Regression: a configured key reached through an alias directory whose resolution fails, at a leftover-shaped real path: no removal is offered and --fix keeps it. It fails without the change. Co-Authored-By: Claude Opus 5.5 --- src/lib/secrets-hygiene.ts | 9 ++++++-- tests/secrets-hygiene.test.ts | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/lib/secrets-hygiene.ts b/src/lib/secrets-hygiene.ts index e0eca7162..a5147daab 100644 --- a/src/lib/secrets-hygiene.ts +++ b/src/lib/secrets-hygiene.ts @@ -1060,8 +1060,13 @@ async function livePaths( const live = new Set(); for (const path of paths) { live.add(resolve(path)); - const real = await realpath(path).catch(() => null); - if (real) { + // A missing input has no resolved spelling; one that cannot be resolved + // for another reason leaves the set unknown, never smaller. + const real = await realpathOrMissing(path); + if (real === "unknown") { + return "unknown"; + } + if (real !== null) { live.add(real); } } diff --git a/tests/secrets-hygiene.test.ts b/tests/secrets-hygiene.test.ts index 171fdad19..4faef9d55 100644 --- a/tests/secrets-hygiene.test.ts +++ b/tests/secrets-hygiene.test.ts @@ -1322,3 +1322,46 @@ test("a removal whose target cannot be resolved is refused, even through an alia } expect(await readFile(archive, "utf8")).toContain(SENTINEL); }); + +test("a configured input whose alias cannot be resolved makes the live set unknown, not smaller", async () => { + const f = await fixture(); + // The configured key is a leftover-shaped file reached through an alias + // directory; resolving that alias spelling fails (an I/O error, say). + const real = join(f.projectRoot, `..hack.secret.key.${UUID}.tmp`); + await writeFile(real, `${SENTINEL}\n`, { mode: 0o600 }); + await backdate(real); + const alias = join(f.root, "alias"); + await symlink(f.projectRoot, alias); + const keyPath = join(alias, basename(real)); + const scope = { + ...f.scope, + stores: [{ storePath: join(f.home, "secrets.enc.json"), keyPath }], + }; + const original = fsPromises.realpath; + const spy = spyOn(fsPromises, "realpath").mockImplementation((async ( + path: Parameters[0], + ...rest: unknown[] + ) => { + if (String(path) === keyPath) { + throw Object.assign(new Error("synthetic EIO"), { code: "EIO" }); + } + return await Reflect.apply(original, fsPromises, [path, ...rest]); + }) as typeof original); + try { + const findings = await inspectSecretsHygiene(scope); + expect( + findings.some( + (finding) => + finding.fix?.kind === "remove_leftover" && finding.path === real + ) + ).toBe(false); + await applySecretsHygieneFixes({ + findings, + report: () => undefined, + currentScope: async () => scope, + }); + } finally { + spy.mockRestore(); + } + expect(await readFile(real, "utf8")).toBe(`${SENTINEL}\n`); +});