From 6dad61597245765e8f847a3c3e78622d536151c1 Mon Sep 17 00:00:00 2001 From: Katniss Date: Fri, 5 Jun 2026 22:27:52 -0700 Subject: [PATCH] fix(cli): make writeCredentials atomic and enforce 0600 on pre-existing files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in writeCredentials(): 1. Non-atomic write. fs.writeFile() writes in-place; a kill/OOM mid-write leaves credentials.json truncated. The next readCredentials() hits JSON.parse on garbage and silently returns null (logged out, no explanation). local-vault.ts/writeVault() — which protects the same config dir — already uses the tmp+rename pattern; credentials.ts should match it. 2. mode: 0o600 only applies on file creation. If the file pre-exists at a looser mode (e.g. 0644 from an older sh1pt version that omitted mode, or from a cp/touch by the user), fs.writeFile() preserves that mode. local-vault.ts/writeVault() explicitly calls chmod() after rename to handle this case; credentials.ts does not. Fix: mirror writeVault() exactly — write to a .tmp sibling, rename atomically, then chmod(0o600) the destination. Also pass mode: 0o700 to mkdir() so the containing directory is not world-traversable. --- packages/cli/src/credentials.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/credentials.ts b/packages/cli/src/credentials.ts index 82fb648d..cea664f3 100644 --- a/packages/cli/src/credentials.ts +++ b/packages/cli/src/credentials.ts @@ -44,9 +44,18 @@ export async function readCredentials(): Promise { export async function writeCredentials(creds: Credentials): Promise { const path = credentialsPath(); - await fs.mkdir(dirname(path), { recursive: true }); - // Mode 0600 — only the user can read. - await fs.writeFile(path, JSON.stringify(creds, null, 2), { encoding: 'utf8', mode: 0o600 }); + // Mode 0o700 on the directory — match local-vault.ts writeVault pattern. + await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); + // Atomic write: write to a tmp file then rename, so a crash mid-write + // never leaves credentials.json truncated/corrupt. Mirrors writeVault() in + // local-vault.ts which holds equally sensitive data. + const tmp = `${path}.tmp`; + await fs.writeFile(tmp, JSON.stringify(creds, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 }); + await fs.rename(tmp, path); + // rename(2) preserves the source mode, but if the destination pre-existed + // at a looser mode (e.g. 0644 from an older sh1pt build), the resulting + // file keeps that loose mode. Explicitly tighten after rename. + await fs.chmod(path, 0o600).catch(() => {}); } export async function clearCredentials(): Promise {