From c888cc351349efb995e1ec3e850e38dce8cb2048 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 14:55:09 +1000 Subject: [PATCH 1/6] feat(cli): make `stash env` mint real deployment credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command was a gated stub (STASH_EXPERIMENTAL_ENV_CMD) waiting on "the CTS mint endpoint" — which turned out to already exist. It now mints working credentials from the device-code session using the same APIs the dashboard uses, all plain fetch against the session's own service URLs: 1. GET {cts}/api/workspaces → server-authoritative region → CS_WORKSPACE_CRN 2. POST {zerokms}/create-client → CS_CLIENT_ID / CS_CLIENT_KEY (hex-transcoded) 3. POST {cts}/api/access-keys → CS_CLIENT_ACCESS_KEY (member role, always — the CLI deliberately cannot mint admin keys; role is omitted so the server owns the default) Design points: - The ZeroKMS client is created before the access key, so a partial failure leaves an inert client record, never an unaccounted-for live credential. - The key name resolves before any profile/network access, so the non-interactive missing-name failure is credential-free — that is the seam the new pty-less e2e exercises (minting live keys from a dev machine's real session in tests is deliberately impossible). - --write emits .env.production.local with mode 0600 and refuses to overwrite non-interactively; --json emits one minted object or the shared { status: 'error' } envelope. Closes #663 (the wasm/edge credential gap, CIP-2997): edge runtimes that cannot read ~/.cipherstash (containerised supabase functions serve, Workers) now have a supported path — stash env + --env-file/secret store. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/stash-env-mint-credentials.md | 19 + packages/cli/src/bin/main.ts | 8 +- packages/cli/src/cli/registry.ts | 34 +- .../src/commands/env/__tests__/env.test.ts | 332 +++++++++++++++++ packages/cli/src/commands/env/index.ts | 345 ++++++++++++++++-- packages/cli/src/messages.ts | 8 + .../tests/e2e/env-non-interactive.e2e.test.ts | 47 +++ skills/stash-cli/SKILL.md | 39 +- skills/stash-supabase/SKILL.md | 7 +- 9 files changed, 793 insertions(+), 46 deletions(-) create mode 100644 .changeset/stash-env-mint-credentials.md create mode 100644 packages/cli/src/commands/env/__tests__/env.test.ts create mode 100644 packages/cli/tests/e2e/env-non-interactive.e2e.test.ts diff --git a/.changeset/stash-env-mint-credentials.md b/.changeset/stash-env-mint-credentials.md new file mode 100644 index 00000000..599cc0f7 --- /dev/null +++ b/.changeset/stash-env-mint-credentials.md @@ -0,0 +1,19 @@ +--- +'stash': minor +--- + +`stash env` now works: it mints deployment credentials from your device-code +session and prints them as env vars — no dashboard copy-paste. The command +creates a fresh ZeroKMS client and a member-role CipherStash access key (named +via `--name`), then emits `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, +and `CS_CLIENT_ACCESS_KEY` to stdout, `--write` (`.env.production.local`, mode +0600), or `--json`. Creating access keys requires the admin role in the +workspace; the minted key itself is always member — the CLI deliberately +cannot mint admin keys. + +This is also the supported credential path for WASM/edge local development +(Supabase Edge Functions, Cloudflare Workers, Deno), where the runtime cannot +read the `~/.cipherstash` device profile: mint a key and feed it via +`--env-file` or the platform's secret store. + +The `STASH_EXPERIMENTAL_ENV_CMD` gate is removed. diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 51542377..38c6992e 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -125,7 +125,7 @@ Commands: encrypt cutover Rename swap encrypted → primary column (EQL v2 only) encrypt drop Generate a migration to drop the plaintext column - env (experimental) Print production env vars for deployment + env Mint deployment credentials and print them as env vars Options: --help, -h Show help @@ -532,7 +532,11 @@ async function dispatch( await runSchemaCommand(subcommand, flags, values) break case 'env': - await envCommand({ write: flags.write }) + await envCommand({ + write: flags.write, + json: flags.json, + name: values.name, + }) break case 'manifest': // Pure metadata (no native code) — safe to run anywhere, including when diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 3c1fb812..e0a6a831 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -530,15 +530,43 @@ export const registry: CommandGroup[] = [ ], }, { - title: 'Experimental', + title: 'Deployment', commands: [ { name: 'env', - summary: '(experimental) Print production env vars for deployment', + summary: 'Mint deployment credentials and print them as env vars', + long: [ + 'Mints a fresh ZeroKMS client and a CipherStash access key from your', + 'device-code session (`stash auth login`), then prints the four env', + 'vars a deployed app needs: CS_WORKSPACE_CRN, CS_CLIENT_ID,', + 'CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY.', + '', + 'The access key is created with the member role (the CLI never mints', + 'admin keys) and is shown exactly once — pipe the output into your', + 'deployment secret store. Creating access keys requires your user to', + 'have the admin role in the workspace.', + ].join('\n'), + examples: [ + 'env --name my-app-prod', + 'env --name my-app-prod --write', + 'env --name edge-dev --json', + ], flags: [ + { + name: '--name', + value: '', + description: + 'Name for the minted access key and ZeroKMS client. Prompted for interactively; required in non-interactive runs.', + }, { name: '--write', - description: 'Write the vars to a file instead of printing them.', + description: + 'Write the vars to .env.production.local (mode 0600) instead of printing them.', + }, + { + name: '--json', + description: + 'Emit a single machine-readable JSON object (or a { status: "error" } envelope) instead of a dotenv block. Implies no prompts.', }, ], }, diff --git a/packages/cli/src/commands/env/__tests__/env.test.ts b/packages/cli/src/commands/env/__tests__/env.test.ts new file mode 100644 index 00000000..b8b0c90e --- /dev/null +++ b/packages/cli/src/commands/env/__tests__/env.test.ts @@ -0,0 +1,332 @@ +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { messages } from '../../../messages.js' + +// env/index.ts imports the native `@cipherstash/auth` binary — replace it +// before load so the unit suite stays native-free (same pattern as +// `auth/__tests__/login.test.ts`). 0.41+ Result envelopes throughout. +const authMock = vi.hoisted(() => ({ + DeviceSessionStrategy: { fromProfile: vi.fn() }, +})) +vi.mock('@cipherstash/auth', () => ({ default: authMock })) + +const clack = vi.hoisted(() => { + const spinnerInstance = { start: vi.fn(), stop: vi.fn() } + return { + spinnerInstance, + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + spinner: vi.fn(() => spinnerInstance), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + text: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(() => false), + } +}) +vi.mock('@clack/prompts', () => clack) + +// Interactivity is env-dependent; pin it per test. +const tty = vi.hoisted(() => ({ isInteractive: vi.fn(() => false) })) +vi.mock('../../../config/tty.js', () => tty) + +// Keep the runner deterministic (`npx stash`) regardless of the dev machine. +vi.mock('../../init/utils.js', () => ({ + detectPackageManager: () => 'npm', + runnerCommand: () => 'npx ', +})) + +const { envCommand } = await import('../index.js') + +/** Sentinel thrown by the stubbed `process.exit` so tests can assert on it. */ +class ExitSignal extends Error { + constructor(readonly code: number | undefined) { + super(`exit ${code}`) + } +} + +/** A happy-path device session: fromProfile → getToken → token data. */ +function stubSession(over: Record = {}) { + authMock.DeviceSessionStrategy.fromProfile.mockReturnValue({ + data: { + getToken: vi.fn(async () => ({ + data: { + token: 'test-bearer-token', + subject: 'CS|user', + workspaceId: 'WS123', + issuer: 'https://cts.test/', + services: { zerokms: 'https://zkms.test/' }, + ...over, + }, + })), + }, + }) +} + +/** + * Route the three HTTP calls by URL. Returns the fetch spy. Bodies mirror the + * real cts-web / zerokms-protocol wire shapes. + */ +function stubFetch( + overrides: Partial< + Record<'workspaces' | 'client' | 'accessKey', Response> + > = {}, +) { + const routes: Array<{ match: string; response: () => Response }> = [ + { + match: '/api/workspaces', + response: () => + overrides.workspaces ?? + Response.json([ + { + id: 'OTHER', + name: 'other', + role: 'member', + region: 'us-east-1.aws', + org_id: 'o', + }, + { + id: 'WS123', + name: 'mine', + role: 'admin', + region: 'ap-southeast-2.aws', + org_id: 'o', + }, + ]), + }, + { + match: '/create-client', + response: () => + overrides.client ?? + // client_key: base64 of bytes 00 01 02 03 → hex "00010203" + Response.json({ + id: 'client-uuid-1', + dataset_id: 'ks-uuid', + name: 'my-app-prod', + description: 'd', + client_key: 'AAECAw==', + }), + }, + { + match: '/api/access-keys', + response: () => + overrides.accessKey ?? + Response.json( + { accessKey: 'CSAKTkeyid.keysecret', role: 'member' }, + { status: 201 }, + ), + }, + ] + const fetchSpy = vi.fn(async (url: string | URL) => { + const href = String(url) + const route = routes.find((r) => href.includes(r.match)) + if (!route) throw new Error(`unexpected fetch: ${href}`) + return route.response() + }) + vi.stubGlobal('fetch', fetchSpy) + return fetchSpy +} + +let exitSpy: ReturnType +let logSpy: ReturnType + +beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code) + }) as never) + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + authMock.DeviceSessionStrategy.fromProfile.mockReset() + tty.isInteractive.mockReturnValue(false) +}) + +/** Every console.log line joined — the command's full stdout. */ +function stdout(): string { + return logSpy.mock.calls.map((c) => c.join(' ')).join('\n') +} + +describe('envCommand — name resolution', () => { + it('fails non-interactively without --name, before touching the profile', async () => { + await expect(envCommand({})).rejects.toThrow(ExitSignal) + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.log.error).toHaveBeenCalledWith( + expect.stringContaining(messages.env.missingName), + ) + // The failure must be reachable with no profile and no network. + expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() + }) + + it('emits the error envelope on the --json stream', async () => { + await expect(envCommand({ json: true })).rejects.toThrow(ExitSignal) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ status: 'error', code: 'missing_name' }) + }) +}) + +describe('envCommand — happy path', () => { + it('mints credentials and prints the four env vars', async () => { + stubSession() + const fetchSpy = stubFetch() + + await envCommand({ name: 'my-app-prod' }) + + const block = stdout() + expect(block).toContain('CS_WORKSPACE_CRN=crn:ap-southeast-2.aws:WS123') + expect(block).toContain('CS_CLIENT_ID=client-uuid-1') + // Hex-transcoded from base64 AAECAw== + expect(block).toContain('CS_CLIENT_KEY=00010203') + expect(block).toContain('CS_CLIENT_ACCESS_KEY=CSAKTkeyid.keysecret') + expect(block).toContain('Do not commit') + // The bearer token must never reach stdout. + expect(block).not.toContain('test-bearer-token') + + // Ordering contract: client BEFORE access key (partial failure leaves an + // inert client, never an unaccounted-for live credential). + const urls = fetchSpy.mock.calls.map((c) => String(c[0])) + expect(urls.findIndex((u) => u.includes('/create-client'))).toBeLessThan( + urls.findIndex((u) => u.includes('/api/access-keys')), + ) + + // Wire details: bearer auth everywhere; role omitted (server defaults to + // member); trailing slashes normalised (no `//` in paths). + for (const [url, init] of fetchSpy.mock.calls as [string, RequestInit][]) { + expect((init.headers as Record).authorization).toBe( + 'Bearer test-bearer-token', + ) + expect(String(url)).not.toMatch(/[^:]\/\//) + } + const accessKeyCall = fetchSpy.mock.calls.find((c) => + String(c[0]).includes('/api/access-keys'), + ) as [string, RequestInit] + const body = JSON.parse(String(accessKeyCall[1].body)) + expect(body).toEqual({ keyName: 'my-app-prod', workspaceId: 'WS123' }) + expect(body).not.toHaveProperty('role') + }) + + it('emits a single minted object in --json mode', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'edge-dev', json: true }) + + expect(logSpy).toHaveBeenCalledTimes(1) + const event = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(event).toEqual({ + status: 'minted', + keyName: 'edge-dev', + workspaceCrn: 'crn:ap-southeast-2.aws:WS123', + clientId: 'client-uuid-1', + clientKey: '00010203', + accessKey: 'CSAKTkeyid.keysecret', + }) + }) +}) + +describe('envCommand — failure modes', () => { + it('reports a missing login with an auth hint', async () => { + authMock.DeviceSessionStrategy.fromProfile.mockReturnValue({ + failure: { type: 'not_authenticated', error: new Error('no auth.json') }, + }) + stubFetch() + + await expect(envCommand({ name: 'x' })).rejects.toThrow(ExitSignal) + expect(clack.log.error).toHaveBeenCalledWith( + expect.stringContaining('Not logged in'), + ) + expect(clack.log.info).toHaveBeenCalledWith( + expect.stringContaining('npx stash auth login'), + ) + }) + + it('maps a 403 on access-key creation to the admin-role message, naming the leftover client', async () => { + stubSession() + stubFetch({ accessKey: new Response('forbidden', { status: 403 }) }) + + await expect(envCommand({ name: 'my-app-prod' })).rejects.toThrow( + ExitSignal, + ) + const message = String(clack.log.error.mock.calls.at(-1)?.[0]) + expect(message).toContain('admin') + expect(message).toContain("ZeroKMS client 'my-app-prod'") + }) + + it('suggests --name on a duplicate-name rejection', async () => { + stubSession() + stubFetch({ + accessKey: new Response('{"error":"Duplicate key error"}', { + status: 400, + }), + }) + + await expect(envCommand({ name: 'taken' })).rejects.toThrow(ExitSignal) + const message = String(clack.log.error.mock.calls.at(-1)?.[0]) + expect(message).toContain('Duplicate key error') + expect(message).toContain('--name') + }) + + it('fails when the session workspace is missing from the workspace list', async () => { + stubSession({ workspaceId: 'GONE' }) + stubFetch() + + await expect(envCommand({ name: 'x' })).rejects.toThrow(ExitSignal) + expect(String(clack.log.error.mock.calls.at(-1)?.[0])).toContain('GONE') + }) +}) + +describe('envCommand — --write', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'stash-env-test-')) + vi.spyOn(process, 'cwd').mockReturnValue(dir) + }) + afterEach(() => { + // The file is written 0600; ensure cleanup can always delete it. + try { + chmodSync(join(dir, '.env.production.local'), 0o600) + } catch {} + rmSync(dir, { recursive: true, force: true }) + }) + + it('writes .env.production.local with mode 0600', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'my-app-prod', write: true }) + + const target = join(dir, '.env.production.local') + const content = readFileSync(target, 'utf-8') + expect(content).toContain('CS_CLIENT_ACCESS_KEY=CSAKTkeyid.keysecret') + expect(statSync(target).mode & 0o777).toBe(0o600) + // Nothing printed to stdout on the write path. + expect(stdout()).not.toContain('CS_CLIENT_ACCESS_KEY') + }) + + it('refuses to overwrite an existing file non-interactively', async () => { + stubSession() + stubFetch() + writeFileSync(join(dir, '.env.production.local'), 'existing') + + await expect(envCommand({ name: 'x', write: true })).rejects.toThrow( + ExitSignal, + ) + expect(readFileSync(join(dir, '.env.production.local'), 'utf-8')).toBe( + 'existing', + ) + expect(String(clack.log.error.mock.calls.at(-1)?.[0])).toContain( + 'refusing to overwrite', + ) + }) +}) diff --git a/packages/cli/src/commands/env/index.ts b/packages/cli/src/commands/env/index.ts index e7881318..586d1baa 100644 --- a/packages/cli/src/commands/env/index.ts +++ b/packages/cli/src/commands/env/index.ts @@ -1,45 +1,87 @@ import { existsSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { basename, resolve } from 'node:path' +import auth from '@cipherstash/auth' import * as p from '@clack/prompts' +import { isInteractive } from '../../config/tty.js' +import { messages } from '../../messages.js' +import { emitJsonError, emitJsonEvent } from '../auth/events.js' import { detectPackageManager, runnerCommand } from '../init/utils.js' +const { DeviceSessionStrategy } = auth + export interface EnvOptions { /** Write the emitted block to `.env.production.local` instead of stdout. */ write?: boolean + /** Name for the minted access key + ZeroKMS client. Required non-interactively. */ + name?: string + /** Emit a single JSON object instead of a dotenv block. Implies no prompts. */ + json?: boolean } /** - * Generate production env vars for CipherStash using the local device-code - * auth. Today this is a scaffold (CIP-2997): the CTS endpoint that mints a - * production access key isn't wired up yet, so the command is gated behind - * the `STASH_EXPERIMENTAL_ENV_CMD` env flag to keep it out of the way until - * the backend piece lands. + * Mint deployment credentials from the local device-code session (CIP-2997, + * stack#663) and print them as env vars. + * + * Uses the CTS + ZeroKMS APIs the dashboard itself uses, authenticated with + * the device-session token from `stash auth login`: + * + * 1. `GET {cts}/api/workspaces` → the workspace's region → `CS_WORKSPACE_CRN` + * 2. `POST {zerokms}/create-client` → `CS_CLIENT_ID` / `CS_CLIENT_KEY` + * 3. `POST {cts}/api/access-keys` → `CS_CLIENT_ACCESS_KEY` * - * Once CTS exposes the mint endpoint, fill in `fetchProdCredentials` below. - * The emitted format matches what apps in the onboarding flow consume via - * `process.env.*`, so `--write` produces a file the user can commit into a - * deployment secrets store. + * The access key is minted with the server-default **member** role — the CLI + * deliberately has no `--role`: a runtime credential never needs more, and + * admin keys should be minted in the dashboard where they're visible. The + * key is returned exactly once by CTS; we print it and never persist it + * (except via an explicit `--write`). + * + * Ordering is deliberate: the ZeroKMS client is created *before* the access + * key, so the only partial-failure leftover is an inert client record — + * never an unaccounted-for live credential. */ export async function envCommand(options: EnvOptions = {}): Promise { - if (!process.env.STASH_EXPERIMENTAL_ENV_CMD) { - p.log.warn('`env` is experimental and not ready for production use yet.') - p.log.info( - 'Set STASH_EXPERIMENTAL_ENV_CMD=1 in your environment to try it.', - ) - return - } - + const json = options.json ?? false const runner = runnerCommand(detectPackageManager(), '').trim() const cliRef = `${runner} stash` - p.intro(`${cliRef} env`) + if (!json) p.intro(`${cliRef} env`) - const creds = await fetchProdCredentials() - if (!creds) { - p.log.error( - `Could not mint production credentials. Make sure you are logged in: ${cliRef} auth login`, - ) + // Resolve the key name BEFORE touching the profile or the network: the + // non-interactive missing-name failure must be reachable without + // credentials (it is what the e2e suite exercises), and an early exit + // here can never leave partial server-side state behind. + const keyName = await resolveKeyName(options, json, cliRef) + + const s = json ? null : p.spinner() + s?.start('Minting deployment credentials...') + + let creds: MintedCredentials + try { + creds = await mintCredentials(keyName) + } catch (err) { + s?.stop('Could not mint deployment credentials.') + const failure = + err instanceof MintError + ? err + : new MintError( + 'mint_failed', + err instanceof Error ? err.message : String(err), + ) + if (json) { + emitJsonError(failure.code, failure.message) + } else { + p.log.error(failure.message) + if (failure.hint) p.log.info(failure.hint.replaceAll('{cli}', cliRef)) + } process.exit(1) + return // unreachable; keeps control flow explicit for tests that stub exit + } + + s?.stop('Deployment credentials minted.') + + if (json) { + emitJsonEvent({ status: 'minted', ...creds }) + return } const block = formatEnvBlock(creds, cliRef) @@ -47,6 +89,13 @@ export async function envCommand(options: EnvOptions = {}): Promise { if (options.write) { const target = resolve(process.cwd(), '.env.production.local') if (existsSync(target)) { + if (!isInteractive()) { + p.log.error( + `${target} already exists — refusing to overwrite non-interactively. Remove it first, or run without --write and redirect the output yourself.`, + ) + process.exit(1) + return + } const overwrite = await p.confirm({ message: `${target} already exists. Overwrite?`, initialValue: false, @@ -57,8 +106,12 @@ export async function envCommand(options: EnvOptions = {}): Promise { } } - writeFileSync(target, block, 'utf-8') + // 0600: the file holds two live secrets. + writeFileSync(target, block, { encoding: 'utf-8', mode: 0o600 }) p.log.success(`Wrote ${target}`) + p.log.warn( + 'This file contains live secrets — keep it out of version control.', + ) p.outro('Done!') return } @@ -69,27 +122,245 @@ export async function envCommand(options: EnvOptions = {}): Promise { p.outro('Done!') } -interface ProdCredentials { +// --------------------------------------------------------------------------- +// Key-name resolution +// --------------------------------------------------------------------------- + +async function resolveKeyName( + options: EnvOptions, + json: boolean, + cliRef: string, +): Promise { + const explicit = options.name?.trim() + if (explicit) return explicit + + if (json || !isInteractive()) { + const message = `${messages.env.missingName} — pass --name (e.g. \`${cliRef} env --name my-app-prod\`).` + if (json) { + emitJsonError('missing_name', message) + } else { + p.log.error(message) + } + process.exit(1) + // process.exit is stubbed in unit tests; throw so the command can't + // continue with an undefined name there. + throw new MintError('missing_name', message) + } + + const suggested = suggestKeyName() + const answer = await p.text({ + message: 'Name for this deployment credential', + initialValue: suggested, + validate: (value) => + value.trim().length === 0 ? 'A name is required.' : undefined, + }) + if (p.isCancel(answer)) { + p.cancel('Cancelled.') + process.exit(0) + throw new MintError('cancelled', 'Cancelled.') + } + return answer.trim() +} + +/** Default key name: the project directory, sanitised, e.g. `my-app-prod`. */ +function suggestKeyName(): string { + const dir = basename(process.cwd()) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + return `${dir || 'app'}-prod` +} + +// --------------------------------------------------------------------------- +// Credential minting +// --------------------------------------------------------------------------- + +interface MintedCredentials { + keyName: string + workspaceCrn: string clientId: string + /** Hex-encoded — the historical `CS_CLIENT_KEY` format every SDK accepts. */ clientKey: string - workspaceId: string + accessKey: string +} + +/** + * Error with a machine-readable code (surfaced on the `--json` stream) and an + * optional human hint (printed as a follow-up log line; `{cli}` is replaced + * with the detected runner, e.g. `npx stash`). + */ +class MintError extends Error { + constructor( + readonly code: string, + message: string, + readonly hint?: string, + ) { + super(message) + this.name = 'MintError' + } +} + +const LOGIN_HINT = 'Run `{cli} auth login` and try again.' + +/** Shape of one workspace in `GET /api/workspaces` (cts-web `UserWorkspace`). */ +interface UserWorkspace { + id: string + region: string +} + +async function mintCredentials(keyName: string): Promise { + // 1. Device session from ~/.cipherstash (written by `stash auth login`). + const strategyResult = DeviceSessionStrategy.fromProfile() + if (strategyResult.failure) { + throw new MintError( + 'not_logged_in', + `Not logged in: ${strategyResult.failure.error.message}`, + LOGIN_HINT, + ) + } + const tokenResult = await strategyResult.data.getToken() + if (tokenResult.failure) { + throw new MintError( + 'session_invalid', + `Could not refresh your session: ${tokenResult.failure.error.message}`, + LOGIN_HINT, + ) + } + const { token, workspaceId, issuer, services } = tokenResult.data + + const ctsBase = trimTrailingSlash(issuer) + const zerokmsUrl = services?.zerokms + if (!zerokmsUrl) { + throw new MintError( + 'no_zerokms_service', + 'Your session token carries no ZeroKMS service URL — re-authenticate and try again.', + LOGIN_HINT, + ) + } + const zerokmsBase = trimTrailingSlash(zerokmsUrl) + + // 2. Workspace region → CRN. The region in the workspace listing is + // server-authoritative (CTS derives it from the workspace's host), so + // this works for self-hosted CTS too — no issuer-hostname parsing. + const wsResponse = await apiFetch(`${ctsBase}/api/workspaces`, token) + if (!wsResponse.ok) { + throw await httpError( + 'list_workspaces_failed', + 'list workspaces', + wsResponse, + ) + } + const workspaces = (await wsResponse.json()) as UserWorkspace[] + const workspace = workspaces.find((w) => w.id === workspaceId) + if (!workspace) { + throw new MintError( + 'workspace_not_found', + `Workspace ${workspaceId} is not in your workspace list — your session may be stale.`, + LOGIN_HINT, + ) + } + const workspaceCrn = `crn:${workspace.region}:${workspaceId}` + + // 3. ZeroKMS client — CS_CLIENT_ID / CS_CLIENT_KEY. Created BEFORE the + // access key (see the ordering note in the command doc). + const clientResponse = await apiFetch(`${zerokmsBase}/create-client`, token, { + method: 'POST', + body: JSON.stringify({ + name: keyName, + description: `Created by \`stash env\` for deployment`, + }), + }) + if (!clientResponse.ok) { + throw await httpError( + 'create_client_failed', + 'create the ZeroKMS client', + clientResponse, + ) + } + const client = (await clientResponse.json()) as { + id: string + client_key: string + } + // ZeroKMS returns the key material base64-encoded; emit hex, the historical + // CS_CLIENT_KEY format that every released SDK accepts (base64 tolerance + // only landed in cipherstash-client 0.40). + const clientKey = Buffer.from(client.client_key, 'base64').toString('hex') + + // 4. Access key — CS_CLIENT_ACCESS_KEY. Role deliberately omitted: CTS + // defaults to member, and the CLI does not mint anything stronger. + const keyResponse = await apiFetch(`${ctsBase}/api/access-keys`, token, { + method: 'POST', + body: JSON.stringify({ keyName, workspaceId }), + }) + if (!keyResponse.ok) { + const leftover = ` (Note: the ZeroKMS client '${keyName}' was already created — it is inert without an access key, but you may want to remove it in the dashboard.)` + if (keyResponse.status === 403) { + throw new MintError( + 'not_admin', + `Creating access keys requires the admin role in this workspace — ask a workspace admin to mint the key in the dashboard, or to grant you admin.${leftover}`, + ) + } + const base = await httpError( + 'create_access_key_failed', + 'create the access key', + keyResponse, + ) + throw new MintError( + base.code, + `${base.message} If the name is already taken, rerun with a different --name.${leftover}`, + ) + } + const { accessKey } = (await keyResponse.json()) as { accessKey: string } + + return { keyName, workspaceCrn, clientId: client.id, clientKey, accessKey } +} + +function trimTrailingSlash(url: string): string { + return url.replace(/\/+$/, '') +} + +function apiFetch( + url: string, + token: string, + init?: { method?: string; body?: string }, +): Promise { + return fetch(url, { + method: init?.method ?? 'GET', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'user-agent': 'stash-cli', + }, + body: init?.body, + }) } -async function fetchProdCredentials(): Promise { - // TODO(CIP-2997): call the CTS mint endpoint once it's available. The - // endpoint shape, auth header, and error codes are TBD — coordinate with - // the platform team before wiring this up. Until then, return undefined - // so the experimental command fails loudly rather than silently emitting - // placeholder credentials. - return undefined +/** Build a MintError from a non-2xx response, including the body (which CTS + * keeps free of secrets) but never the request's bearer token. */ +async function httpError( + code: string, + action: string, + response: Response, +): Promise { + const body = (await response.text().catch(() => '')).slice(0, 500) + return new MintError( + code, + `Could not ${action}: HTTP ${response.status}${body ? ` — ${body}` : ''}`, + ) } -function formatEnvBlock(creds: ProdCredentials, cliRef: string): string { +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +function formatEnvBlock(creds: MintedCredentials, cliRef: string): string { return [ - `# Generated by \`${cliRef} env\` — production credentials`, + `# Generated by \`${cliRef} env\` — CipherStash deployment credentials ('${creds.keyName}')`, + '# CS_CLIENT_KEY and CS_CLIENT_ACCESS_KEY are secrets. Do not commit them.', + `CS_WORKSPACE_CRN=${creds.workspaceCrn}`, `CS_CLIENT_ID=${creds.clientId}`, `CS_CLIENT_KEY=${creds.clientKey}`, - `CS_WORKSPACE_ID=${creds.workspaceId}`, + `CS_CLIENT_ACCESS_KEY=${creds.accessKey}`, '', ].join('\n') } diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 9e671825..2cda0a5b 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -109,6 +109,14 @@ export const messages = { ) => `\`${pkg}\` is not installed in this project.\n\nInstall the CipherStash packages, then re-run:\n ${installCommands}\n\nOr run \`${stash} init\` to set everything up.`, }, + env: { + /** + * Stable leader of the non-interactive missing-name error. The e2e suite + * asserts on this; the `--name` hint (with the runner-aware invocation) + * is appended at the call site. + */ + missingName: 'A credential name is required in non-interactive mode', + }, telemetry: { /** * The one-time first-run notice. Printed to stderr so it never pollutes diff --git a/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts b/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts new file mode 100644 index 00000000..df01c176 --- /dev/null +++ b/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { messages } from '../../src/messages.js' +import { runPiped } from '../helpers/spawn-piped.js' + +/** + * Non-interactive `stash env`. Only the missing-name failure is exercised: + * the command resolves the credential name BEFORE loading the device profile + * or touching the network, so these cases are deterministic, credential-free, + * and — critically — can never mint real keys on a developer machine that + * happens to have a live `~/.cipherstash` session. + * + * The happy path (real CTS + ZeroKMS calls) is covered by unit tests with a + * stubbed fetch; minting live credentials from CI is deliberately not done. + */ + +/** Find the first stdout line that parses as a JSON object, or undefined. */ +function firstJsonLine(stdout: string): Record | undefined { + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) continue + try { + return JSON.parse(trimmed) as Record + } catch { + // not JSON — keep scanning + } + } + return undefined +} + +describe('stash env — non-interactive name resolution', () => { + it('exits 1 (no hang, no mint) in a non-TTY context with no --name', async () => { + const r = await runPiped(['env'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + expect(r.stdout + r.stderr).toContain(messages.env.missingName) + // The actionable fix is named. + expect(r.stdout + r.stderr).toContain('--name') + }) + + it('--json with no --name emits a JSON missing_name error and exits 1', async () => { + const r = await runPiped(['env', '--json'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const payload = firstJsonLine(r.stdout) + expect(payload).toMatchObject({ status: 'error', code: 'missing_name' }) + }) +}) diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index de04c8d3..142af28a 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -484,15 +484,48 @@ Version-aware. For **EQL v2** columns in the `cut-over` phase it emits `ALTER TA Flags: `--table`, `--column`, `--migrations-dir `. -### Experimental +### Deployment #### `env` ```bash -stash env +stash env --name my-app-prod # print the four CS_* vars to stdout +stash env --name my-app-prod --write # write .env.production.local (mode 0600) +stash env --name edge-dev --json # single JSON object, no prompts ``` -**A stub — it does not work yet.** Gated behind `STASH_EXPERIMENTAL_ENV_CMD`, and even then the credential mint endpoint is not wired up, so it emits nothing. Intended to print the `CS_*` variables needed to deploy. Don't build on it. +Mints deployment credentials from the local device-code session (`stash auth +login`) — no dashboard copy-paste. It creates a fresh ZeroKMS client and a +CipherStash access key (both named `--name`), then emits the four env vars a +deployed app needs: + +``` +CS_WORKSPACE_CRN=crn:: +CS_CLIENT_ID= +CS_CLIENT_KEY= +CS_CLIENT_ACCESS_KEY=CSAK… +``` + +Things to know: + +- **The access key is shown exactly once** — CTS cannot re-reveal it. Pipe the + output straight into your secret store (`supabase secrets set --env-file`, + `vercel env add`, `wrangler secret put`, …). `CS_CLIENT_KEY` and + `CS_CLIENT_ACCESS_KEY` are secrets; never commit them. +- **The key is member-role, always.** The CLI deliberately cannot mint admin + keys — use the dashboard for those. *Creating* a key does, however, require + your own user to have the admin role in the workspace (403 otherwise). +- **Non-interactive runs require `--name`** — without it the command exits 1 + with an actionable message before touching the network. In `--json` mode + failures arrive as `{ status: "error", code, message }` on stdout. +- Each run mints a **new** credential; a duplicate name is rejected by the + server — rerun with a different `--name`. +- This is also the local-dev path for runtimes that can't reach + `~/.cipherstash` (Supabase Edge Functions run in a container; Workers have + no filesystem): mint a key, feed it via `--env-file` or the platform's + secret store, and use `@cipherstash/stack/wasm-inline` with explicit config. + +Flags: `--name `, `--write`, `--json`. ## Programmatic API diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 24292d26..b3904275 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -47,7 +47,12 @@ The Supabase integration ships as its own first-party package, agent-assisted flow — auth, schema, and database end to end) or `npx stash auth login` (device code flow; no environment variables needed). CI and production use the `CS_*` machine-credential environment variables — -see the `stash-encryption` skill's Configuration section. +see the `stash-encryption` skill's Configuration section. Mint them from your +device session with `npx stash env --name ` (no dashboard copy-paste); +this is also how **Supabase Edge Functions** get credentials in local dev — +`supabase functions serve` runs in a container that cannot see +`~/.cipherstash`, so write the vars to a file (`stash env --name edge-dev +--write`) and pass `--env-file`, or `supabase secrets set` them for deploys. ### 1. Install EQL v3 on the database From c33bf61ad5b5372e04c6a5859217323c42ac800f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 15:07:31 +1000 Subject: [PATCH 2/6] docs(skills): point stash-encryption's CI/prod config at `stash env` The Configuration section listed the four CS_* variables but left how to obtain them to the dashboard. Both the stash-cli and stash-supabase skills now document `stash env`; this closes the loop in the skill the other two reference for configuration. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- skills/stash-encryption/SKILL.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index e6ea8381..80aae76c 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -123,6 +123,16 @@ CS_CLIENT_KEY=your-client-key CS_CLIENT_ACCESS_KEY=your-access-key ``` +Mint all four from your device session with `npx stash env --name ` — +no dashboard copy-paste. It creates a fresh ZeroKMS client plus a +**member-role** access key (shown exactly once; the CLI cannot mint admin +keys) and prints the block above, ready to pipe into your platform's secret +store. `CS_CLIENT_KEY` and `CS_CLIENT_ACCESS_KEY` are secrets — never commit +them. Also the path for runtimes that can't read `~/.cipherstash` — e.g. +`@cipherstash/stack/wasm-inline` on Supabase Edge Functions (containerised +even in local dev) or Cloudflare Workers. See the `stash-cli` skill for flags +(`--write`, `--json`). + When both are present, the `CS_*` variables take precedence over the saved profile. From 3b83a37db05d9493d5f93bebee02a8f4fce76a6f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 15:15:07 +1000 Subject: [PATCH 3/6] =?UTF-8?q?docs(skills):=20agent-driven=20`auth=20logi?= =?UTF-8?q?n`=20=E2=80=94=20the=20operational=20loop,=20live-verified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the flow for real (agent starts `auth login --json --region us-west-2` backgrounded, human approves the relayed URL): authorization_required → authorized → device_bound, exit 0, session verified via getToken. The event table in stash-cli was already accurate; what was missing was the part that bites an agent — the command blocks polling for up to ~15 minutes, so it must run as a background task with a generous timeout, and success is confirmed from the event stream (never by inspecting ~/.cipherstash). stash-encryption's Local Development section now points agents at the flow. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- skills/stash-cli/SKILL.md | 16 ++++++++++++++++ skills/stash-encryption/SKILL.md | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 142af28a..d03835ea 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -82,6 +82,22 @@ npx stash auth login --json --region us-east-1 | `{ status: "device_bound" }` | Device bound to the default keyset. Done. | | `{ status: "error", code, message }` | Failure. Exit code 1. | +Operationally: after printing `authorization_required` the command **blocks, +polling, until the human approves or the code expires** (`expiresIn` is +~900 s). So run it as a background/async task with a generous timeout — +a short-timeout synchronous run kills the poll and the login never lands. +The working loop is: + +1. Start `npx stash auth login --json --region ` in the background. +2. Read the first stdout line; relay `verificationUriComplete` to the human + (include `userCode` so they can cross-check what they're approving, and + mention the ~15-minute expiry). +3. Leave the process running. Success is **exit 0 with `device_bound` as the + final event** — the session and development key are then in the profile + and every later command authenticates silently. +4. To confirm, trust the event stream or run any authenticated command — + never inspect `~/.cipherstash` (see "Never read these"). + **Authenticate before `stash init`.** Init's authenticate step uses the interactive path, so an agent running `init` unauthenticated makes the CLI try to open a browser on the agent's machine — and in a non-TTY it exits with `region_required` unless `--region` or `STASH_REGION` is set. Once a valid token exists, init logs `Using workspace X (region)` and moves on silently. Flags: `--region ` (env `STASH_REGION`), `--json`, `--no-open`, `--supabase` / `--drizzle` (referrer tracking only). diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 80aae76c..5cfe9146 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -111,6 +111,12 @@ already-initialized project, `npx stash auth login` alone authenticates the machine; the SDK and CLI pick up the saved profile automatically. Sign up at [cipherstash.com/signup](https://cipherstash.com/signup) first. +Agents can drive the login too: `npx stash auth login --json --region ` +emits the verification URL as data — run it in the background, relay the URL +to the human, and the flow completes when they approve in a browser. The +event contract and the operational loop are in the `stash-cli` skill's +Authentication section. + ### CI and Production (environment variables) Deployed environments and CI use machine credentials via environment variables From 5e0bb31ed88fa04b2d670ee8b48d4ac90c934774 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 15:41:42 +1000 Subject: [PATCH 4/6] =?UTF-8?q?fix(cli):=20harden=20`stash=20env`=20?= =?UTF-8?q?=E2=80=94=20all=2010=20findings=20from=20the=20branch=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An 8-angle adversarial review of this branch confirmed 10 defects (plus one skill nit); this commit fixes all of them: 1. --write's overwrite decision (refusal AND interactive confirm) now runs BEFORE minting — a declined overwrite previously discarded a live, shown-exactly-once access key, contradicting the file's own contract. 2. Stdout is now actually pipe-clean: every clack call routes chrome to stderr via { output: process.stderr }, so `stash env > prod.env` and pipes into dotenv consumers capture only the block. (clack defaults everything to stdout — the old comment's claim was wrong.) 3. This also fixes prompts rendering into redirected stdout (isInteractive is stdin-only): prompts on stderr stay visible on the terminal. 4. `--write ` is now a feature, not a parse trap: parseArgs put the path in values.write leaving flags.write undefined, silently printing secrets to stdout. main.ts passes values.write ?? flags.write and the command accepts a target path. 5. `--name` followed by another flag gets its own name_requires_value diagnostic instead of a false missing_name. 6. `stash env ` is rejected with did-you-mean --name guidance instead of silently vanishing into the subcommand slot. 7. --json + --write now compose: the file is written and the JSON confirmation ({ status: 'written' }) is deliberately secret-free. 8. 0600 is enforced on overwrite too (writeFileSync's mode only applies on creation) via an explicit chmod. 9. All three API responses are zod-validated; non-base64 client_key is refused before Node's lenient decoder can transcode garbage; a service shape change can no longer print undefined into a credentials file. 10. The member role is pinned in the request (wire value verified lowercase in cts-common) AND asserted on the response — a non-member key is refused with revocation guidance, so the documented 'cannot mint admin keys' invariant is enforced, not assumed. 11. All exits go through CliExit (never deep process.exit), so run() records env outcomes for telemetry; cancel paths use CliExit(0). The three divergent dead-code exit trailers are gone. 12. skills/stash-cli: the bare --env-file mention is attributed to `supabase functions serve`, per the manifest-resolution rule. Tests: 21 env unit tests (preflight-before-mint asserted via zero fetch calls; chrome-on-stderr; role pinning; validation refusals; json+write secret-free event; chmod-on-overwrite) and 4 e2e cases (all credential-free pre-mint failures, including asserting error chrome lands on stderr, not stdout). Full suite: 534 unit / 62 e2e green, code:check exit 0. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/stash-env-mint-credentials.md | 21 +- packages/cli/src/bin/main.ts | 10 +- packages/cli/src/cli/registry.ts | 10 +- .../src/commands/env/__tests__/env.test.ts | 241 +++++++++++-- packages/cli/src/commands/env/index.ts | 321 +++++++++++++----- packages/cli/src/messages.ts | 8 +- .../tests/e2e/env-non-interactive.e2e.test.ts | 39 ++- skills/stash-cli/SKILL.md | 29 +- 8 files changed, 525 insertions(+), 154 deletions(-) diff --git a/.changeset/stash-env-mint-credentials.md b/.changeset/stash-env-mint-credentials.md index 599cc0f7..99e1004a 100644 --- a/.changeset/stash-env-mint-credentials.md +++ b/.changeset/stash-env-mint-credentials.md @@ -5,15 +5,24 @@ `stash env` now works: it mints deployment credentials from your device-code session and prints them as env vars — no dashboard copy-paste. The command creates a fresh ZeroKMS client and a member-role CipherStash access key (named -via `--name`), then emits `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, -and `CS_CLIENT_ACCESS_KEY` to stdout, `--write` (`.env.production.local`, mode -0600), or `--json`. Creating access keys requires the admin role in the -workspace; the minted key itself is always member — the CLI deliberately -cannot mint admin keys. +via `--name`; the role is pinned in the request and verified on the response — +the CLI deliberately cannot mint admin keys), then emits `CS_WORKSPACE_CRN`, +`CS_CLIENT_ID`, `CS_CLIENT_KEY`, and `CS_CLIENT_ACCESS_KEY`. + +Output goes to stdout by default — and stdout is pipe-clean (progress UI is on +stderr), so `stash env --name x > prod.env` and pipes into secret stores are +safe. `--write [path]` writes a file instead (default `.env.production.local`, +enforced mode 0600 even when overwriting), confirming before overwriting and +refusing non-interactively — always *before* anything is minted, so a refusal +never discards the shown-exactly-once access key. `--json` emits NDJSON; with +`--write` the confirmation event is deliberately secret-free. API responses +are schema-validated so a service change can never print `undefined` into a +credentials file. Creating access keys requires the admin role in the +workspace. This is also the supported credential path for WASM/edge local development (Supabase Edge Functions, Cloudflare Workers, Deno), where the runtime cannot read the `~/.cipherstash` device profile: mint a key and feed it via -`--env-file` or the platform's secret store. +`supabase functions serve --env-file` or the platform's secret store. The `STASH_EXPERIMENTAL_ENV_CMD` gate is removed. diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 38c6992e..b7703df7 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -533,9 +533,17 @@ async function dispatch( break case 'env': await envCommand({ - write: flags.write, + // parseArgs puts `--write path/x` in values and bare `--write` in + // flags — accept both so a path after --write targets that file + // instead of silently printing secrets to stdout. + write: values.write ?? flags.write, json: flags.json, name: values.name, + // `--name` followed by another flag (or nothing) is booleanised by + // parseArgs; surface it as its own error instead of missing_name. + nameMissingValue: flags.name === true, + // `stash env my-app` would otherwise vanish into `subcommand`. + unexpectedArg: subcommand ?? commandArgs[0], }) break case 'manifest': diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index e0a6a831..ce64a201 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -545,10 +545,15 @@ export const registry: CommandGroup[] = [ 'admin keys) and is shown exactly once — pipe the output into your', 'deployment secret store. Creating access keys requires your user to', 'have the admin role in the workspace.', + '', + 'Stdout carries only the dotenv block (or the --json events);', + 'progress UI goes to stderr, so `stash env --name x > prod.env`', + 'and pipes into secret stores are safe.', ].join('\n'), examples: [ 'env --name my-app-prod', 'env --name my-app-prod --write', + 'env --name staging --write .env.staging.local', 'env --name edge-dev --json', ], flags: [ @@ -560,13 +565,14 @@ export const registry: CommandGroup[] = [ }, { name: '--write', + value: '[path]', description: - 'Write the vars to .env.production.local (mode 0600) instead of printing them.', + 'Write the vars to a file (default .env.production.local, mode 0600) instead of printing them. An existing file prompts before overwriting — and is refused non-interactively — before anything is minted.', }, { name: '--json', description: - 'Emit a single machine-readable JSON object (or a { status: "error" } envelope) instead of a dotenv block. Implies no prompts.', + 'Emit machine-readable NDJSON (a { status: "minted" } object, or { status: "written" } with --write — deliberately secret-free since the secrets are in the file; failures are { status: "error" }). Implies no prompts.', }, ], }, diff --git a/packages/cli/src/commands/env/__tests__/env.test.ts b/packages/cli/src/commands/env/__tests__/env.test.ts index b8b0c90e..a1f2fd2b 100644 --- a/packages/cli/src/commands/env/__tests__/env.test.ts +++ b/packages/cli/src/commands/env/__tests__/env.test.ts @@ -9,6 +9,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CliExit } from '../../../cli/exit.js' import { messages } from '../../../messages.js' // env/index.ts imports the native `@cipherstash/auth` binary — replace it @@ -47,11 +48,19 @@ vi.mock('../../init/utils.js', () => ({ const { envCommand } = await import('../index.js') -/** Sentinel thrown by the stubbed `process.exit` so tests can assert on it. */ -class ExitSignal extends Error { - constructor(readonly code: number | undefined) { - super(`exit ${code}`) +/** Run envCommand and return the CliExit it threw (fails if it didn't). */ +async function expectExit( + promise: Promise, + code: number, +): Promise { + try { + await promise + } catch (err) { + expect(err).toBeInstanceOf(CliExit) + expect((err as CliExit).code).toBe(code) + return err as CliExit } + throw new Error(`expected CliExit(${code}), but the command returned`) } /** A happy-path device session: fromProfile → getToken → token data. */ @@ -136,13 +145,9 @@ function stubFetch( return fetchSpy } -let exitSpy: ReturnType let logSpy: ReturnType beforeEach(() => { - exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new ExitSignal(code) - }) as never) logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) }) @@ -158,22 +163,45 @@ function stdout(): string { return logSpy.mock.calls.map((c) => c.join(' ')).join('\n') } -describe('envCommand — name resolution', () => { +/** The most recent p.log.error message (first arg; second is the stderr chrome opts). */ +function lastError(): string { + return String(clack.log.error.mock.calls.at(-1)?.[0]) +} + +describe('envCommand — pre-mint argv failures (all credential-free)', () => { it('fails non-interactively without --name, before touching the profile', async () => { - await expect(envCommand({})).rejects.toThrow(ExitSignal) - expect(exitSpy).toHaveBeenCalledWith(1) + await expectExit(envCommand({}), 1) expect(clack.log.error).toHaveBeenCalledWith( expect.stringContaining(messages.env.missingName), + expect.anything(), ) // The failure must be reachable with no profile and no network. expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() }) it('emits the error envelope on the --json stream', async () => { - await expect(envCommand({ json: true })).rejects.toThrow(ExitSignal) + await expectExit(envCommand({ json: true }), 1) const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) expect(event).toMatchObject({ status: 'error', code: 'missing_name' }) }) + + it('rejects a valueless --name with its own error, not missing_name', async () => { + await expectExit(envCommand({ nameMissingValue: true, json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ + status: 'error', + code: 'name_requires_value', + }) + expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() + }) + + it('rejects a stray positional with did-you-mean guidance', async () => { + await expectExit(envCommand({ unexpectedArg: 'my-app-prod' }), 1) + const message = lastError() + expect(message).toContain(messages.env.unexpectedArgument) + expect(message).toContain('--name my-app-prod') + expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() + }) }) describe('envCommand — happy path', () => { @@ -192,6 +220,9 @@ describe('envCommand — happy path', () => { expect(block).toContain('Do not commit') // The bearer token must never reach stdout. expect(block).not.toContain('test-bearer-token') + // Stdout is pipe-clean: exactly one console.log — the block itself. + // (All chrome goes through the mocked clack, which routes to stderr.) + expect(logSpy).toHaveBeenCalledTimes(1) // Ordering contract: client BEFORE access key (partial failure leaves an // inert client, never an unaccounted-for live credential). @@ -200,8 +231,8 @@ describe('envCommand — happy path', () => { urls.findIndex((u) => u.includes('/api/access-keys')), ) - // Wire details: bearer auth everywhere; role omitted (server defaults to - // member); trailing slashes normalised (no `//` in paths). + // Wire details: bearer auth everywhere; the member role is pinned in the + // request; trailing slashes normalised (no `//` in paths). for (const [url, init] of fetchSpy.mock.calls as [string, RequestInit][]) { expect((init.headers as Record).authorization).toBe( 'Bearer test-bearer-token', @@ -212,8 +243,31 @@ describe('envCommand — happy path', () => { String(c[0]).includes('/api/access-keys'), ) as [string, RequestInit] const body = JSON.parse(String(accessKeyCall[1].body)) - expect(body).toEqual({ keyName: 'my-app-prod', workspaceId: 'WS123' }) - expect(body).not.toHaveProperty('role') + expect(body).toEqual({ + keyName: 'my-app-prod', + workspaceId: 'WS123', + role: 'member', + }) + }) + + it('routes all chrome to stderr', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'my-app-prod' }) + + const stderrOpts = { output: process.stderr } + expect(clack.intro).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining(stderrOpts), + ) + expect(clack.spinner).toHaveBeenCalledWith( + expect.objectContaining(stderrOpts), + ) + expect(clack.outro).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining(stderrOpts), + ) }) it('emits a single minted object in --json mode', async () => { @@ -242,12 +296,14 @@ describe('envCommand — failure modes', () => { }) stubFetch() - await expect(envCommand({ name: 'x' })).rejects.toThrow(ExitSignal) + await expectExit(envCommand({ name: 'x' }), 1) expect(clack.log.error).toHaveBeenCalledWith( expect.stringContaining('Not logged in'), + expect.anything(), ) expect(clack.log.info).toHaveBeenCalledWith( expect.stringContaining('npx stash auth login'), + expect.anything(), ) }) @@ -255,10 +311,8 @@ describe('envCommand — failure modes', () => { stubSession() stubFetch({ accessKey: new Response('forbidden', { status: 403 }) }) - await expect(envCommand({ name: 'my-app-prod' })).rejects.toThrow( - ExitSignal, - ) - const message = String(clack.log.error.mock.calls.at(-1)?.[0]) + await expectExit(envCommand({ name: 'my-app-prod' }), 1) + const message = lastError() expect(message).toContain('admin') expect(message).toContain("ZeroKMS client 'my-app-prod'") }) @@ -271,8 +325,8 @@ describe('envCommand — failure modes', () => { }), }) - await expect(envCommand({ name: 'taken' })).rejects.toThrow(ExitSignal) - const message = String(clack.log.error.mock.calls.at(-1)?.[0]) + await expectExit(envCommand({ name: 'taken' }), 1) + const message = lastError() expect(message).toContain('Duplicate key error') expect(message).toContain('--name') }) @@ -281,8 +335,64 @@ describe('envCommand — failure modes', () => { stubSession({ workspaceId: 'GONE' }) stubFetch() - await expect(envCommand({ name: 'x' })).rejects.toThrow(ExitSignal) - expect(String(clack.log.error.mock.calls.at(-1)?.[0])).toContain('GONE') + await expectExit(envCommand({ name: 'x' }), 1) + expect(lastError()).toContain('GONE') + }) +}) + +describe('envCommand — response validation (nothing minted may print undefined)', () => { + it('rejects a workspace entry with no region instead of emitting crn:undefined', async () => { + stubSession() + stubFetch({ + workspaces: Response.json([{ id: 'WS123', name: 'mine' }]), + }) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ + status: 'error', + code: 'unexpected_response', + }) + expect(String(event.message)).toContain('region') + }) + + it('rejects an access-key response with no accessKey field', async () => { + stubSession() + stubFetch({ accessKey: Response.json({ ok: true }, { status: 201 }) }) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ + status: 'error', + code: 'unexpected_response', + }) + }) + + it('refuses to transcode non-base64 client key material', async () => { + stubSession() + stubFetch({ + client: Response.json({ id: 'c1', client_key: 'not!!valid==' }), + }) + + await expectExit(envCommand({ name: 'x' }), 1) + expect(lastError()).toContain('unexpected encoding') + }) + + it('refuses to emit an access key whose returned role is not member', async () => { + stubSession() + stubFetch({ + accessKey: Response.json( + { accessKey: 'CSAKTid.secret', role: 'admin' }, + { status: 201 }, + ), + }) + + await expectExit(envCommand({ name: 'privileged' }), 1) + const message = lastError() + expect(message).toContain("'admin'") + expect(message).toContain("Revoke 'privileged'") + // The over-privileged secret itself must not be printed anywhere. + expect(stdout()).not.toContain('CSAKTid.secret') }) }) @@ -293,10 +403,6 @@ describe('envCommand — --write', () => { vi.spyOn(process, 'cwd').mockReturnValue(dir) }) afterEach(() => { - // The file is written 0600; ensure cleanup can always delete it. - try { - chmodSync(join(dir, '.env.production.local'), 0o600) - } catch {} rmSync(dir, { recursive: true, force: true }) }) @@ -314,19 +420,84 @@ describe('envCommand — --write', () => { expect(stdout()).not.toContain('CS_CLIENT_ACCESS_KEY') }) - it('refuses to overwrite an existing file non-interactively', async () => { + it('honours a custom path passed as the --write value', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'staging', write: '.env.staging.local' }) + + const target = join(dir, '.env.staging.local') + expect(readFileSync(target, 'utf-8')).toContain('CS_CLIENT_ACCESS_KEY=') + expect(statSync(target).mode & 0o777).toBe(0o600) + }) + + it('re-applies 0600 when overwriting an existing permissive file', async () => { stubSession() stubFetch() + const target = join(dir, '.env.production.local') + writeFileSync(target, 'OLD=1') + chmodSync(target, 0o644) + tty.isInteractive.mockReturnValue(true) + clack.confirm.mockResolvedValue(true) + + await envCommand({ name: 'x', write: true }) + + expect(readFileSync(target, 'utf-8')).toContain('CS_CLIENT_ACCESS_KEY=') + expect(statSync(target).mode & 0o777).toBe(0o600) + }) + + it('refuses an existing file non-interactively BEFORE minting anything', async () => { + stubSession() + const fetchSpy = stubFetch() writeFileSync(join(dir, '.env.production.local'), 'existing') - await expect(envCommand({ name: 'x', write: true })).rejects.toThrow( - ExitSignal, - ) + await expectExit(envCommand({ name: 'x', write: true }), 1) expect(readFileSync(join(dir, '.env.production.local'), 'utf-8')).toBe( 'existing', ) - expect(String(clack.log.error.mock.calls.at(-1)?.[0])).toContain( - 'refusing to overwrite', + expect(lastError()).toContain('refusing to overwrite') + // The load-bearing bit: the refusal happened with ZERO server state + // created — no fetch, no client, no orphaned shown-once access key. + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('declining the interactive overwrite aborts BEFORE minting, exit 0', async () => { + stubSession() + const fetchSpy = stubFetch() + writeFileSync(join(dir, '.env.production.local'), 'existing') + tty.isInteractive.mockReturnValue(true) + clack.confirm.mockResolvedValue(false) + + await expectExit(envCommand({ name: 'x', write: true }), 0) + expect(fetchSpy).not.toHaveBeenCalled() + expect(clack.cancel).toHaveBeenCalledWith( + expect.stringContaining('nothing was minted'), + expect.anything(), ) }) + + it('--json --write writes the file and emits a secret-free confirmation', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'edge-dev', write: true, json: true }) + + const target = join(dir, '.env.production.local') + expect(readFileSync(target, 'utf-8')).toContain( + 'CS_CLIENT_ACCESS_KEY=CSAKTkeyid.keysecret', + ) + expect(logSpy).toHaveBeenCalledTimes(1) + const event = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(event).toEqual({ + status: 'written', + path: target, + keyName: 'edge-dev', + workspaceCrn: 'crn:ap-southeast-2.aws:WS123', + clientId: 'client-uuid-1', + }) + // The secrets live in the 0600 file only — never on the JSON stream. + const raw = logSpy.mock.calls[0][0] as string + expect(raw).not.toContain('CSAKTkeyid.keysecret') + expect(raw).not.toContain('00010203') + }) }) diff --git a/packages/cli/src/commands/env/index.ts b/packages/cli/src/commands/env/index.ts index 586d1baa..cc38bbd3 100644 --- a/packages/cli/src/commands/env/index.ts +++ b/packages/cli/src/commands/env/index.ts @@ -1,7 +1,9 @@ -import { existsSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, writeFileSync } from 'node:fs' import { basename, resolve } from 'node:path' import auth from '@cipherstash/auth' import * as p from '@clack/prompts' +import { z } from 'zod' +import { CliExit } from '../../cli/exit.js' import { isInteractive } from '../../config/tty.js' import { messages } from '../../messages.js' import { emitJsonError, emitJsonEvent } from '../auth/events.js' @@ -9,12 +11,28 @@ import { detectPackageManager, runnerCommand } from '../init/utils.js' const { DeviceSessionStrategy } = auth +/** + * All human-facing chrome (intro, spinner, prompts, logs) is routed to + * STDERR. Stdout carries exactly one thing: the dotenv block (or, with + * `--json`, the NDJSON events) — so `stash env > prod.env` and pipes into + * dotenv consumers stay clean, and prompts remain visible on the terminal + * even when stdout is redirected. + */ +const CHROME = { output: process.stderr } as const + export interface EnvOptions { - /** Write the emitted block to `.env.production.local` instead of stdout. */ - write?: boolean + /** + * Write the emitted block to a file instead of stdout. `true` uses the + * default `.env.production.local`; a string names the target path. + */ + write?: boolean | string /** Name for the minted access key + ZeroKMS client. Required non-interactively. */ name?: string - /** Emit a single JSON object instead of a dotenv block. Implies no prompts. */ + /** True when `--name` was passed with no value (argv put it in the boolean flags). */ + nameMissingValue?: boolean + /** A stray positional argument (e.g. `stash env my-app`) — rejected with guidance. */ + unexpectedArg?: string + /** Emit NDJSON events instead of a dotenv block. Implies no prompts. */ json?: boolean } @@ -29,37 +47,34 @@ export interface EnvOptions { * 2. `POST {zerokms}/create-client` → `CS_CLIENT_ID` / `CS_CLIENT_KEY` * 3. `POST {cts}/api/access-keys` → `CS_CLIENT_ACCESS_KEY` * - * The access key is minted with the server-default **member** role — the CLI - * deliberately has no `--role`: a runtime credential never needs more, and - * admin keys should be minted in the dashboard where they're visible. The - * key is returned exactly once by CTS; we print it and never persist it - * (except via an explicit `--write`). + * The access key is minted with the **member** role — pinned in the request + * AND asserted on the response; the CLI deliberately has no `--role` (admin + * keys belong in the dashboard, where they're visible). The key is returned + * exactly once by CTS; we print it and never persist it (except via an + * explicit `--write`). * - * Ordering is deliberate: the ZeroKMS client is created *before* the access - * key, so the only partial-failure leftover is an inert client record — - * never an unaccounted-for live credential. + * Ordering is deliberate, and everything that can refuse without server + * state does so BEFORE minting: argv problems, the key name, and the + * `--write` overwrite decision are all resolved first, so a refusal never + * discards a minted credential. Within the mint, the ZeroKMS client is + * created before the access key, so the only partial-failure leftover is an + * inert client record — never an unaccounted-for live credential. + * + * Exits via {@link CliExit} (never deep `process.exit`) so run() records the + * outcome for telemetry. */ export async function envCommand(options: EnvOptions = {}): Promise { const json = options.json ?? false const runner = runnerCommand(detectPackageManager(), '').trim() const cliRef = `${runner} stash` - if (!json) p.intro(`${cliRef} env`) - - // Resolve the key name BEFORE touching the profile or the network: the - // non-interactive missing-name failure must be reachable without - // credentials (it is what the e2e suite exercises), and an early exit - // here can never leave partial server-side state behind. - const keyName = await resolveKeyName(options, json, cliRef) + if (!json) p.intro(`${cliRef} env`, CHROME) - const s = json ? null : p.spinner() - s?.start('Minting deployment credentials...') - - let creds: MintedCredentials try { - creds = await mintCredentials(keyName) + await runEnv(options, json, cliRef) } catch (err) { - s?.stop('Could not mint deployment credentials.') + // CliExit(0) from a cancel path — already rendered; just unwind. + if (err instanceof CliExit) throw err const failure = err instanceof MintError ? err @@ -70,60 +85,90 @@ export async function envCommand(options: EnvOptions = {}): Promise { if (json) { emitJsonError(failure.code, failure.message) } else { - p.log.error(failure.message) - if (failure.hint) p.log.info(failure.hint.replaceAll('{cli}', cliRef)) + p.log.error(failure.message, CHROME) + if (failure.hint) { + p.log.info(failure.hint.replaceAll('{cli}', cliRef), CHROME) + } } - process.exit(1) - return // unreachable; keeps control flow explicit for tests that stub exit + throw new CliExit(1) } +} - s?.stop('Deployment credentials minted.') +async function runEnv( + options: EnvOptions, + json: boolean, + cliRef: string, +): Promise { + // Everything refusable without server state fails BEFORE minting: argv + // shape, the key name, and the --write overwrite decision. This ordering + // is load-bearing — the access key is shown exactly once, so no local + // refusal may run after it exists (and it keeps the failure paths + // credential-free, which is what the e2e suite exercises). + if (options.unexpectedArg) { + throw new MintError( + 'unexpected_argument', + `${messages.env.unexpectedArgument} '${options.unexpectedArg}' — pass the credential name with --name (e.g. \`${cliRef} env --name ${options.unexpectedArg}\`).`, + ) + } + if (options.nameMissingValue) { + throw new MintError( + 'name_requires_value', + `${messages.env.nameRequiresValue} — e.g. \`${cliRef} env --name my-app-prod\`.`, + ) + } - if (json) { - emitJsonEvent({ status: 'minted', ...creds }) - return + const keyName = await resolveKeyName(options, json, cliRef) + const writeTarget = await resolveWriteTarget(options, json) + + const s = json ? null : p.spinner(CHROME) + s?.start('Minting deployment credentials...') + let creds: MintedCredentials + try { + creds = await mintCredentials(keyName) + } catch (err) { + s?.stop('Could not mint deployment credentials.') + throw err } + s?.stop('Deployment credentials minted.') const block = formatEnvBlock(creds, cliRef) - if (options.write) { - const target = resolve(process.cwd(), '.env.production.local') - if (existsSync(target)) { - if (!isInteractive()) { - p.log.error( - `${target} already exists — refusing to overwrite non-interactively. Remove it first, or run without --write and redirect the output yourself.`, - ) - process.exit(1) - return - } - const overwrite = await p.confirm({ - message: `${target} already exists. Overwrite?`, - initialValue: false, + if (writeTarget) { + writeEnvFile(writeTarget, block) + if (json) { + // Deliberately secret-free: the secrets are in the 0600 file, so the + // machine-readable confirmation never lands them in a captured log. + emitJsonEvent({ + status: 'written', + path: writeTarget, + keyName: creds.keyName, + workspaceCrn: creds.workspaceCrn, + clientId: creds.clientId, }) - if (p.isCancel(overwrite) || !overwrite) { - p.cancel('Aborted.') - return - } + return } - - // 0600: the file holds two live secrets. - writeFileSync(target, block, { encoding: 'utf-8', mode: 0o600 }) - p.log.success(`Wrote ${target}`) + p.log.success(`Wrote ${writeTarget}`, CHROME) p.log.warn( 'This file contains live secrets — keep it out of version control.', + CHROME, ) - p.outro('Done!') + p.outro('Done!', CHROME) return } - // Default: print to stdout so users can pipe into secret stores / CI env. - // Use `console.log` (not `p.*`) so the output is clean for redirection. + if (json) { + emitJsonEvent({ status: 'minted', ...creds }) + return + } + + // Stdout carries the block and nothing else (chrome is on stderr), so + // users can redirect or pipe it straight into a secret store. console.log(block) - p.outro('Done!') + p.outro('Done!', CHROME) } // --------------------------------------------------------------------------- -// Key-name resolution +// Pre-mint resolution: key name and --write target // --------------------------------------------------------------------------- async function resolveKeyName( @@ -135,33 +180,60 @@ async function resolveKeyName( if (explicit) return explicit if (json || !isInteractive()) { - const message = `${messages.env.missingName} — pass --name (e.g. \`${cliRef} env --name my-app-prod\`).` - if (json) { - emitJsonError('missing_name', message) - } else { - p.log.error(message) - } - process.exit(1) - // process.exit is stubbed in unit tests; throw so the command can't - // continue with an undefined name there. - throw new MintError('missing_name', message) + throw new MintError( + 'missing_name', + `${messages.env.missingName} — pass --name (e.g. \`${cliRef} env --name my-app-prod\`).`, + ) } - const suggested = suggestKeyName() const answer = await p.text({ message: 'Name for this deployment credential', - initialValue: suggested, + initialValue: suggestKeyName(), validate: (value) => value.trim().length === 0 ? 'A name is required.' : undefined, + ...CHROME, }) if (p.isCancel(answer)) { - p.cancel('Cancelled.') - process.exit(0) - throw new MintError('cancelled', 'Cancelled.') + p.cancel('Cancelled.', CHROME) + throw new CliExit(0) } return answer.trim() } +/** + * Resolve and PREFLIGHT the `--write` target before anything is minted: a + * refused or declined overwrite must never discard a shown-exactly-once + * credential. + */ +async function resolveWriteTarget( + options: EnvOptions, + json: boolean, +): Promise { + if (!options.write) return null + const target = resolve( + process.cwd(), + typeof options.write === 'string' ? options.write : '.env.production.local', + ) + if (!existsSync(target)) return target + + if (json || !isInteractive()) { + throw new MintError( + 'write_conflict', + `${target} already exists — refusing to overwrite non-interactively. Remove it first, or pass a different path to --write.`, + ) + } + const overwrite = await p.confirm({ + message: `${target} already exists. Overwrite?`, + initialValue: false, + ...CHROME, + }) + if (p.isCancel(overwrite) || !overwrite) { + p.cancel('Aborted — nothing was minted.', CHROME) + throw new CliExit(0) + } + return target +} + /** Default key name: the project directory, sanitised, e.g. `my-app-prod`. */ function suggestKeyName(): string { const dir = basename(process.cwd()) @@ -171,6 +243,17 @@ function suggestKeyName(): string { return `${dir || 'app'}-prod` } +/** + * Write the dotenv block with owner-only permissions. `writeFileSync`'s + * `mode` only applies when the file is CREATED, so an overwrite of an + * existing (possibly 0644) file must be followed by an explicit chmod for + * the documented 0600 guarantee to hold. + */ +function writeEnvFile(target: string, block: string): void { + writeFileSync(target, block, { encoding: 'utf-8', mode: 0o600 }) + chmodSync(target, 0o600) +} + // --------------------------------------------------------------------------- // Credential minting // --------------------------------------------------------------------------- @@ -202,10 +285,33 @@ class MintError extends Error { const LOGIN_HINT = 'Run `{cli} auth login` and try again.' -/** Shape of one workspace in `GET /api/workspaces` (cts-web `UserWorkspace`). */ -interface UserWorkspace { - id: string - region: string +// The API responses cross an ownership boundary — CTS/ZeroKMS version these +// shapes, not this repo — so each one is validated before any field reaches +// the emitted env block. An `as`-cast here would print `undefined` into a +// credentials file after a live, shown-exactly-once key was already minted. +const workspaceListSchema = z.array(z.object({ id: z.string() }).passthrough()) +const workspaceRegionSchema = z.object({ region: z.string().min(1) }) +const createClientSchema = z + .object({ id: z.string().min(1), client_key: z.string().min(1) }) + .passthrough() +const accessKeySchema = z + .object({ accessKey: z.string().min(1), role: z.string().optional() }) + .passthrough() + +/** Standard padded base64 — rejected BEFORE Node's lenient decoder can turn + * a hex/enveloped/garbled key into plausible-looking wrong bytes. */ +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/ + +function parsed(schema: z.ZodType, data: unknown, what: string): T { + const result = schema.safeParse(data) + if (!result.success) { + const issue = result.error.issues[0] + throw new MintError( + 'unexpected_response', + `Unexpected ${what} response shape (${issue ? `${issue.path.join('.') || ''}: ${issue.message}` : 'invalid'}) — the service API may have changed; check for a newer CLI release.`, + ) + } + return result.data } async function mintCredentials(keyName: string): Promise { @@ -242,6 +348,8 @@ async function mintCredentials(keyName: string): Promise { // 2. Workspace region → CRN. The region in the workspace listing is // server-authoritative (CTS derives it from the workspace's host), so // this works for self-hosted CTS too — no issuer-hostname parsing. + // The list is validated loosely (only `id`), then OUR workspace + // strictly — an unrelated malformed entry must not block the mint. const wsResponse = await apiFetch(`${ctsBase}/api/workspaces`, token) if (!wsResponse.ok) { throw await httpError( @@ -250,7 +358,11 @@ async function mintCredentials(keyName: string): Promise { wsResponse, ) } - const workspaces = (await wsResponse.json()) as UserWorkspace[] + const workspaces = parsed( + workspaceListSchema, + await wsResponse.json(), + 'workspace list', + ) const workspace = workspaces.find((w) => w.id === workspaceId) if (!workspace) { throw new MintError( @@ -259,7 +371,8 @@ async function mintCredentials(keyName: string): Promise { LOGIN_HINT, ) } - const workspaceCrn = `crn:${workspace.region}:${workspaceId}` + const { region } = parsed(workspaceRegionSchema, workspace, 'workspace') + const workspaceCrn = `crn:${region}:${workspaceId}` // 3. ZeroKMS client — CS_CLIENT_ID / CS_CLIENT_KEY. Created BEFORE the // access key (see the ordering note in the command doc). @@ -277,20 +390,32 @@ async function mintCredentials(keyName: string): Promise { clientResponse, ) } - const client = (await clientResponse.json()) as { - id: string - client_key: string - } + const client = parsed( + createClientSchema, + await clientResponse.json(), + 'create-client', + ) // ZeroKMS returns the key material base64-encoded; emit hex, the historical // CS_CLIENT_KEY format that every released SDK accepts (base64 tolerance - // only landed in cipherstash-client 0.40). + // only landed in cipherstash-client 0.40). Node's base64 decoder is + // lenient, so the format is checked first — silently transcoding a + // non-base64 value would emit a plausible-looking but corrupt key. + if (!BASE64.test(client.client_key) || client.client_key.length % 4 !== 0) { + throw new MintError( + 'unexpected_response', + 'ZeroKMS returned client key material in an unexpected encoding — refusing to emit a possibly-corrupt CS_CLIENT_KEY. Check for a newer CLI release.', + ) + } const clientKey = Buffer.from(client.client_key, 'base64').toString('hex') - // 4. Access key — CS_CLIENT_ACCESS_KEY. Role deliberately omitted: CTS - // defaults to member, and the CLI does not mint anything stronger. + // 4. Access key — CS_CLIENT_ACCESS_KEY. The member role is pinned in the + // request (CTS also defaults to member) AND asserted on the response: + // the docs promise this command cannot mint anything stronger, so a + // server that returns a different role fails the command rather than + // silently handing out an over-privileged credential. const keyResponse = await apiFetch(`${ctsBase}/api/access-keys`, token, { method: 'POST', - body: JSON.stringify({ keyName, workspaceId }), + body: JSON.stringify({ keyName, workspaceId, role: 'member' }), }) if (!keyResponse.ok) { const leftover = ` (Note: the ZeroKMS client '${keyName}' was already created — it is inert without an access key, but you may want to remove it in the dashboard.)` @@ -310,9 +435,25 @@ async function mintCredentials(keyName: string): Promise { `${base.message} If the name is already taken, rerun with a different --name.${leftover}`, ) } - const { accessKey } = (await keyResponse.json()) as { accessKey: string } + const accessKeyBody = parsed( + accessKeySchema, + await keyResponse.json(), + 'access key', + ) + if (accessKeyBody.role && accessKeyBody.role.toLowerCase() !== 'member') { + throw new MintError( + 'unexpected_role', + `CTS returned a '${accessKeyBody.role}' access key where member was requested — refusing to emit it. Revoke '${keyName}' in the dashboard.`, + ) + } - return { keyName, workspaceCrn, clientId: client.id, clientKey, accessKey } + return { + keyName, + workspaceCrn, + clientId: client.id, + clientKey, + accessKey: accessKeyBody.accessKey, + } } function trimTrailingSlash(url: string): string { diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 2cda0a5b..b97c4090 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -111,11 +111,13 @@ export const messages = { }, env: { /** - * Stable leader of the non-interactive missing-name error. The e2e suite - * asserts on this; the `--name` hint (with the runner-aware invocation) - * is appended at the call site. + * Stable leaders of the pre-mint argv errors. The e2e suite asserts on + * these; the runner-aware hints are appended at the call sites. All + * three fire BEFORE any profile or network access. */ missingName: 'A credential name is required in non-interactive mode', + nameRequiresValue: '--name requires a value', + unexpectedArgument: 'Unexpected argument', }, telemetry: { /** diff --git a/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts b/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts index df01c176..733f94d2 100644 --- a/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts +++ b/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts @@ -3,11 +3,12 @@ import { messages } from '../../src/messages.js' import { runPiped } from '../helpers/spawn-piped.js' /** - * Non-interactive `stash env`. Only the missing-name failure is exercised: - * the command resolves the credential name BEFORE loading the device profile - * or touching the network, so these cases are deterministic, credential-free, - * and — critically — can never mint real keys on a developer machine that - * happens to have a live `~/.cipherstash` session. + * Non-interactive `stash env`. Only the pre-mint argv failures are exercised: + * the command resolves the credential name (and any argv problems) BEFORE + * loading the device profile or touching the network, so these cases are + * deterministic, credential-free, and — critically — can never mint real + * keys on a developer machine that happens to have a live `~/.cipherstash` + * session. * * The happy path (real CTS + ZeroKMS calls) is covered by unit tests with a * stubbed fetch; minting live credentials from CI is deliberately not done. @@ -27,14 +28,17 @@ function firstJsonLine(stdout: string): Record | undefined { return undefined } -describe('stash env — non-interactive name resolution', () => { +describe('stash env — non-interactive argv resolution', () => { it('exits 1 (no hang, no mint) in a non-TTY context with no --name', async () => { const r = await runPiped(['env'], { timeoutMs: 8000 }) expect(r.timedOut).toBe(false) expect(r.exitCode).toBe(1) - expect(r.stdout + r.stderr).toContain(messages.env.missingName) + expect(r.stderr).toContain(messages.env.missingName) // The actionable fix is named. - expect(r.stdout + r.stderr).toContain('--name') + expect(r.stderr).toContain('--name') + // Stdout is reserved for the dotenv block / JSON events — human error + // chrome must land on stderr so `stash env > file` can't capture it. + expect(r.stdout).not.toContain(messages.env.missingName) }) it('--json with no --name emits a JSON missing_name error and exits 1', async () => { @@ -44,4 +48,23 @@ describe('stash env — non-interactive name resolution', () => { const payload = firstJsonLine(r.stdout) expect(payload).toMatchObject({ status: 'error', code: 'missing_name' }) }) + + it('a valueless --name gets its own diagnostic, not missing_name', async () => { + const r = await runPiped(['env', '--name', '--json'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const payload = firstJsonLine(r.stdout) + expect(payload).toMatchObject({ + status: 'error', + code: 'name_requires_value', + }) + }) + + it('a stray positional is rejected with did-you-mean guidance', async () => { + const r = await runPiped(['env', 'my-app-prod'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + expect(r.stderr).toContain(messages.env.unexpectedArgument) + expect(r.stderr).toContain('--name my-app-prod') + }) }) diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index d03835ea..ae6cbaf0 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -507,7 +507,8 @@ Flags: `--table`, `--column`, `--migrations-dir `. ```bash stash env --name my-app-prod # print the four CS_* vars to stdout stash env --name my-app-prod --write # write .env.production.local (mode 0600) -stash env --name edge-dev --json # single JSON object, no prompts +stash env --name staging --write .env.staging.local # custom target path +stash env --name edge-dev --json # NDJSON events, no prompts ``` Mints deployment credentials from the local device-code session (`stash auth @@ -528,20 +529,30 @@ Things to know: output straight into your secret store (`supabase secrets set --env-file`, `vercel env add`, `wrangler secret put`, …). `CS_CLIENT_KEY` and `CS_CLIENT_ACCESS_KEY` are secrets; never commit them. -- **The key is member-role, always.** The CLI deliberately cannot mint admin - keys — use the dashboard for those. *Creating* a key does, however, require - your own user to have the admin role in the workspace (403 otherwise). +- **Stdout is pipe-clean.** Only the dotenv block (or the `--json` events) + goes to stdout; progress UI and prompts go to stderr. `stash env --name x + > prod.env` and pipes into dotenv consumers are safe. +- **The key is member-role, always** — pinned in the request and verified on + the response. The CLI deliberately cannot mint admin keys — use the + dashboard for those. *Creating* a key does, however, require your own user + to have the admin role in the workspace (403 otherwise). - **Non-interactive runs require `--name`** — without it the command exits 1 - with an actionable message before touching the network. In `--json` mode - failures arrive as `{ status: "error", code, message }` on stdout. + with an actionable message before touching the network, and `--write` + refuses to overwrite an existing file (also before anything is minted). + In `--json` mode failures arrive as `{ status: "error", code, message }` + on stdout. +- **`--json` + `--write` compose**: the file is written and the JSON + confirmation (`{ status: "written", path, … }`) is deliberately + secret-free, so captured CI logs never contain the key. - Each run mints a **new** credential; a duplicate name is rejected by the server — rerun with a different `--name`. - This is also the local-dev path for runtimes that can't reach `~/.cipherstash` (Supabase Edge Functions run in a container; Workers have - no filesystem): mint a key, feed it via `--env-file` or the platform's - secret store, and use `@cipherstash/stack/wasm-inline` with explicit config. + no filesystem): mint a key, feed it via `supabase functions serve + --env-file` or the platform's secret store, and use + `@cipherstash/stack/wasm-inline` with explicit config. -Flags: `--name `, `--write`, `--json`. +Flags: `--name `, `--write [path]`, `--json`. ## Programmatic API From 5dbe58ac9b48dea56ddeba4590a055d7a305cde7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 15:47:01 +1000 Subject: [PATCH 5/6] fix(cli): address PR #682 review comments (timeout, name validation, doc nits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apiFetch now carries a 30s AbortSignal.timeout; timeouts surface as request_timeout and connection failures as network_error (naming the host, and the undici cause rather than the opaque 'fetch failed'). A stalled CTS/ZeroKMS endpoint no longer hangs the CLI. (CodeRabbit) - The credential name rejects control characters (invalid_name) — a newline could break out of the dotenv comment line and inject a line into a written credentials file. Enforced pre-mint for both the flag and the interactive prompt. (Copilot) - skills/stash-cli: dotenv language on the output fence (MD040); skills/stash-supabase: keep the example command in one code span. Unit tests: 24 env tests (invalid_name pre-profile, request_timeout, network_error). Suite 537/62 green, code:check clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../src/commands/env/__tests__/env.test.ts | 39 +++++++++++++ packages/cli/src/commands/env/index.ts | 58 +++++++++++++++---- skills/stash-cli/SKILL.md | 2 +- skills/stash-supabase/SKILL.md | 5 +- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/env/__tests__/env.test.ts b/packages/cli/src/commands/env/__tests__/env.test.ts index a1f2fd2b..ae743d0e 100644 --- a/packages/cli/src/commands/env/__tests__/env.test.ts +++ b/packages/cli/src/commands/env/__tests__/env.test.ts @@ -195,6 +195,13 @@ describe('envCommand — pre-mint argv failures (all credential-free)', () => { expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() }) + it('rejects a name containing control characters before touching the profile', async () => { + await expectExit(envCommand({ name: 'bad\nCS_INJECTED=1', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ status: 'error', code: 'invalid_name' }) + expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() + }) + it('rejects a stray positional with did-you-mean guidance', async () => { await expectExit(envCommand({ unexpectedArg: 'my-app-prod' }), 1) const message = lastError() @@ -331,6 +338,38 @@ describe('envCommand — failure modes', () => { expect(message).toContain('--name') }) + it('maps a request timeout to a clear request_timeout error', async () => { + stubSession() + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new DOMException('The operation timed out.', 'TimeoutError') + }), + ) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ status: 'error', code: 'request_timeout' }) + expect(String(event.message)).toContain('cts.test') + }) + + it('maps a connection failure to a network_error naming the host', async () => { + stubSession() + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('fetch failed', { + cause: new Error('getaddrinfo ENOTFOUND cts.test'), + }) + }), + ) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ status: 'error', code: 'network_error' }) + expect(String(event.message)).toContain('ENOTFOUND') + }) + it('fails when the session workspace is missing from the workspace list', async () => { stubSession({ workspaceId: 'GONE' }) stubFetch() diff --git a/packages/cli/src/commands/env/index.ts b/packages/cli/src/commands/env/index.ts index cc38bbd3..74343c01 100644 --- a/packages/cli/src/commands/env/index.ts +++ b/packages/cli/src/commands/env/index.ts @@ -118,6 +118,15 @@ async function runEnv( } const keyName = await resolveKeyName(options, json, cliRef) + // The name lands in the emitted dotenv comment line, so a control + // character (esp. \n) could break out of the comment and inject a line + // into a credentials file — reject before it reaches anything. + if (CONTROL_CHARS.test(keyName)) { + throw new MintError( + 'invalid_name', + 'The credential name must not contain newlines or control characters.', + ) + } const writeTarget = await resolveWriteTarget(options, json) const s = json ? null : p.spinner(CHROME) @@ -189,8 +198,11 @@ async function resolveKeyName( const answer = await p.text({ message: 'Name for this deployment credential', initialValue: suggestKeyName(), - validate: (value) => - value.trim().length === 0 ? 'A name is required.' : undefined, + validate: (value) => { + if (value.trim().length === 0) return 'A name is required.' + if (CONTROL_CHARS.test(value)) return 'No control characters.' + return undefined + }, ...CHROME, }) if (p.isCancel(answer)) { @@ -302,6 +314,10 @@ const accessKeySchema = z * a hex/enveloped/garbled key into plausible-looking wrong bytes. */ const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/ +/** C0 controls + DEL — anything that could corrupt the emitted dotenv block. */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control chars is the point +const CONTROL_CHARS = /[\x00-\x1f\x7f]/ + function parsed(schema: z.ZodType, data: unknown, what: string): T { const result = schema.safeParse(data) if (!result.success) { @@ -460,20 +476,38 @@ function trimTrailingSlash(url: string): string { return url.replace(/\/+$/, '') } -function apiFetch( +/** Per-request deadline — a stalled CTS/ZeroKMS endpoint must not hang the CLI. */ +const REQUEST_TIMEOUT_MS = 30_000 + +async function apiFetch( url: string, token: string, init?: { method?: string; body?: string }, ): Promise { - return fetch(url, { - method: init?.method ?? 'GET', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - 'user-agent': 'stash-cli', - }, - body: init?.body, - }) + try { + return await fetch(url, { + method: init?.method ?? 'GET', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'user-agent': 'stash-cli', + }, + body: init?.body, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + } catch (err) { + const host = new URL(url).host + if (err instanceof DOMException && err.name === 'TimeoutError') { + throw new MintError( + 'request_timeout', + `Request to ${host} timed out after ${REQUEST_TIMEOUT_MS / 1000}s — check your network and try again.`, + ) + } + throw new MintError( + 'network_error', + `Could not reach ${host}: ${err instanceof Error ? (err.cause instanceof Error ? err.cause.message : err.message) : String(err)}`, + ) + } } /** Build a MintError from a non-2xx response, including the body (which CTS diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index ae6cbaf0..595c3db2 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -516,7 +516,7 @@ login`) — no dashboard copy-paste. It creates a fresh ZeroKMS client and a CipherStash access key (both named `--name`), then emits the four env vars a deployed app needs: -``` +```dotenv CS_WORKSPACE_CRN=crn:: CS_CLIENT_ID= CS_CLIENT_KEY= diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index b3904275..7eb38bd5 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -51,8 +51,9 @@ see the `stash-encryption` skill's Configuration section. Mint them from your device session with `npx stash env --name ` (no dashboard copy-paste); this is also how **Supabase Edge Functions** get credentials in local dev — `supabase functions serve` runs in a container that cannot see -`~/.cipherstash`, so write the vars to a file (`stash env --name edge-dev ---write`) and pass `--env-file`, or `supabase secrets set` them for deploys. +`~/.cipherstash`, so write the vars to a file with +`stash env --name edge-dev --write` and pass `--env-file`, or +`supabase secrets set` them for deploys. ### 1. Install EQL v3 on the database From f1efc2dabefc37c506419d5a36c6f153e470a857 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 17 Jul 2026 16:58:16 +1000 Subject: [PATCH 6/6] fix(cli): address James's review notes on stash env (#682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Note 2: treat an ABSENT access-key role as member (server default) rather than skipping the check — `(role ?? 'member')` — so the documented 'verified on the response' guarantee doesn't depend on the field always being present. - Note 3: only append the 'name already taken, use --name' hint on 400/409 (where duplicates land), not every non-403 — a 500 no longer carries a misleading suggestion. - Note 4 (defense-in-depth): reject any server-provided emitted value (CRN/region, client id, access key) that contains a control character, closing the dotenv line-injection surface the name guard already covered. clientKey is hex by construction. Tests: absent-role → success; 500 omits the --name hint (keeps the leftover-client note); a newline in the server region → refused, no injected line on stdout. 27 env unit tests, suite 540/62 green. Note 1 (CRN round-trip) needs no code change — the pre-merge live smoke already inits a wasm-inline client with the emitted workspaceCrn (see PR comment); replying there. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../src/commands/env/__tests__/env.test.ts | 46 +++++++++++++++++++ packages/cli/src/commands/env/index.ts | 35 +++++++++++--- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/env/__tests__/env.test.ts b/packages/cli/src/commands/env/__tests__/env.test.ts index ae743d0e..01bf8acd 100644 --- a/packages/cli/src/commands/env/__tests__/env.test.ts +++ b/packages/cli/src/commands/env/__tests__/env.test.ts @@ -338,6 +338,19 @@ describe('envCommand — failure modes', () => { expect(message).toContain('--name') }) + it('does NOT append the --name hint to a 500 (only 400/409 are duplicates)', async () => { + stubSession() + stubFetch({ + accessKey: new Response('internal error', { status: 500 }), + }) + + await expectExit(envCommand({ name: 'x' }), 1) + const message = lastError() + expect(message).not.toContain('already taken') + // The leftover-client note still appears — a client was minted. + expect(message).toContain("ZeroKMS client 'x'") + }) + it('maps a request timeout to a clear request_timeout error', async () => { stubSession() vi.stubGlobal( @@ -433,6 +446,39 @@ describe('envCommand — response validation (nothing minted may print undefined // The over-privileged secret itself must not be printed anywhere. expect(stdout()).not.toContain('CSAKTid.secret') }) + + it('treats an absent role as member (server default) and succeeds', async () => { + stubSession() + stubFetch({ + // No `role` field — the check must assume member, not skip. + accessKey: Response.json( + { accessKey: 'CSAKTid.secret' }, + { status: 201 }, + ), + }) + + await envCommand({ name: 'x' }) + expect(stdout()).toContain('CS_CLIENT_ACCESS_KEY=CSAKTid.secret') + }) + + it('refuses to emit a server value containing a control character', async () => { + stubSession() + stubFetch({ + // A newline in the region would break out of the CRN's dotenv line. + workspaces: Response.json([ + { id: 'WS123', region: 'us-east-1.aws\nCS_INJECTED=evil' }, + ]), + }) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ + status: 'error', + code: 'unexpected_response', + }) + expect(String(event.message)).toContain('workspaceCrn') + expect(stdout()).not.toContain('CS_INJECTED') + }) }) describe('envCommand — --write', () => { diff --git a/packages/cli/src/commands/env/index.ts b/packages/cli/src/commands/env/index.ts index 74343c01..4087f9ae 100644 --- a/packages/cli/src/commands/env/index.ts +++ b/packages/cli/src/commands/env/index.ts @@ -446,30 +446,53 @@ async function mintCredentials(keyName: string): Promise { 'create the access key', keyResponse, ) - throw new MintError( - base.code, - `${base.message} If the name is already taken, rerun with a different --name.${leftover}`, - ) + // A duplicate name comes back as 400 (BadRequest) or 409 (Conflict); only + // then is "pick a different name" the right suggestion — a 500 shouldn't + // carry it. + const dupHint = + keyResponse.status === 400 || keyResponse.status === 409 + ? ' If the name is already taken, rerun with a different --name.' + : '' + throw new MintError(base.code, `${base.message}${dupHint}${leftover}`) } const accessKeyBody = parsed( accessKeySchema, await keyResponse.json(), 'access key', ) - if (accessKeyBody.role && accessKeyBody.role.toLowerCase() !== 'member') { + // The request pinned `member` and the server owns the default, so an ABSENT + // role means "server default (member)" — treat it as member rather than + // skipping the check, so the documented "verified on the response" guarantee + // doesn't quietly depend on the field always being present. + const returnedRole = (accessKeyBody.role ?? 'member').toLowerCase() + if (returnedRole !== 'member') { throw new MintError( 'unexpected_role', `CTS returned a '${accessKeyBody.role}' access key where member was requested — refusing to emit it. Revoke '${keyName}' in the dashboard.`, ) } - return { + const creds: MintedCredentials = { keyName, workspaceCrn, clientId: client.id, clientKey, accessKey: accessKeyBody.accessKey, } + // Defense-in-depth: the name is already control-char-guarded, but the + // server-provided values (CRN region, client id, access key) land on their + // own dotenv lines too. A control char — especially a newline — could break + // out of its line and inject another. Very low risk (trusted server), fully + // closed here. clientKey is hex by construction, so it can't carry one. + for (const [field, value] of Object.entries(creds)) { + if (CONTROL_CHARS.test(value)) { + throw new MintError( + 'unexpected_response', + `The service returned a ${field} containing a control character — refusing to emit it. Check for a newer CLI release.`, + ) + } + } + return creds } function trimTrailingSlash(url: string): string {