import * as fs from 'fs'; import * as path from 'path'; import * as readline from 'readline'; import { randomBytes } from 'crypto'; import { loadEd } from '../ed25519'; import { AGENTGUARD_SPEND_VERSION } from '../index'; import { TASK_TEMPLATES, getTaskTemplate, listTaskTemplates, type TaskTemplate } from '../templates'; import { agentguardHome, banner, cyanBold, green, statusBar } from './colors'; const SIGNING_PRIVATE_KEY_FILE = 'signing-private.key'; const SIGNING_PUBLIC_KEY_FILE = 'signing-public.key'; /** * Read the existing local signing keypair or generate one. The private seed is * stored mode 0600 under ~/.agentguard/ and never leaves the machine. Returns * both keys as lowercase hex. */ export async function ensureSigningKeys(home = agentguardHome()): Promise<{ privateKeyHex: string; publicKeyHex: string }> { fs.mkdirSync(home, { recursive: true }); const privatePath = path.join(home, SIGNING_PRIVATE_KEY_FILE); const publicPath = path.join(home, SIGNING_PUBLIC_KEY_FILE); if (fs.existsSync(privatePath) && fs.existsSync(publicPath)) { return { privateKeyHex: fs.readFileSync(privatePath, 'utf-8').trim(), publicKeyHex: fs.readFileSync(publicPath, 'utf-8').trim(), }; } const privateSeed = new Uint8Array(randomBytes(32)); const ed = await loadEd(); const publicKey = await ed.getPublicKeyAsync(privateSeed); const privateKeyHex = Buffer.from(privateSeed).toString('hex'); const publicKeyHex = Buffer.from(publicKey).toString('hex'); fs.writeFileSync(privatePath, privateKeyHex, { mode: 0o600 }); fs.writeFileSync(publicPath, publicKeyHex); return { privateKeyHex, publicKeyHex }; } const BUILD_TYPES = ['chatbot', 'agent', 'batch job', 'API endpoint']; const CAPABILITY_TIERS = [ ['read_only', 'Read records and produce findings'], ['data_write', 'Write allowed fields after policy checks'], ['payment_initiate', 'Create or recommend payment intents'], ['payment_execute', 'Execute money movement only with verified attestation'], ] as const; export interface WizardAnswers { building: string; template: string; capability: string; allowedModels: string[]; fallbackModel: string; perCallCents: number; perDayCents: number; perMonthCents: number; systemInstructions: string; } export class WizardStateMachine { step = 0; readonly totalSteps = 7; next(answer: string): boolean { if (!this.validate(this.step, answer)) return false; this.step = Math.min(this.step + 1, this.totalSteps - 1); return true; } back(): void { this.step = Math.max(0, this.step - 1); } validate(step: number, answer: string): boolean { if (step === 0) return BUILD_TYPES.includes(answer); if (step === 1) return Boolean(TASK_TEMPLATES[answer]); if (step === 2) return CAPABILITY_TIERS.some(([tier]) => tier === answer); if (step === 3) return answer.split(',').map((item) => item.trim()).filter(Boolean).length > 0; if (step === 4) return answer.trim().length > 0; if (step === 5) return answer.split(',').every((item) => Number.isSafeInteger(Number(item.trim())) && Number(item.trim()) >= 0); return true; } } export async function runWizard(argv: string[]): Promise { if (argv.includes('--help') || argv.includes('-h')) { console.log('agentguard wizard [--defaults] [--template ]'); return 0; } const templateSlug = valueAfter(argv, '--template') ?? 'risk-review'; const useDefaults = argv.includes('--defaults') || !process.stdin.isTTY; const answers = useDefaults ? defaultAnswers(templateSlug) : await promptAnswers(templateSlug); const signingKeys = await ensureSigningKeys(); const outputs = writeWizardOutputs(answers, agentguardHome(), signingKeys); console.log(''); console.log(' ' + banner(AGENTGUARD_SPEND_VERSION)); console.log(''); console.log(` ${green('created')} ${outputs.policyPath}`); console.log(` ${green('created')} ${outputs.quickstartTsPath}`); console.log(` ${green('created')} ${outputs.quickstartPyPath}`); console.log(''); console.log(cyanBold('Quickstart snippet')); console.log(''); console.log(outputs.snippet); console.log(''); console.log(' ' + statusBar()); console.log(''); return 0; } export function defaultAnswers(templateSlug = 'risk-review'): WizardAnswers { const template = getTaskTemplate(templateSlug) ?? getTaskTemplate('risk-review') as TaskTemplate; const perCall = template.caps.find((cap) => cap.window === 'per_call')?.amountCents ?? 100; const perDay = template.caps.find((cap) => cap.window === 'per_day')?.amountCents ?? 2500; return { building: 'agent', template: template.slug, capability: template.requiredCapability, allowedModels: [...template.allowedModels], fallbackModel: template.fallbackModel, perCallCents: perCall, perDayCents: perDay, perMonthCents: perDay * 20, systemInstructions: template.systemInstructions, }; } export function writeWizardOutputs( answers: WizardAnswers, home = agentguardHome(), signingKeys?: { publicKeyHex: string }, ): { policyPath: string; quickstartTsPath: string; quickstartPyPath: string; snippet: string } { fs.mkdirSync(home, { recursive: true }); const policyPath = path.join(home, 'policy.yaml'); const quickstartTsPath = path.join(home, 'quickstart.ts'); const quickstartPyPath = path.join(home, 'quickstart.py'); const policy = policyYaml(answers); const ts = quickstartTs(answers, signingKeys?.publicKeyHex); const py = quickstartPy(answers); fs.writeFileSync(policyPath, policy); fs.writeFileSync(quickstartTsPath, ts); fs.writeFileSync(quickstartPyPath, py); return { policyPath, quickstartTsPath, quickstartPyPath, snippet: tsSnippet(answers) }; } async function promptAnswers(templateSlug: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ask = (question: string, fallback: string) => new Promise((resolve) => { rl.question(`${question} (${fallback}): `, (answer) => resolve(answer.trim() || fallback)); }); try { const seed = defaultAnswers(templateSlug); console.log(''); console.log(' ' + banner(AGENTGUARD_SPEND_VERSION)); console.log(''); const building = await ask(`What are you building? ${BUILD_TYPES.join(', ')}`, seed.building); const template = await ask(`Pick a task template: ${listTaskTemplates().map((item) => item.slug).join(', ')}`, seed.template); const capability = await ask(`Capability tier: ${CAPABILITY_TIERS.map(([tier, label]) => tier + ' (' + label + ')').join(', ')}`, seed.capability); const templateDef = getTaskTemplate(template) ?? getTaskTemplate(seed.template) as TaskTemplate; const allowedModels = (await ask('Allowed models, comma separated', templateDef.allowedModels.join(','))).split(',').map((item) => item.trim()).filter(Boolean); const fallbackModel = await ask('Fallback on downgrade', templateDef.fallbackModel); const caps = await ask('Caps in cents: per-call, per-day, per-month', `${seed.perCallCents},${seed.perDayCents},${seed.perMonthCents}`); const [perCallCents, perDayCents, perMonthCents] = caps.split(',').map((item) => Number(item.trim())); const systemInstructions = await ask('System instructions', templateDef.systemInstructions); return { building, template, capability, allowedModels, fallbackModel, perCallCents, perDayCents, perMonthCents, systemInstructions }; } finally { rl.close(); } } function policyYaml(answers: WizardAnswers): string { const id = `agentguard-${answers.template}-v1`; const allowed = answers.allowedModels.map((model) => ` - ${model}`).join('\n'); return `# AgentGuard Spend policy generated by agentguard wizard\nid: ${id}\nname: ${answers.template} policy\nversion: 1\neffectiveFrom: "${new Date().toISOString()}"\nmode: enforce\nrequiredCapability: ${answers.capability}\nscope:\n tenantId: my-tenant\nmodels:\n allowed:\n${allowed}\n fallback: ${answers.fallbackModel}\ncaps:\n # WHY: Per-call cap bounds one agent action.\n - amountCents: ${answers.perCallCents}\n window: per_call\n action: downgrade\n downgradeTo: ${answers.fallbackModel}\n reason: "Per-call budget reached, routing to fallback model"\n # WHY: Daily cap catches runaway loops and unexpected volume.\n - amountCents: ${answers.perDayCents}\n window: per_day\n action: block\n reason: "Daily budget reached"\n # WHY: Monthly cap keeps the finance view predictable.\n - amountCents: ${answers.perMonthCents}\n window: per_month\n action: block\n reason: "Monthly budget reached"\nsystemInstructions: |\n${indent(answers.systemInstructions, 2)}\n`; } function quickstartTs(answers: WizardAnswers, publicKeyHex?: string): string { const pubHex = publicKeyHex ?? ''; return `import OpenAI from 'openai';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { withSpendGuard, NdjsonDecisionLogStore, type SpendPolicy } from '@agentguard-run/spend';\n\nconst policy: SpendPolicy = {\n id: 'agentguard-${answers.template}-v1',\n name: '${answers.template} policy',\n scope: { tenantId: 'my-tenant' },\n caps: [\n { amountCents: ${answers.perCallCents}, window: 'per_call', action: 'downgrade', downgradeTo: '${answers.fallbackModel}' },\n { amountCents: ${answers.perDayCents}, window: 'per_day', action: 'block' },\n { amountCents: ${answers.perMonthCents}, window: 'per_month', action: 'block' },\n ],\n mode: 'enforce',\n requiredCapability: '${answers.capability}',\n version: 1,\n effectiveFrom: new Date().toISOString(),\n};\n\nconst openrouter = new OpenAI({\n baseURL: 'https://openrouter.ai/api/v1',\n apiKey: process.env.OPENROUTER_API_KEY,\n});\n\n// Local Ed25519 signing key generated by \`agentguard wizard\` (never leaves this machine).\nconst home = path.join(os.homedir(), '.agentguard');\nconst signingKeys = {\n privateKey: Uint8Array.from(Buffer.from(fs.readFileSync(path.join(home, 'signing-private.key'), 'utf-8').trim(), 'hex')),\n publicKey: Uint8Array.from(Buffer.from(fs.readFileSync(path.join(home, 'signing-public.key'), 'utf-8').trim(), 'hex')),\n};\nconst publicKeyHex = '${pubHex}' || Buffer.from(signingKeys.publicKey).toString('hex');\n\nexport const guardedClient = withSpendGuard(openrouter, {\n policy,\n scope: { tenantId: 'my-tenant', agentId: '${answers.template}' },\n capabilityClaim: '${answers.capability}',\n config: {\n // Persists signed receipts to ~/.agentguard/my-tenant/decisions.ndjson so\n // \`agentguard serve\` shows governed spend, $ saved, and a verifiable trail.\n logStore: new NdjsonDecisionLogStore('my-tenant', { publicKeyHex }),\n signingKeys,\n },\n});\n`; } function quickstartPy(answers: WizardAnswers): string { return `from openai import OpenAI\nfrom agentguard_spend import with_spend_guard\nfrom agentguard_spend.types import SpendPolicy, SpendCap\n\npolicy = SpendPolicy(\n id='agentguard-${answers.template}-v1', name='${answers.template} policy',\n scope={'tenantId': 'my-tenant'},\n caps=[\n SpendCap(amountCents=${answers.perCallCents}, window='per_call', action='downgrade', downgradeTo='${answers.fallbackModel}'),\n SpendCap(amountCents=${answers.perDayCents}, window='per_day', action='block'),\n SpendCap(amountCents=${answers.perMonthCents}, window='per_month', action='block'),\n ],\n mode='enforce', requiredCapability='${answers.capability}',\n version=1, effectiveFrom='2026-05-27T00:00:00Z',\n)\n\nopenrouter = OpenAI(base_url='https://openrouter.ai/api/v1')\nguarded_client = with_spend_guard(openrouter, policy=policy, scope={'tenantId': 'my-tenant', 'agentId': '${answers.template}'}, capability_claim='${answers.capability}')\n`; } function tsSnippet(answers: WizardAnswers): string { return `const response = await guardedClient.chat.completions.create({\n model: '${answers.allowedModels[0] ?? 'openai/gpt-4o-mini'}',\n messages: [{ role: 'user', content: 'Run the governed task.' }],\n});`; } function indent(value: string, spaces: number): string { const prefix = ' '.repeat(spaces); return value.split('\n').map((line) => prefix + line).join('\n'); } function valueAfter(argv: string[], flag: string): string | undefined { const index = argv.indexOf(flag); return index >= 0 ? argv[index + 1] : undefined; }