diff --git a/CLAUDE.md b/CLAUDE.md index 1bcb076..46faa33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ Key input field notes: - `create --request-approval` and `request-approval` both show an approval URL in interactive mode and poll until approved/denied/expired/failed. In JSON mode (`--format json`), they return immediately with an `_next.command` for `spend-request retrieve`. - `retrieve --interval ` polls until approved/denied/expired/succeeded/failed. If `--timeout` is reached or `--max-attempts` is exhausted while the request is still non-terminal, it exits non-zero with `POLLING_TIMEOUT`. - `card` credentials include `billing_address` (name, line1, line2, city, state, postal_code, country) and `valid_until` (ISO date string — when the card expires/stops working) +- `--output-file ` on `retrieve` or `create` writes full card credentials to a local file (0600 permissions) and redacts card data in stdout. `--force` allows overwriting an existing file. ### mpp pay diff --git a/README.md b/README.md index 3782779..36fb6e7 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,14 @@ link-cli spend-request retrieve lsrq_001 --format json ``` By default, retrieving a spend request will not include card details. Use the `--include=card` to see unmasked card details. +To avoid leaking card credentials into agent transcripts or logs, use `--output-file` to write the full card to a secure local file while stdout shows only redacted data (brand, last4, expiry): + +```bash +link-cli spend-request retrieve lsrq_001 --include card --output-file /tmp/link-card.json --format json +``` + +The file is created with `0600` permissions. If the file already exists, the command fails unless `--force` is passed. When `--output-file` is set, the JSON output replaces the `card` object with redacted fields and adds a `card_output_file` path. + For agent polling, pass `--interval` and optionally `--max-attempts`: ```bash diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 42c49e5..8b6109b 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -7,6 +7,7 @@ import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback, useEffect, useState } from 'react'; +import { writeCredentialFile } from '../../utils/credential-output'; import { AppDownloadQrCodes } from './app-download-qr-codes'; import { ApprovalWaitingView } from './approval-waiting-view'; import { useApprovalPolling } from './use-approval-polling'; @@ -15,6 +16,8 @@ interface CreateSpendRequestProps { repository: ISpendRequestResource; params: CreateSpendRequestParams; requestApproval?: boolean; + outputFile?: string; + force?: boolean; onComplete: (result: SpendRequest | null) => void; } @@ -22,6 +25,8 @@ export const CreateSpendRequest: React.FC = ({ repository, params, requestApproval = false, + outputFile, + force, onComplete, }) => { const [status, setStatus] = useState< @@ -29,6 +34,8 @@ export const CreateSpendRequest: React.FC = ({ >('creating'); const [request, setRequest] = useState(null); const [error, setError] = useState(''); + const [outputFilePath, setOutputFilePath] = useState(null); + const [fileError, setFileError] = useState(''); const approvalUrl = request?.approval_url ?? ''; @@ -71,6 +78,22 @@ export const CreateSpendRequest: React.FC = ({ create(); }, [repository, params, requestApproval, onComplete]); + useEffect(() => { + if (status !== 'success' || !outputFile || !request?.card) return; + + const fileData = { + spend_request_id: request.id, + merchant_name: request.merchant_name, + merchant_url: request.merchant_url, + context: request.context, + created_at: request.created_at, + card: request.card, + }; + writeCredentialFile(outputFile, fileData, force ?? false) + .then((path) => setOutputFilePath(path)) + .catch((err) => setFileError((err as Error).message)); + }, [status, outputFile, force, request]); + if (status === 'creating') { return ( @@ -125,6 +148,51 @@ export const CreateSpendRequest: React.FC = ({ )} + {request?.card && !outputFile && ( + + Card Details: + + {' '} + Number: {request.card.number} + + + {' '} + Brand: {request.card.brand} + + + {' '} + Expiry:{' '} + + {String(request.card.exp_month).padStart(2, '0')}/ + {request.card.exp_year} + + + {request.card.cvc && ( + + {' '} + CVC: {request.card.cvc} + + )} + {request.card.valid_until && ( + + {' '} + Valid Until: {request.card.valid_until} + + )} + + )} + {request?.card && outputFile && ( + + {outputFilePath && ( + + Card credentials written to {outputFilePath} + + )} + {fileError && ( + Failed to write card file: {fileError} + )} + + )} ); diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index c2aa414..deefcd0 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -9,6 +9,7 @@ import { storage } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import { render } from 'ink'; import React from 'react'; +import { writeCredentialFile } from '../../utils/credential-output'; import { parseLineItemFlag, parseTotalFlag, @@ -19,6 +20,29 @@ import { RetrieveSpendRequest } from './retrieve'; import { createOptions, retrieveOptions, updateOptions } from './schema'; import { UpdateSpendRequest } from './update'; +async function applyOutputFile( + request: SpendRequest, + outputFile: string | undefined, + force: boolean, +): Promise { + if (!outputFile || !request.card) return request; + + const fileData = { + spend_request_id: request.id, + merchant_name: request.merchant_name, + merchant_url: request.merchant_url, + context: request.context, + created_at: request.created_at, + card: request.card, + }; + const resolvedPath = await writeCredentialFile(outputFile, fileData, force); + const { card: _, ...withoutCard } = request; + return { + ...withoutCard, + card_output_file: resolvedPath, + } as SpendRequest & { card_output_file?: string }; +} + export function createSpendRequestCli(repository: ISpendRequestResource) { const cli = Cli.create('spend-request', { description: 'Spend request management commands', @@ -110,6 +134,9 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { test: opts.test ? true : undefined, }; + const outputFile = opts.outputFile; + const forceOverwrite = opts.force; + if (!c.agent && !c.formatExplicit) { return new Promise((resolve) => { let capturedResult: SpendRequest | null = null; @@ -118,6 +145,8 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { repository={repository} params={createParams} requestApproval={requestApproval} + outputFile={outputFile} + force={forceOverwrite} onComplete={(result) => { capturedResult = result; }} @@ -133,7 +162,15 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { // The agent drives the polling loop via `spend-request retrieve`. const created = await repository.createSpendRequest(createParams); if (!requestApproval) { - yield created; + try { + yield await applyOutputFile(created, outputFile, forceOverwrite); + } catch (err) { + const message = (err as Error).message; + if (message.startsWith('OUTPUT_FILE_EXISTS')) { + return c.error({ code: 'OUTPUT_FILE_EXISTS', message }); + } + return c.error({ code: 'OUTPUT_FILE_WRITE_ERROR', message }); + } return; } yield { @@ -291,6 +328,8 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { const maxAttempts = opts.maxAttempts; const includeArr = opts.include; const include = includeArr?.length ? includeArr : undefined; + const outputFile = opts.outputFile; + const forceOverwrite = opts.force; if (!c.agent && !c.formatExplicit) { return new Promise((resolve) => { @@ -301,6 +340,8 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { id={id} timeout={timeout} include={include} + outputFile={outputFile} + force={forceOverwrite} onComplete={(result) => { capturedResult = result; }} @@ -332,16 +373,22 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { }); } - if (terminalStatuses.has(request.status)) { - yield request; + const shouldEmitFinal = + terminalStatuses.has(request.status) || interval <= 0; + if (shouldEmitFinal) { + try { + yield await applyOutputFile(request, outputFile, forceOverwrite); + } catch (err) { + const message = (err as Error).message; + if (message.startsWith('OUTPUT_FILE_EXISTS')) { + return c.error({ code: 'OUTPUT_FILE_EXISTS', message }); + } + return c.error({ code: 'OUTPUT_FILE_WRITE_ERROR', message }); + } return; } attempts++; - if (interval <= 0) { - yield request; - return; - } const maxAttemptsExhausted = maxAttempts > 0 && attempts >= maxAttempts; const timeoutReached = Date.now() >= deadline; diff --git a/packages/cli/src/commands/spend-request/retrieve.tsx b/packages/cli/src/commands/spend-request/retrieve.tsx index 94f8ea5..7c5f543 100644 --- a/packages/cli/src/commands/spend-request/retrieve.tsx +++ b/packages/cli/src/commands/spend-request/retrieve.tsx @@ -3,12 +3,15 @@ import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useEffect, useRef, useState } from 'react'; +import { writeCredentialFile } from '../../utils/credential-output'; interface RetrieveSpendRequestProps { repository: ISpendRequestResource; id: string; timeout?: number; include?: string[]; + outputFile?: string; + force?: boolean; onComplete: (result: SpendRequest | null) => void; } @@ -25,12 +28,16 @@ export const RetrieveSpendRequest: React.FC = ({ id, timeout = 300, include, + outputFile, + force, onComplete, }) => { const [phase, setPhase] = useState('fetching'); const [request, setRequest] = useState(null); const [error, setError] = useState(''); const [elapsed, setElapsed] = useState(0); + const [outputFilePath, setOutputFilePath] = useState(null); + const [fileError, setFileError] = useState(''); const startTimeRef = useRef(Date.now()); const pollRef = useRef | null>(null); const timerRef = useRef | null>(null); @@ -43,6 +50,22 @@ export const RetrieveSpendRequest: React.FC = ({ }; }, []); + useEffect(() => { + if (phase !== 'success' || !outputFile || !request?.card) return; + + const fileData = { + spend_request_id: request.id, + merchant_name: request.merchant_name, + merchant_url: request.merchant_url, + context: request.context, + created_at: request.created_at, + card: request.card, + }; + writeCredentialFile(outputFile, fileData, force ?? false) + .then((path) => setOutputFilePath(path)) + .catch((err) => setFileError((err as Error).message)); + }, [phase, outputFile, force, request]); + useEffect(() => { const fetch = async () => { try { @@ -251,7 +274,7 @@ export const RetrieveSpendRequest: React.FC = ({ )} - {request?.card && ( + {request?.card && !outputFile && ( Card Details: @@ -317,6 +340,18 @@ export const RetrieveSpendRequest: React.FC = ({ )} )} + {request?.card && outputFile && ( + + {outputFilePath && ( + + Card credentials written to {outputFilePath} + + )} + {fileError && ( + Failed to write card file: {fileError} + )} + + )} ); diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index e8709cc..45b0f60 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -57,6 +57,16 @@ export const createOptions = z.object({ .describe( 'Use test mode (creates testmode credentials from test card data)', ), + outputFile: z + .string() + .optional() + .describe( + 'Write full card credentials to this file path; stdout shows redacted card data only', + ), + force: z + .boolean() + .default(false) + .describe('Overwrite output file if it already exists'), }); export const retrieveOptions = z.object({ @@ -82,6 +92,16 @@ export const retrieveOptions = z.object({ .array(z.string()) .default([]) .describe('Include extra data (repeatable, e.g. --include card)'), + outputFile: z + .string() + .optional() + .describe( + 'Write full card credentials to this file path; stdout shows redacted card data only', + ), + force: z + .boolean() + .default(false) + .describe('Overwrite output file if it already exists'), }); export const updateOptions = z.object({ diff --git a/packages/cli/src/utils/__tests__/credential-output.test.ts b/packages/cli/src/utils/__tests__/credential-output.test.ts new file mode 100644 index 0000000..bff969a --- /dev/null +++ b/packages/cli/src/utils/__tests__/credential-output.test.ts @@ -0,0 +1,50 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { writeCredentialFile } from '../credential-output'; + +describe('writeCredentialFile', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'link-cli-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('writes JSON file with 0600 permissions', async () => { + const filePath = path.join(tmpDir, 'card.json'); + const data = { number: '4242424242424242', cvc: '123' }; + const result = await writeCredentialFile(filePath, data, false); + expect(result).toBe(filePath); + const contents = JSON.parse(await fs.readFile(filePath, 'utf-8')); + expect(contents).toEqual(data); + const stat = await fs.stat(filePath); + expect(stat.mode & 0o777).toBe(0o600); + }); + + it('refuses to overwrite existing file without force', async () => { + const filePath = path.join(tmpDir, 'card.json'); + await fs.writeFile(filePath, 'existing'); + await expect(writeCredentialFile(filePath, {}, false)).rejects.toThrow( + 'OUTPUT_FILE_EXISTS', + ); + }); + + it('overwrites existing file with force', async () => { + const filePath = path.join(tmpDir, 'card.json'); + await fs.writeFile(filePath, 'old'); + await writeCredentialFile(filePath, { new: true }, true); + const contents = JSON.parse(await fs.readFile(filePath, 'utf-8')); + expect(contents).toEqual({ new: true }); + }); + + it('resolves relative paths to absolute', async () => { + const filePath = path.join(tmpDir, 'card.json'); + const result = await writeCredentialFile(filePath, {}, false); + expect(path.isAbsolute(result)).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/credential-output.ts b/packages/cli/src/utils/credential-output.ts new file mode 100644 index 0000000..572e7df --- /dev/null +++ b/packages/cli/src/utils/credential-output.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export async function writeCredentialFile( + filePath: string, + data: unknown, + force: boolean, +): Promise { + const resolved = path.resolve(filePath); + + if (!force) { + try { + await fs.access(resolved); + throw new Error( + `OUTPUT_FILE_EXISTS: ${resolved} already exists. Use --force to overwrite.`, + ); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } + + await fs.writeFile(resolved, JSON.stringify(data, null, 2), { + mode: 0o600, + }); + await fs.chmod(resolved, 0o600); + return resolved; +} diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 3b870fb..2d39c9f 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -159,6 +159,12 @@ Recommend the user approves with the [Link app](https://link.com/download). Show **Card:** Run `link-cli spend-request retrieve --include card --format json` to get the `card` object with `number`, `cvc`, `exp_month`, `exp_year`, `billing_address` (name, line1, line2, city, state, postal_code, country), and `valid_until` (unix timestamp — the card stops working after this time). Enter these details into the merchant's checkout form. +**Safe credential handoff:** To avoid leaking card data into transcripts or logs, add `--output-file ` to write the full card to a local file (created with `0600` permissions) while stdout shows only redacted data. Use `--force` to overwrite an existing file. Example: + +```bash +link-cli spend-request retrieve --include card --output-file /tmp/link-card.json --format json +``` + **SPT with 402 flow:** The SPT is **one-time use** — if the payment fails, you need a new spend request and new SPT. ```bash