From fbe4073cf203aa2a2108c1bf31ce101cb982a028 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 19 Aug 2026 14:46:43 +0000 Subject: [PATCH] cli-tools config pull: take API keys from the logicsrc team vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting a machine up meant knowing which of ~180 vaults happened to carry the OpenAI key and pulling it by hand — and a rotated key reached a machine only when somebody remembered to repeat that. Meanwhile the same key was already sitting in 27 prod vaults, which is duplication nobody chose. So there is now a shared vault, profullstack-sharable-keys--prod, holding the account-level keys that are one account across many projects, and: cli-tools config pull decrypts it and imports what these commands read. It imports ONLY those keys and says how many it left behind. Copying the whole vault down would put a second copy of every team secret on the machine, drifting from the thing that is supposed to be authoritative — the failure the vault exists to prevent. This is a cache of two or three keys, not a mirror, and the direction of authority is the point. `logicsrc teams pull` can only write a decrypted .env to a path, so plaintext exists for the length of one read: a 0700 temp directory, removed in a finally so a failed pull or an unparseable file cannot leave it behind. Tests assert the directory is gone on both paths, because that is the whole risk of the feature. A missing logicsrc is reported as a missing logicsrc, with the command that installs it, rather than as an exec failure three layers down. The runner is injectable, so none of the 13 new tests talk to a real vault. 180 tests pass (was 167), typecheck clean. Verified against the real vault: imports 2 of 13 keys, second run reports both already matching, credentials.json stays 0600, and no temp directory survives. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 33 ++++++++- bin/cli-tools.ts | 75 ++++++++++++++++++- plugins/tools/commands/config.md | 29 +++++++- src/vault.ts | 123 +++++++++++++++++++++++++++++++ test/vault.test.ts | 110 +++++++++++++++++++++++++++ 5 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 src/vault.ts create mode 100644 test/vault.test.ts diff --git a/README.md b/README.md index 1fdcd75..95277c0 100644 --- a/README.md +++ b/README.md @@ -97,11 +97,42 @@ ln -sf ~/scripts/bin/gh-prs-merge ~/.local/bin/gh-prs-merge # and so on has to carry it in an environment again: ```sh -cli-tools config set openai # prompts; the value is never echoed +cli-tools config pull # import them from the logicsrc team vault +cli-tools config set openai # or set one by hand; the value is never echoed cli-tools config # what is set, and which source is winning cli-tools config unset openai ``` +### From the team vault + +`cli-tools config pull` decrypts the shared logicsrc vault and imports the keys +these commands use — the fastest way to set a new machine up, and the way a +rotated key reaches it: + +```sh +cli-tools config pull +# config: imported OPENAI_API_KEY (sk-pr…ZyAA (164 chars)) +# config: imported ANTHROPIC_API_KEY (sk-an…uAAA (108 chars)) +# 11 other key(s) in the vault were left there +``` + +It defaults to `profullstack/profullstack-sharable-keys--prod`, overridable with +`CLI_TOOLS_VAULT_TEAM`, `CLI_TOOLS_VAULT_PROJECT` and `CLI_TOOLS_VAULT_ENV`. +Needs the `logicsrc` CLI and a login (`moshcode install secrets`, then +`logicsrc login`); if it is missing, the error says so rather than failing +obscurely. + +**It imports only the keys these commands read, and leaves the rest in the +vault.** Copying a whole vault down would make the local file a second copy of +every team secret that nobody remembers to invalidate — which is the thing the +vault exists to avoid. The vault stays the authority; this is a cache of the two +or three keys `generate-names` actually needs. + +`logicsrc teams pull` can only write a decrypted `.env` to a path, so the +plaintext exists for the length of one read: it goes to a `0700` temporary +directory and is removed in a `finally`, including when the pull or the parse +fails. + Keys live in `~/.config/cli-tools/credentials.json`, written `0600` in a `0700` directory (`$CLI_TOOLS_CREDENTIALS` overrides the path). Nothing prints a whole key back — `config` shows a masked preview and a length, which is enough to tell diff --git a/bin/cli-tools.ts b/bin/cli-tools.ts index 24251d1..1c7b340 100755 --- a/bin/cli-tools.ts +++ b/bin/cli-tools.ts @@ -29,6 +29,7 @@ import { mask, saveStored, } from '../src/credentials.ts'; +import { pullVault, vaultTarget } from '../src/vault.ts'; import { isMain } from '../src/is-main.ts'; import { aliasesPath, @@ -45,7 +46,7 @@ const USAGE = `Usage: cli-tools link [--force] cli-tools unlink cli-tools aliases [--install] - cli-tools config [set [value] | unset ] + cli-tools config [pull | set [value] | unset ] cli-tools [args…] Commands: @@ -55,6 +56,7 @@ Commands: unlink Remove the symlinks we own aliases Print the moshcode pit aliases, or write them with --install config API keys: what is set, where it came from, and how to change it + "config pull" imports them from the logicsrc team vault where Print the checkout this command is running from Keys (config set ): @@ -233,8 +235,77 @@ async function configCommand(rest: readonly string[], json: boolean): Promise; + try { + vault = pullVault(target); + } catch (error) { + process.stderr.write(`config: ${(error as Error).message}\n`); + return 1; + } + + // Only the keys these commands use. Copying the whole vault down would make + // this file a second, drifting copy of every team secret — which is the + // thing the vault exists to avoid. + const stored = loadStored(); + const imported: string[] = []; + const unchanged: string[] = []; + for (const variable of Object.values(KNOWN_KEYS)) { + const value = vault[variable]; + if (!value) continue; + if (stored[variable] === value) { + unchanged.push(variable); + continue; + } + stored[variable] = value; + imported.push(variable); + } + + if (imported.length === 0 && unchanged.length === 0) { + process.stderr.write( + `config: ${label} has ${Object.keys(vault).length} keys, none of them ones these ` + + `commands use (${Object.values(KNOWN_KEYS).join(', ')}).\n`, + ); + return 1; + } + + if (imported.length > 0) { + const path = saveStored(stored); + for (const variable of imported) { + process.stdout.write(`config: imported ${variable} (${mask(stored[variable]!)})\n`); + } + process.stdout.write(`config: written to ${path}\n`); + } + for (const variable of unchanged) { + process.stdout.write(`config: ${variable} already matches the vault\n`); + } + + const ignored = Object.keys(vault).filter( + (key) => !Object.values(KNOWN_KEYS).includes(key), + ).length; + if (ignored > 0) { + process.stdout.write( + `\n${ignored} other key(s) in the vault were left there — this stores only what\n` + + 'these commands read. The vault stays the authority.\n', + ); + } + + const shadowed = imported.filter((variable) => process.env[variable]); + if (shadowed.length > 0) { + process.stdout.write( + `\nNote: ${shadowed.join(', ')} ${shadowed.length === 1 ? 'is' : 'are'} also set in your\n` + + 'environment, which wins over what was just stored.\n', + ); + } + return 0; + } + if (verb !== 'set' && verb !== 'unset') { - process.stderr.write(`config: unknown verb "${verb}" (expected set or unset)\n`); + process.stderr.write(`config: unknown verb "${verb}" (expected set, unset or pull)\n`); return 1; } diff --git a/plugins/tools/commands/config.md b/plugins/tools/commands/config.md index c15f270..98154cf 100644 --- a/plugins/tools/commands/config.md +++ b/plugins/tools/commands/config.md @@ -9,12 +9,37 @@ Set up, inspect or clear the API keys `cli-tools` commands use. ```bash cli-tools config # what is set, and where each key came from -cli-tools config set openai # prompts; the value is never echoed -cli-tools config set anthropic +cli-tools config pull # import from the logicsrc team vault +cli-tools config set openai # or by hand; prompts, never echoed cli-tools config unset openai cli-tools config --json # machine-readable, still masked ``` +## From the team vault + +`cli-tools config pull` is the normal way to set a machine up, and the way a +rotated key reaches one: + +```bash +cli-tools config pull +``` + +It decrypts `profullstack/profullstack-sharable-keys--prod` and imports the keys +these commands use. Override the target with `CLI_TOOLS_VAULT_TEAM`, +`CLI_TOOLS_VAULT_PROJECT`, `CLI_TOOLS_VAULT_ENV`. + +Needs the `logicsrc` CLI and a login — `moshcode install secrets`, then +`logicsrc login`. A missing CLI is reported as such rather than as a generic +failure. + +**Only the keys these commands read are imported; the rest stay in the vault.** +Pulling a whole vault down would leave a second copy of every team secret on the +machine, drifting from the vault that is supposed to be the authority. This is a +cache of two or three keys, not a mirror. + +The decrypted `.env` logicsrc writes lives in a `0700` temp directory for the +length of one read and is removed in a `finally`, including on failure. + Keys live in `~/.config/cli-tools/credentials.json`, written `0600` inside a `0700` directory. `$CLI_TOOLS_CREDENTIALS` overrides the path. diff --git a/src/vault.ts b/src/vault.ts new file mode 100644 index 0000000..3cee340 --- /dev/null +++ b/src/vault.ts @@ -0,0 +1,123 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Read API keys out of a logicsrc team vault. + * + * The vault is the authority; {@link ../credentials.ts} is a cache of the few + * keys these commands actually use. That direction matters — copying the whole + * vault down would make the local file a second, silently drifting copy of + * every team secret, which is the thing the vault exists to avoid. + * + * `logicsrc teams pull` can only write a decrypted `.env` to a path, so the + * plaintext exists on disk for the length of one read. It goes to a 0700 + * temporary directory and is removed in a `finally`, including when the parse + * throws. + */ + +export interface VaultTarget { + team: string; + project: string; + env: string; +} + +/** The team vault holding account-level keys shared across the org. */ +export const DEFAULT_TARGET: VaultTarget = { + team: 'profullstack', + project: 'profullstack-sharable-keys', + env: 'prod', +}; + +/** Resolve the target, letting the environment point at a different vault. */ +export function vaultTarget(env: NodeJS.ProcessEnv = process.env): VaultTarget { + return { + team: env.CLI_TOOLS_VAULT_TEAM || DEFAULT_TARGET.team, + project: env.CLI_TOOLS_VAULT_PROJECT || DEFAULT_TARGET.project, + env: env.CLI_TOOLS_VAULT_ENV || DEFAULT_TARGET.env, + }; +} + +/** Parse a dotenv file. Only what a vault actually contains: KEY=value lines. */ +export function parseEnvFile(text: string): Record { + const parsed: Record = {}; + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const at = line.indexOf('='); + if (at <= 0) continue; + + const key = line.slice(0, at).trim(); + let value = line.slice(at + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"') && value.length > 1) || + (value.startsWith("'") && value.endsWith("'") && value.length > 1) + ) { + value = value.slice(1, -1); + } + if (key && value) parsed[key] = value; + } + return parsed; +} + +export type Runner = (args: readonly string[], envPath: string) => { status: number; stderr: string }; + +/** Shell out to the real logicsrc CLI. */ +export const logicsrcRunner: Runner = (args, envPath) => { + const result = spawnSync('logicsrc', [...args, '--env', envPath], { encoding: 'utf8' }); + if (result.error) { + const code = (result.error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return { + status: 127, + stderr: + 'logicsrc is not installed. Install it with `moshcode install secrets`, ' + + 'or see https://logicsrc.com', + }; + } + return { status: 1, stderr: result.error.message }; + } + return { status: result.status ?? 1, stderr: result.stderr ?? '' }; +}; + +/** + * Pull a vault and return its keys. + * + * The decrypted file never leaves this function, and the caller receives only + * the parsed record — so nothing downstream has a path it could accidentally + * leave lying around. + */ +export function pullVault( + target: VaultTarget = vaultTarget(), + run: Runner = logicsrcRunner, +): Record { + const dir = mkdtempSync(join(tmpdir(), 'cli-tools-vault-')); + const envPath = join(dir, 'vault.env'); + + try { + const { status, stderr } = run( + ['teams', 'pull', target.team, target.project, target.env], + envPath, + ); + if (status !== 0) { + const detail = stderr.trim().split('\n').slice(-3).join('\n'); + throw new Error( + `logicsrc teams pull ${target.team} ${target.project} ${target.env} failed` + + (detail ? `:\n${detail}` : '.'), + ); + } + + let text: string; + try { + text = readFileSync(envPath, 'utf8'); + } catch { + throw new Error('logicsrc reported success but wrote no file — nothing imported.'); + } + return parseEnvFile(text); + } finally { + // Recursive so the temp directory goes with it, and force so a failure + // before the file existed is not itself an error. + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/test/vault.test.ts b/test/vault.test.ts new file mode 100644 index 0000000..d218b74 --- /dev/null +++ b/test/vault.test.ts @@ -0,0 +1,110 @@ +import { existsSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_TARGET, + parseEnvFile, + pullVault, + vaultTarget, + type Runner, +} from '../src/vault.ts'; + +/** A runner that writes the given dotenv text where logicsrc would have. */ +function writing(text: string): { run: Runner; seen: { args: string[]; dir: string | null } } { + const seen = { args: [] as string[], dir: null as string | null }; + const run: Runner = (args, envPath) => { + seen.args = [...args]; + seen.dir = dirname(envPath); + writeFileSync(envPath, text); + return { status: 0, stderr: '' }; + }; + return { run, seen }; +} + +describe('vaultTarget', () => { + it('defaults to the shared team vault', () => { + expect(vaultTarget({} as NodeJS.ProcessEnv)).toEqual(DEFAULT_TARGET); + expect(DEFAULT_TARGET.project).toBe('profullstack-sharable-keys'); + }); + + it('can be pointed elsewhere', () => { + const target = vaultTarget({ + CLI_TOOLS_VAULT_TEAM: 'other', + CLI_TOOLS_VAULT_PROJECT: 'thing', + CLI_TOOLS_VAULT_ENV: 'staging', + } as NodeJS.ProcessEnv); + expect(target).toEqual({ team: 'other', project: 'thing', env: 'staging' }); + }); +}); + +describe('parseEnvFile', () => { + it('reads KEY=value lines', () => { + expect(parseEnvFile('A=1\nB=two\n')).toEqual({ A: '1', B: 'two' }); + }); + + it('strips matching quotes', () => { + expect(parseEnvFile('A="quoted"\nB=\'single\'')).toEqual({ A: 'quoted', B: 'single' }); + }); + + // A key is base64 or a URL as often as not; splitting on every = would + // truncate exactly the values that matter. + it('keeps equals signs inside a value', () => { + expect(parseEnvFile('TOKEN=abc==def=').TOKEN).toBe('abc==def='); + }); + + it('ignores comments, blanks and malformed lines', () => { + expect(parseEnvFile('# note\n\nNOEQUALS\n=novalue\nA=1')).toEqual({ A: '1' }); + }); + + it('drops empty values rather than storing a blank key', () => { + expect(parseEnvFile('A=\nB=2')).toEqual({ B: '2' }); + }); +}); + +describe('pullVault', () => { + it('returns the vault contents', () => { + const { run } = writing('OPENAI_API_KEY=sk-vault-value\nOTHER=x\n'); + expect(pullVault(DEFAULT_TARGET, run)).toEqual({ + OPENAI_API_KEY: 'sk-vault-value', + OTHER: 'x', + }); + }); + + it('asks logicsrc for the right vault', () => { + const { run, seen } = writing('A=1'); + pullVault({ team: 't', project: 'p', env: 'e' }, run); + expect(seen.args).toEqual(['teams', 'pull', 't', 'p', 'e']); + }); + + // The decrypted file is the whole risk of this function; it must not outlive + // the call, including when the call fails. + it('removes the decrypted file and its directory', () => { + const { run, seen } = writing('A=1'); + pullVault(DEFAULT_TARGET, run); + expect(existsSync(seen.dir!)).toBe(false); + }); + + it('removes the directory even when the pull fails', () => { + let dir: string | null = null; + const run: Runner = (_args, envPath) => { + dir = dirname(envPath); + return { status: 1, stderr: 'access denied' }; + }; + + expect(() => pullVault(DEFAULT_TARGET, run)).toThrow(/access denied/); + expect(existsSync(dir!)).toBe(false); + }); + + it('names the vault in the failure', () => { + const run: Runner = () => ({ status: 1, stderr: 'nope' }); + expect(() => pullVault({ team: 't', project: 'p', env: 'e' }, run)).toThrow(/t p e/); + }); + + // Reporting success while writing nothing would otherwise surface as an empty + // import with no explanation. + it('complains when logicsrc succeeds but writes no file', () => { + const run: Runner = () => ({ status: 0, stderr: '' }); + expect(() => pullVault(DEFAULT_TARGET, run)).toThrow(/wrote no file/); + }); +});