From b447f2b6b712ac841a857de2e19b53632acbaf86 Mon Sep 17 00:00:00 2001 From: cvince Date: Wed, 24 Jun 2026 23:04:42 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20capy=20add=20=20--web=20?= =?UTF-8?q?=E2=80=94=20local=20browser=20secret=20intake=20(encrypts=20+?= =?UTF-8?q?=20syncs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/addCommand.ts | 214 ++++++++++++++++++++++++++ src/commands/intakeSecurity.ts | 24 +++ src/index.ts | 26 ++++ src/ui/intakePage.ts | 84 ++++++++++ tests/commands/intakeSecurity.test.ts | 37 +++++ tests/ui/intakePage.test.ts | 30 ++++ 6 files changed, 415 insertions(+) create mode 100644 src/commands/addCommand.ts create mode 100644 src/commands/intakeSecurity.ts create mode 100644 src/ui/intakePage.ts create mode 100644 tests/commands/intakeSecurity.test.ts create mode 100644 tests/ui/intakePage.test.ts diff --git a/src/commands/addCommand.ts b/src/commands/addCommand.ts new file mode 100644 index 0000000..d8beb13 --- /dev/null +++ b/src/commands/addCommand.ts @@ -0,0 +1,214 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'http'; +import { randomBytes } from 'crypto'; +import type { Socket } from 'net'; +import { CapyError, ERROR_CODES } from '../types'; +import { resolveContext, writeAndSync } from './connectors/shared'; +import { generateIntakeForm } from '../ui/intakePage'; +import { nonceEqual, isLoopbackHost, isAllowedOrigin } from './intakeSecurity'; + +export interface AddOpts { + web?: boolean; + reason?: string; + helpUrl?: string; + /** false when --no-open was passed (commander negation). */ + open?: boolean; + noPush?: boolean; + force?: boolean; + nonTty?: boolean; +} + +const VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const INTAKE_TIMEOUT_MS = 5 * 60 * 1000; + +interface IntakeParams { + varName: string; + reason?: string; + helpUrl?: string; + exists: boolean; + open: boolean; +} + +/** + * Open a local browser form and resolve with the value the user submits. The + * value is returned in-process — it is never printed to stdout/stderr or logged. + * Only the (secret-free, single-use, loopback) URL is printed. + */ +function runWebIntake(params: IntakeParams): Promise { + const nonce = randomBytes(32).toString('hex'); + const connections = new Set(); + let submitted = false; + + return new Promise((resolve, reject) => { + let timer: NodeJS.Timeout; + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const addr = server.address(); + const port = addr && typeof addr === 'object' ? addr.port : 0; + const expectedHost = `127.0.0.1:${port}`; + const url = new URL(req.url ?? '/', `http://${expectedHost}`); + + if (req.method === 'GET' && url.pathname === '/') { + if (url.searchParams.get('n') !== nonce) { + res.writeHead(403).end('forbidden'); + return; + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(generateIntakeForm({ varName: params.varName, nonce, reason: params.reason, helpUrl: params.helpUrl, exists: params.exists })); + return; + } + + if (req.method === 'POST' && url.pathname === '/submit') { + // Anti DNS-rebinding: the request must target the loopback host we bound. + if (!isLoopbackHost(req.headers.host, port)) { + res.writeHead(403).end('bad host'); + return; + } + if (!isAllowedOrigin(req.headers.origin, port)) { + res.writeHead(403).end('bad origin'); + return; + } + if (submitted) { + res.writeHead(409).end('already submitted'); + return; + } + + let body = ''; + let aborted = false; + req.on('data', (c: Buffer) => { + body += c.toString(); + if (body.length > 1_000_000) { + aborted = true; + res.writeHead(413).end('too large'); + req.destroy(); + } + }); + req.on('end', () => { + if (aborted) return; + let parsed: { nonce?: unknown; value?: unknown }; + try { + parsed = JSON.parse(body); + } catch { + res.writeHead(400).end('bad json'); + return; + } + if (!nonceEqual(parsed.nonce, nonce)) { + res.writeHead(403).end('bad nonce'); + return; + } + if (typeof parsed.value !== 'string') { + res.writeHead(400).end('missing value'); + return; + } + submitted = true; + const value = parsed.value; + res.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}'); + // Let the success response flush before tearing down. + setTimeout(() => { + cleanup(); + resolve(value); + }, 250); + }); + return; + } + + res.writeHead(404).end('not found'); + }); + + const cleanup = (): void => { + clearTimeout(timer); + try { + server.close(); + } catch { + /* ignore */ + } + for (const c of connections) { + try { + c.destroy(); + } catch { + /* ignore */ + } + } + connections.clear(); + }; + + server.on('connection', (c: Socket) => { + connections.add(c); + c.on('close', () => connections.delete(c)); + }); + server.on('error', (err) => { + cleanup(); + reject(err); + }); + + timer = setTimeout(() => { + cleanup(); + reject(new CapyError('Timed out waiting for the value (5 minutes).', ERROR_CODES.SERVICE_ERROR)); + }, INTAKE_TIMEOUT_MS); + timer.unref(); + + process.once('SIGINT', () => { + cleanup(); + reject(new CapyError('Cancelled.', ERROR_CODES.SERVICE_ERROR)); + }); + + server.listen(0, '127.0.0.1', async () => { + const addr = server.address(); + const port = addr && typeof addr === 'object' ? addr.port : 0; + const url = `http://127.0.0.1:${port}/?n=${nonce}`; + console.log(''); + console.log(` Enter ${params.varName} in your browser (the value never touches this terminal or the AI):`); + console.log(` ${url}`); + console.log(''); + if (params.open) { + try { + const open = (await import('open')).default; + await open(url); + } catch { + /* best-effort; the printed URL is the fallback */ + } + } + }); + }); +} + +export class AddCommand { + async execute(varName: string, opts: AddOpts): Promise { + const name = varName.trim(); + if (!VAR_RE.test(name)) { + throw new CapyError(`"${varName}" is not a valid environment variable name.`, ERROR_CODES.INVALID_FORMAT); + } + + const ctx = await resolveContext(); + const exists = name in ctx.localPlaintext; + + if (exists && !opts.force && !opts.web && !opts.nonTty) { + const inquirer = (await import('inquirer')).default; + const { ok } = await inquirer.prompt([{ type: 'confirm', name: 'ok', message: `${name} already exists. Overwrite?`, default: false }]); + if (!ok) { + console.log('Aborted.'); + return; + } + } + + let value: string; + if (opts.web) { + value = await runWebIntake({ varName: name, reason: opts.reason, helpUrl: opts.helpUrl, exists, open: opts.open !== false }); + } else if (opts.nonTty) { + throw new CapyError( + 'Non-interactive add requires --web (browser intake). Re-run with --web.', + ERROR_CODES.INVALID_FORMAT, + ); + } else { + const inquirer = (await import('inquirer')).default; + const { value: entered } = await inquirer.prompt([{ type: 'password', name: 'value', message: `Value for ${name}:`, mask: '*' }]); + value = entered; + if (!value) throw new CapyError('No value entered.', ERROR_CODES.INVALID_FORMAT); + } + + await writeAndSync(ctx, name, value, { push: opts.noPush !== true }); + + const verb = exists ? 'Updated' : 'Added'; + const where = opts.noPush ? ' (.env only — not pushed)' : ` and synced to ${ctx.branch}`; + console.log(`✓ ${verb} ${name}${where}.`); + } +} diff --git a/src/commands/intakeSecurity.ts b/src/commands/intakeSecurity.ts new file mode 100644 index 0000000..4b86c57 --- /dev/null +++ b/src/commands/intakeSecurity.ts @@ -0,0 +1,24 @@ +// Loopback intake guards, isolated for unit testing. The intake server accepts a +// secret value from a local browser; these checks make that safe: a single-use +// constant-time nonce, plus Host/Origin pinning to the exact loopback address we +// bound (defends against DNS-rebinding from a malicious web page). +import { timingSafeEqual } from 'crypto'; + +export function nonceEqual(received: unknown, expected: string): boolean { + if (typeof received !== 'string' || received.length !== expected.length) return false; + try { + return timingSafeEqual(Buffer.from(received), Buffer.from(expected)); + } catch { + return false; + } +} + +export function isLoopbackHost(host: string | undefined, port: number): boolean { + return host === `127.0.0.1:${port}`; +} + +export function isAllowedOrigin(origin: string | undefined, port: number): boolean { + // Absent Origin (same-origin form submit/navigation) is allowed; if present it + // must be exactly our loopback origin. + return !origin || origin === `http://127.0.0.1:${port}`; +} diff --git a/src/index.ts b/src/index.ts index 1550374..e4f48bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -599,6 +599,32 @@ program await cmd.execute(); }); +program + .command('add ') + .description('Add a secret value to the project (encrypts + syncs)') + .option('--web', 'enter the value in a local browser form (supports multiline)') + .option('--reason ', 'short note shown on the intake page') + .option('--help-url ', 'link shown on the intake page for where to get the value') + .option('--no-open', 'do not auto-open the browser; print the URL only') + .option('--no-push', 'write to .env only; do not push to Capy') + .option('-f, --force', 'overwrite an existing value without prompting') + .option('--non-tty', 'never prompt; resolve from flags or fail fast (agents/CI)') + .action(async (varName, options, command) => { + assertNotLocalOnly('add'); + const { AddCommand } = await import('./commands/addCommand'); + const cmd = new AddCommand(); + const merged = command.optsWithGlobals(); + await cmd.execute(varName, { + web: options.web, + reason: options.reason, + helpUrl: options.helpUrl, + open: options.open, + noPush: options.push === false, + force: merged.force, + nonTty: options.nonTty, + }); + }); + program .command('connect [provider]') .description('Connect a third-party provider and pull a credential into .env') diff --git a/src/ui/intakePage.ts b/src/ui/intakePage.ts new file mode 100644 index 0000000..d8f0638 --- /dev/null +++ b/src/ui/intakePage.ts @@ -0,0 +1,84 @@ +import { DEPLOY_PAGE_CSS } from './deployPage/generatedAssets'; + +const escHtml = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +// Safe to inline inside a "). +const jsStr = (s: string): string => JSON.stringify(s).replace(/`; + +export interface IntakeFormOptions { + varName: string; + nonce: string; + reason?: string; + helpUrl?: string; + exists: boolean; +} + +/** Local browser form where the user pastes a secret value. The value is POSTed + * back to the loopback server and never leaves the machine in plaintext. */ +export function generateIntakeForm(o: IntakeFormOptions): string { + const v = escHtml(o.varName); + const reason = o.reason ? `

${escHtml(o.reason)}

` : ''; + const help = o.helpUrl + ? `

Where to find it: ${escHtml(o.helpUrl)}

` + : ''; + const warn = o.exists + ? `

This variable already exists — submitting overwrites it.

` + : ''; + + return ` + + + + + Capy — Add ${v} + + + +
+
+
${CAPY_LOGO_SVG}
+

Add ${v}

+
+ ${reason} + ${help} + ${warn} +
+ + + +
+
+

This value is encrypted on your machine and synced to Capy. It never leaves in plaintext and never passes through the AI assistant.

+
+ + +`; +} diff --git a/tests/commands/intakeSecurity.test.ts b/tests/commands/intakeSecurity.test.ts new file mode 100644 index 0000000..20926e8 --- /dev/null +++ b/tests/commands/intakeSecurity.test.ts @@ -0,0 +1,37 @@ +import { describe, test, expect } from 'bun:test'; +import { randomBytes } from 'crypto'; +import { nonceEqual, isLoopbackHost, isAllowedOrigin } from '../../src/commands/intakeSecurity'; + +describe('nonceEqual', () => { + test('true for identical nonces', () => { + const n = randomBytes(32).toString('hex'); + expect(nonceEqual(n, n)).toBe(true); + }); + test('false for different nonces', () => { + expect(nonceEqual(randomBytes(32).toString('hex'), randomBytes(32).toString('hex'))).toBe(false); + }); + test('false for length mismatch or non-string', () => { + expect(nonceEqual('abc', 'abcd')).toBe(false); + expect(nonceEqual(undefined, 'abcd')).toBe(false); + expect(nonceEqual(123, 'abcd')).toBe(false); + }); +}); + +describe('isLoopbackHost', () => { + test('only the exact bound loopback host passes', () => { + expect(isLoopbackHost('127.0.0.1:5000', 5000)).toBe(true); + expect(isLoopbackHost('127.0.0.1:5001', 5000)).toBe(false); + expect(isLoopbackHost('evil.com', 5000)).toBe(false); + expect(isLoopbackHost('localhost:5000', 5000)).toBe(false); + expect(isLoopbackHost(undefined, 5000)).toBe(false); + }); +}); + +describe('isAllowedOrigin', () => { + test('absent origin allowed; present must match exactly', () => { + expect(isAllowedOrigin(undefined, 5000)).toBe(true); + expect(isAllowedOrigin('http://127.0.0.1:5000', 5000)).toBe(true); + expect(isAllowedOrigin('http://evil.com', 5000)).toBe(false); + expect(isAllowedOrigin('https://127.0.0.1:5000', 5000)).toBe(false); + }); +}); diff --git a/tests/ui/intakePage.test.ts b/tests/ui/intakePage.test.ts new file mode 100644 index 0000000..df19c34 --- /dev/null +++ b/tests/ui/intakePage.test.ts @@ -0,0 +1,30 @@ +import { describe, test, expect } from 'bun:test'; +import { generateIntakeForm } from '../../src/ui/intakePage'; + +describe('generateIntakeForm', () => { + const html = generateIntakeForm({ varName: 'STRIPE_SECRET_KEY', nonce: 'abc123', exists: false }); + + test('shows the var name and embeds the nonce', () => { + expect(html).toContain('STRIPE_SECRET_KEY'); + expect(html).toContain('abc123'); + }); + + test('has a multiline value textarea and posts to /submit', () => { + expect(html).toContain(' { + expect(html).toMatch(/never passes through the AI/i); + }); + + test('warns on overwrite when the var exists', () => { + expect(generateIntakeForm({ varName: 'X', nonce: 'n', exists: true })).toMatch(/already exists/i); + }); + + test('neutralizes HTML/script in the var name', () => { + const h = generateIntakeForm({ varName: '', nonce: 'n', exists: false }); + expect(h).not.toContain(''); + expect(h).toContain('</script><b>'); + }); +}); From a82989bb222ce9cae7811062c398d5153712f870 Mon Sep 17 00:00:00 2001 From: cvince Date: Wed, 24 Jun 2026 23:55:28 -0700 Subject: [PATCH 2/3] fix: register add in the dev entrypoint + thread devMode through AddCommand --- src/commands/addCommand.ts | 4 +++- src/index-dev.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/commands/addCommand.ts b/src/commands/addCommand.ts index d8beb13..b10181a 100644 --- a/src/commands/addCommand.ts +++ b/src/commands/addCommand.ts @@ -172,13 +172,15 @@ function runWebIntake(params: IntakeParams): Promise { } export class AddCommand { + constructor(private readonly devMode: boolean = false) {} + async execute(varName: string, opts: AddOpts): Promise { const name = varName.trim(); if (!VAR_RE.test(name)) { throw new CapyError(`"${varName}" is not a valid environment variable name.`, ERROR_CODES.INVALID_FORMAT); } - const ctx = await resolveContext(); + const ctx = await resolveContext({ devMode: this.devMode }); const exists = name in ctx.localPlaintext; if (exists && !opts.force && !opts.web && !opts.nonTty) { diff --git a/src/index-dev.ts b/src/index-dev.ts index 17d8b3f..bf2a879 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -694,6 +694,31 @@ program process.exit(code); }); +program + .command('add ') + .description('Add a secret value to the project (encrypts + syncs)') + .option('--web', 'enter the value in a local browser form (supports multiline)') + .option('--reason ', 'short note shown on the intake page') + .option('--help-url ', 'link shown on the intake page for where to get the value') + .option('--no-open', 'do not auto-open the browser; print the URL only') + .option('--no-push', 'write to .env only; do not push to Capy') + .option('-f, --force', 'overwrite an existing value without prompting') + .option('--non-tty', 'never prompt; resolve from flags or fail fast (agents/CI)') + .action(async (varName, options, command) => { + const { AddCommand } = await import('./commands/addCommand'); + const cmd = new AddCommand(true); // devMode: dev backend + ~/.capy-dev + const merged = command.optsWithGlobals(); + await cmd.execute(varName, { + web: options.web, + reason: options.reason, + helpUrl: options.helpUrl, + open: options.open, + noPush: options.push === false, + force: merged.force, + nonTty: options.nonTty, + }); + }); + program .command('connect [provider]') .description('Connect a third-party provider and pull a credential into .env') From 9d804336fd8bead4f922182995753422f4678777 Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 25 Jun 2026 00:14:28 -0700 Subject: [PATCH 3/3] fix(add --web): save before responding so the browser shows save failures (500) and allows retry --- src/commands/addCommand.ts | 81 +++++++++++++++++++++++-------------- src/ui/intakePage.ts | 23 +++++++---- tests/ui/intakePage.test.ts | 8 ++++ 3 files changed, 74 insertions(+), 38 deletions(-) diff --git a/src/commands/addCommand.ts b/src/commands/addCommand.ts index b10181a..1777505 100644 --- a/src/commands/addCommand.ts +++ b/src/commands/addCommand.ts @@ -29,16 +29,19 @@ interface IntakeParams { } /** - * Open a local browser form and resolve with the value the user submits. The - * value is returned in-process — it is never printed to stdout/stderr or logged. - * Only the (secret-free, single-use, loopback) URL is printed. + * Open a local browser form and run `onSubmit` with the value the user enters. + * The save runs INSIDE the request, so the browser learns whether it actually + * succeeded: on success the page shows "Saved" and this resolves; on failure the + * server returns 500 with the error message and the user can fix it and retry in + * the form. The value is handled in-process — never printed, logged, or returned. */ -function runWebIntake(params: IntakeParams): Promise { +function runWebIntake(params: IntakeParams, onSubmit: (value: string) => Promise): Promise { const nonce = randomBytes(32).toString('hex'); const connections = new Set(); - let submitted = false; + let busy = false; + let done = false; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let timer: NodeJS.Timeout; const server = createServer((req: IncomingMessage, res: ServerResponse) => { @@ -67,10 +70,6 @@ function runWebIntake(params: IntakeParams): Promise { res.writeHead(403).end('bad origin'); return; } - if (submitted) { - res.writeHead(409).end('already submitted'); - return; - } let body = ''; let aborted = false; @@ -82,7 +81,7 @@ function runWebIntake(params: IntakeParams): Promise { req.destroy(); } }); - req.on('end', () => { + req.on('end', async () => { if (aborted) return; let parsed: { nonce?: unknown; value?: unknown }; try { @@ -99,14 +98,31 @@ function runWebIntake(params: IntakeParams): Promise { res.writeHead(400).end('missing value'); return; } - submitted = true; - const value = parsed.value; - res.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}'); - // Let the success response flush before tearing down. - setTimeout(() => { - cleanup(); - resolve(value); - }, 250); + if (done) { + res.writeHead(409).end('already submitted'); + return; + } + if (busy) { + res.writeHead(409).end('a submission is already in progress'); + return; + } + // Perform the actual save BEFORE responding, so the browser learns + // whether it really succeeded. On failure: 500 + the message, and + // leave the server open so the user can fix it and retry in the form. + busy = true; + try { + await onSubmit(parsed.value); + done = true; + res.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}'); + setTimeout(() => { + cleanup(); + resolve(); + }, 250); + } catch (err) { + busy = false; + const message = err instanceof Error ? err.message : 'Failed to save the secret.'; + res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: message })); + } }); return; } @@ -192,25 +208,30 @@ export class AddCommand { } } - let value: string; + const push = opts.noPush !== true; + if (opts.web) { - value = await runWebIntake({ varName: name, reason: opts.reason, helpUrl: opts.helpUrl, exists, open: opts.open !== false }); - } else if (opts.nonTty) { - throw new CapyError( - 'Non-interactive add requires --web (browser intake). Re-run with --web.', - ERROR_CODES.INVALID_FORMAT, + // The save runs inside the intake server, so the browser reflects success + // or failure of the actual encrypt+sync. + await runWebIntake( + { varName: name, reason: opts.reason, helpUrl: opts.helpUrl, exists, open: opts.open !== false }, + (value) => writeAndSync(ctx, name, value, { push }), ); } else { + if (opts.nonTty) { + throw new CapyError( + 'Non-interactive add requires --web (browser intake). Re-run with --web.', + ERROR_CODES.INVALID_FORMAT, + ); + } const inquirer = (await import('inquirer')).default; const { value: entered } = await inquirer.prompt([{ type: 'password', name: 'value', message: `Value for ${name}:`, mask: '*' }]); - value = entered; - if (!value) throw new CapyError('No value entered.', ERROR_CODES.INVALID_FORMAT); + if (!entered) throw new CapyError('No value entered.', ERROR_CODES.INVALID_FORMAT); + await writeAndSync(ctx, name, entered, { push }); } - await writeAndSync(ctx, name, value, { push: opts.noPush !== true }); - const verb = exists ? 'Updated' : 'Added'; - const where = opts.noPush ? ' (.env only — not pushed)' : ` and synced to ${ctx.branch}`; + const where = push ? ` and synced to ${ctx.branch}` : ' (.env only — not pushed)'; console.log(`✓ ${verb} ${name}${where}.`); } } diff --git a/src/ui/intakePage.ts b/src/ui/intakePage.ts index d8f0638..15805cb 100644 --- a/src/ui/intakePage.ts +++ b/src/ui/intakePage.ts @@ -52,29 +52,36 @@ export function generateIntakeForm(o: IntakeFormOptions): string { class="w-full font-mono text-sm border border-black dark:border-white dark:bg-black p-3"> -
+

This value is encrypted on your machine and synced to Capy. It never leaves in plaintext and never passes through the AI assistant.

'); expect(h).toContain('</script><b>'); }); + + test('shows a saving state and renders a failed-save error (with retry)', () => { + expect(html).toContain('Saving'); + expect(html).toContain('Could not save'); + // surfaces the server error message and re-enables the button for retry + expect(html).toContain('b.error'); + expect(html).toContain('btn.disabled = false'); + }); });