From e5dbe45c828d9261d0fd0374fc14642a43ba4a13 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 14 Mar 2026 05:09:47 -0500 Subject: [PATCH 1/2] refactor: split renderer.ts into per-command-group modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1266-line monolith → 7 focused modules under src/cli/renderer/: - common.ts (6 lines) — renderError - generate.ts (161 lines) — guardrail loop rendering - redteam.ts (435 lines) — red team scan/target/prompt rendering - runtime.ts (305 lines) — runtime scan + config management rendering - modelsecurity.ts (328 lines) — model security rendering - audit.ts (48 lines) — audit rendering - index.ts (6 lines) — barrel re-exports All command files updated to import from renderer/index.js. Closes #170 Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/audit.ts | 2 +- src/cli/commands/generate.ts | 2 +- src/cli/commands/list.ts | 2 +- src/cli/commands/modelsecurity.ts | 2 +- src/cli/commands/redteam.ts | 2 +- src/cli/commands/report.ts | 2 +- src/cli/commands/resume.ts | 2 +- src/cli/commands/runtime.ts | 2 +- src/cli/renderer.ts | 1272 ----------------------------- src/cli/renderer/audit.ts | 48 ++ src/cli/renderer/common.ts | 6 + src/cli/renderer/generate.ts | 161 ++++ src/cli/renderer/index.ts | 6 + src/cli/renderer/modelsecurity.ts | 328 ++++++++ src/cli/renderer/redteam.ts | 435 ++++++++++ src/cli/renderer/runtime.ts | 305 +++++++ 16 files changed, 1297 insertions(+), 1280 deletions(-) delete mode 100644 src/cli/renderer.ts create mode 100644 src/cli/renderer/audit.ts create mode 100644 src/cli/renderer/common.ts create mode 100644 src/cli/renderer/generate.ts create mode 100644 src/cli/renderer/index.ts create mode 100644 src/cli/renderer/modelsecurity.ts create mode 100644 src/cli/renderer/redteam.ts create mode 100644 src/cli/renderer/runtime.ts diff --git a/src/cli/commands/audit.ts b/src/cli/commands/audit.ts index a5cb120..0bb397a 100644 --- a/src/cli/commands/audit.ts +++ b/src/cli/commands/audit.ts @@ -14,7 +14,7 @@ import { renderConflicts, renderError, renderMetrics, -} from '../renderer.js'; +} from '../renderer/index.js'; /** Register the `audit` command — evaluate all topics in a profile. */ export function registerAuditCommand(program: Command): void { diff --git a/src/cli/commands/generate.ts b/src/cli/commands/generate.ts index 48e0144..6eb74d9 100644 --- a/src/cli/commands/generate.ts +++ b/src/cli/commands/generate.ts @@ -30,7 +30,7 @@ import { renderTestsAccumulated, renderTestsComposed, renderTopic, -} from '../renderer.js'; +} from '../renderer/index.js'; /** Register the `generate` command — starts a new guardrail generation loop. */ export function registerGenerateCommand(program: Command): void { diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 87dbca9..400e54f 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -1,7 +1,7 @@ import type { Command } from 'commander'; import { loadConfig } from '../../config/loader.js'; import { JsonFileStore } from '../../persistence/store.js'; -import { renderError, renderHeader, renderRunList } from '../renderer.js'; +import { renderError, renderHeader, renderRunList } from '../renderer/index.js'; /** Register the `list` command — lists all saved runs. */ export function registerListCommand(program: Command): void { diff --git a/src/cli/commands/modelsecurity.ts b/src/cli/commands/modelsecurity.ts index 436eefc..06e05b5 100644 --- a/src/cli/commands/modelsecurity.ts +++ b/src/cli/commands/modelsecurity.ts @@ -21,7 +21,7 @@ import { renderRuleList, renderViolationDetail, renderViolationList, -} from '../renderer.js'; +} from '../renderer/index.js'; /** Create an SdkModelSecurityService from config. */ async function createService() { diff --git a/src/cli/commands/redteam.ts b/src/cli/commands/redteam.ts index e07f636..6ba8cf9 100644 --- a/src/cli/commands/redteam.ts +++ b/src/cli/commands/redteam.ts @@ -23,7 +23,7 @@ import { renderTargetDetail, renderTargetList, renderVersionInfo, -} from '../renderer.js'; +} from '../renderer/index.js'; /** Create an SdkRedTeamService from config. */ async function createService() { diff --git a/src/cli/commands/report.ts b/src/cli/commands/report.ts index b0234e3..e539928 100644 --- a/src/cli/commands/report.ts +++ b/src/cli/commands/report.ts @@ -12,7 +12,7 @@ import { renderMetrics, renderTestResults, renderTopic, -} from '../renderer.js'; +} from '../renderer/index.js'; /** Register the `report` command — view detailed results for a run. */ export function registerReportCommand(program: Command): void { diff --git a/src/cli/commands/resume.ts b/src/cli/commands/resume.ts index 1b5f943..5c39f18 100644 --- a/src/cli/commands/resume.ts +++ b/src/cli/commands/resume.ts @@ -22,7 +22,7 @@ import { renderTestsAccumulated, renderTestsComposed, renderTopic, -} from '../renderer.js'; +} from '../renderer/index.js'; /** Register the `resume` command — resumes a paused or failed run. */ export function registerResumeCommand(program: Command): void { diff --git a/src/cli/commands/runtime.ts b/src/cli/commands/runtime.ts index 2e45b92..1c363e2 100644 --- a/src/cli/commands/runtime.ts +++ b/src/cli/commands/runtime.ts @@ -22,7 +22,7 @@ import { renderScanLogList, renderTopicDetail, renderTopicList, -} from '../renderer.js'; +} from '../renderer/index.js'; function renderScanResult(result: RuntimeScanResult): void { const actionColor = result.action === 'block' ? chalk.red : chalk.green; diff --git a/src/cli/renderer.ts b/src/cli/renderer.ts deleted file mode 100644 index 2907076..0000000 --- a/src/cli/renderer.ts +++ /dev/null @@ -1,1272 +0,0 @@ -import chalk from 'chalk'; -import type { - ModelSecurityEvaluation, - ModelSecurityFile, - ModelSecurityGroup, - ModelSecurityRule, - ModelSecurityRuleInstance, - ModelSecurityScan, - ModelSecurityViolation, -} from '../airs/types.js'; -import type { AuditResult, ConflictPair, ProfileTopic } from '../audit/types.js'; -import type { - AnalysisReport, - CustomTopic, - EfficacyMetrics, - IterationResult, - RunState, - TestResult, -} from '../core/types.js'; -import type { RunStateSummary } from '../persistence/types.js'; - -/** Render the application banner. */ -export function renderHeader(): void { - console.log(chalk.bold.cyan('\n Prisma AIRS Guardrail Generator')); - console.log(chalk.dim(' Iterative custom topic refinement\n')); -} - -/** Render iteration number header. */ -export function renderIterationStart(iteration: number): void { - console.log(chalk.bold(`\n━━━ Iteration ${iteration} ━━━`)); -} - -/** Render a topic's name, description, and examples. */ -export function renderTopic(topic: CustomTopic): void { - console.log(chalk.bold(' Topic:')); - console.log(` Name: ${chalk.white(topic.name)}`); - console.log(` Desc: ${chalk.white(topic.description)}`); - console.log(' Examples:'); - for (const ex of topic.examples) { - console.log(` ${chalk.dim('•')} ${ex}`); - } -} - -/** Render companion allow topic info (two-phase generation). */ -export function renderCompanionTopic(topic: CustomTopic): void { - console.log(chalk.bold(' Companion Allow Topic:')); - console.log(` Name: ${chalk.white(topic.name)}`); - console.log(` Desc: ${chalk.white(topic.description)}`); -} - -/** Render a scan progress bar with percentage. */ -export function renderTestProgress(completed: number, total: number): void { - const pct = Math.round((completed / total) * 100); - const bar = '█'.repeat(Math.round(pct / 5)) + '░'.repeat(20 - Math.round(pct / 5)); - process.stdout.write(`\r Scanning: ${bar} ${pct}% (${completed}/${total})`); - if (completed === total) console.log(); -} - -/** Render efficacy metrics with color-coded coverage. */ -export function renderMetrics(metrics: EfficacyMetrics): void { - const coverageColor = - metrics.coverage >= 0.9 ? chalk.green : metrics.coverage >= 0.7 ? chalk.yellow : chalk.red; - - console.log(chalk.bold('\n Metrics:')); - console.log(` Coverage: ${coverageColor(`${(metrics.coverage * 100).toFixed(1)}%`)}`); - console.log(` Accuracy: ${(metrics.accuracy * 100).toFixed(1)}%`); - console.log(` TPR: ${(metrics.truePositiveRate * 100).toFixed(1)}%`); - console.log(` TNR: ${(metrics.trueNegativeRate * 100).toFixed(1)}%`); - console.log(` F1 Score: ${metrics.f1Score.toFixed(3)}`); - let countsLine = ` TP: ${metrics.truePositives} TN: ${metrics.trueNegatives} FP: ${metrics.falsePositives} FN: ${metrics.falseNegatives}`; - if (metrics.regressionCount > 0) { - countsLine += chalk.red(` Regressions: ${metrics.regressionCount}`); - } - console.log(chalk.dim(countsLine)); -} - -/** Render analysis summary with FP/FN pattern details. */ -export function renderAnalysis(analysis: AnalysisReport): void { - console.log(chalk.bold('\n Analysis:')); - console.log(` ${analysis.summary}`); - if (analysis.falsePositivePatterns.length > 0) { - console.log(chalk.yellow(' FP patterns:')); - for (const p of analysis.falsePositivePatterns) { - console.log(` ${chalk.dim('•')} ${p}`); - } - } - if (analysis.falseNegativePatterns.length > 0) { - console.log(chalk.red(' FN patterns:')); - for (const p of analysis.falseNegativePatterns) { - console.log(` ${chalk.dim('•')} ${p}`); - } - } -} - -/** Render per-test-case results table. */ -export function renderTestResults(results: TestResult[]): void { - console.log(chalk.bold('\n Test Results:')); - for (const r of results) { - const icon = r.correct ? chalk.green('✓') : chalk.red('✗'); - const expected = r.testCase.expectedTriggered ? 'triggered' : 'safe'; - const actual = r.actualTriggered ? 'triggered' : 'safe'; - const status = r.correct ? '' : chalk.red(` (expected ${expected}, got ${actual})`); - console.log(` ${icon} ${r.testCase.prompt}${status}`); - } -} - -/** Render loop completion summary with best iteration and run ID. */ -export function renderLoopComplete(runState: RunState): void { - const best = runState.iterations[runState.bestIteration - 1]; - console.log(chalk.bold.green('\n━━━ Complete ━━━')); - console.log( - ` Best iteration: ${runState.bestIteration} (coverage: ${(runState.bestCoverage * 100).toFixed(1)}%)`, - ); - console.log(` Total iterations: ${runState.iterations.length}`); - if (best) { - renderTopic(best.topic); - } - console.log(`\n Run ID: ${chalk.dim(runState.id)}\n`); -} - -/** Render a list of saved runs with status, coverage, and topic description. */ -export function renderRunList(runs: RunStateSummary[]): void { - if (runs.length === 0) { - console.log(chalk.dim(' No saved runs found.\n')); - return; - } - console.log(chalk.bold('\n Saved Runs:\n')); - for (const run of runs) { - const statusColor = - run.status === 'completed' - ? chalk.green - : run.status === 'running' - ? chalk.blue - : run.status === 'paused' - ? chalk.yellow - : chalk.red; - console.log(` ${chalk.dim(run.id)}`); - console.log( - ` Status: ${statusColor(run.status)} Coverage: ${(run.bestCoverage * 100).toFixed(1)}% Iterations: ${run.currentIteration}`, - ); - console.log(` Topic: ${run.topicDescription}`); - console.log(` Created: ${run.createdAt}\n`); - } -} - -/** Render an error message to stderr. */ -export function renderError(message: string): void { - console.error(chalk.red(`\n Error: ${message}\n`)); -} - -/** Render a one-line iteration summary with duration and coverage. */ -export function renderIterationSummary(result: IterationResult): void { - const coverageColor = - result.metrics.coverage >= 0.9 - ? chalk.green - : result.metrics.coverage >= 0.7 - ? chalk.yellow - : chalk.red; - console.log( - ` ${chalk.dim(`[${result.durationMs}ms]`)} Coverage: ${coverageColor(`${(result.metrics.coverage * 100).toFixed(1)}%`)} | Accuracy: ${(result.metrics.accuracy * 100).toFixed(1)}%`, - ); -} - -/** Render memory loading status (count of learnings loaded or "none found"). */ -export function renderMemoryLoaded(learningCount: number): void { - if (learningCount > 0) { - console.log(chalk.cyan(` Memory: loaded ${learningCount} learnings from previous runs`)); - } else { - console.log(chalk.dim(' Memory: no previous learnings found')); - } -} - -/** Render count of learnings extracted from the current run. */ -export function renderMemoryExtracted(learningCount: number): void { - console.log(chalk.cyan(` Memory: extracted ${learningCount} learnings from this run`)); -} - -// --------------------------------------------------------------------------- -// Red Team rendering -// --------------------------------------------------------------------------- - -/** Render the red team banner. */ -export function renderRedteamHeader(): void { - console.log(chalk.bold.red('\n Prisma AIRS — AI Red Team')); - console.log(chalk.dim(' Adversarial scan operations\n')); -} - -type ChalkFn = (text: string) => string; - -/** Severity → chalk color mapping. */ -function severityColor(severity: string): ChalkFn { - switch (severity.toUpperCase()) { - case 'CRITICAL': - return chalk.red; - case 'HIGH': - return chalk.magenta; - case 'MEDIUM': - return chalk.yellow; - case 'LOW': - return chalk.cyan; - default: - return chalk.dim; - } -} - -/** Status → chalk color mapping. */ -function statusColor(status: string): ChalkFn { - switch (status) { - case 'COMPLETED': - return chalk.green; - case 'RUNNING': - return chalk.blue; - case 'QUEUED': - case 'INIT': - return chalk.yellow; - case 'FAILED': - case 'ABORTED': - return chalk.red; - case 'PARTIALLY_COMPLETE': - return chalk.yellow; - default: - return chalk.white; - } -} - -/** Render a scan's status summary. */ -export function renderScanStatus(job: { - uuid: string; - name: string; - status: string; - jobType: string; - targetName?: string; - score?: number | null; - asr?: number | null; - completed?: number | null; - total?: number | null; -}): void { - console.log(chalk.bold(' Scan Status:')); - console.log(` ID: ${chalk.dim(job.uuid)}`); - console.log(` Name: ${job.name}`); - console.log(` Type: ${job.jobType}`); - if (job.targetName) console.log(` Target: ${job.targetName}`); - console.log(` Status: ${statusColor(job.status)(job.status)}`); - if (job.total != null && job.completed != null) { - console.log(` Progress: ${job.completed}/${job.total}`); - } - if (job.score != null) console.log(` Score: ${job.score}`); - if (job.asr != null) console.log(` ASR: ${job.asr.toFixed(1)}%`); - console.log(); -} - -/** Render a table of scans. */ -export function renderScanList( - jobs: Array<{ - uuid: string; - name: string; - status: string; - jobType: string; - score?: number | null; - createdAt?: string | null; - }>, -): void { - if (jobs.length === 0) { - console.log(chalk.dim(' No scans found.\n')); - return; - } - console.log(chalk.bold('\n Recent Scans:\n')); - for (const job of jobs) { - console.log(` ${chalk.dim(job.uuid)}`); - console.log( - ` ${job.name} ${statusColor(job.status)(job.status)} ${job.jobType}${job.score != null ? ` score: ${job.score}` : ''}`, - ); - if (job.createdAt) console.log(` ${chalk.dim(job.createdAt)}`); - console.log(); - } -} - -/** Render a static scan report. */ -export function renderStaticReport(report: { - score?: number | null; - asr?: number | null; - severityBreakdown: Array<{ severity: string; successful: number; failed: number }>; - reportSummary?: string | null; - categories: Array<{ - id: string; - displayName: string; - asr: number; - successful: number; - failed: number; - total: number; - }>; -}): void { - console.log(chalk.bold('\n Static Scan Report:')); - if (report.score != null) console.log(` Score: ${report.score}`); - if (report.asr != null) console.log(` ASR: ${report.asr.toFixed(1)}%`); - - if (report.severityBreakdown.length > 0) { - console.log(chalk.bold('\n Severity Breakdown:')); - for (const s of report.severityBreakdown) { - const color = severityColor(s.severity); - console.log( - ` ${color(s.severity.padEnd(10))} ${chalk.red(`${s.successful} bypassed`)} ${chalk.green(`${s.failed} blocked`)}`, - ); - } - } - - if (report.categories.length > 0) { - console.log(chalk.bold('\n Categories:')); - for (const c of report.categories) { - console.log( - ` ${c.displayName.padEnd(30)} ASR: ${c.asr.toFixed(1)}% (${c.successful}/${c.total})`, - ); - } - } - - if (report.reportSummary) { - console.log(chalk.bold('\n Summary:')); - console.log(` ${report.reportSummary}`); - } - console.log(); -} - -/** Render a custom attack report. */ -export function renderCustomReport(report: { - totalPrompts: number; - totalAttacks: number; - totalThreats: number; - score: number; - asr: number; - promptSets: Array<{ - promptSetName: string; - totalPrompts: number; - totalThreats: number; - threatRate: number; - }>; -}): void { - console.log(chalk.bold('\n Custom Attack Report:')); - console.log(` Score: ${report.score}`); - console.log(` ASR: ${report.asr.toFixed(1)}%`); - console.log(` Attacks: ${report.totalAttacks} Threats: ${report.totalThreats}`); - - if (report.promptSets.length > 0) { - console.log(chalk.bold('\n Prompt Sets:')); - for (const ps of report.promptSets) { - console.log( - ` ${ps.promptSetName.padEnd(40)} ${ps.totalThreats}/${ps.totalPrompts} threats (${ps.threatRate.toFixed(1)}%)`, - ); - } - } - console.log(); -} - -/** Render attack list with severity coloring. */ -export function renderAttackList( - attacks: Array<{ - name: string; - severity?: string; - category?: string; - successful: boolean; - }>, -): void { - if (attacks.length === 0) { - console.log(chalk.dim(' No attacks found.\n')); - return; - } - console.log(chalk.bold('\n Attacks:\n')); - for (const a of attacks) { - const sev = a.severity - ? severityColor(a.severity)(a.severity.padEnd(10)) - : chalk.dim('N/A'.padEnd(10)); - const result = a.successful ? chalk.red('BYPASSED') : chalk.green('BLOCKED'); - console.log( - ` ${sev} ${result} ${a.name}${a.category ? chalk.dim(` [${a.category}]`) : ''}`, - ); - } - console.log(); -} - -/** Render custom attack list (prompt-level results). */ -export function renderCustomAttackList( - attacks: Array<{ - promptText: string; - goal?: string; - threat: boolean; - asr?: number; - promptSetName?: string; - }>, -): void { - if (attacks.length === 0) { - console.log(chalk.dim(' No custom attacks found.\n')); - return; - } - console.log(chalk.bold('\n Custom Attacks:\n')); - for (const a of attacks) { - const result = a.threat ? chalk.red('THREAT') : chalk.green('SAFE'); - const prompt = a.promptText.length > 80 ? `${a.promptText.substring(0, 77)}...` : a.promptText; - const asrStr = a.asr != null ? chalk.dim(` ASR: ${a.asr.toFixed(1)}%`) : ''; - console.log(` ${result}${asrStr} ${prompt}`); - if (a.goal) console.log(` ${chalk.dim(a.goal)}`); - } - console.log(); -} - -/** Render target list. */ -export function renderTargetList( - targets: Array<{ - uuid: string; - name: string; - status: string; - targetType?: string; - active: boolean; - }>, -): void { - if (targets.length === 0) { - console.log(chalk.dim(' No targets found.\n')); - return; - } - console.log(chalk.bold('\n Targets:\n')); - for (const t of targets) { - console.log(` ${chalk.dim(t.uuid)}`); - console.log( - ` ${t.name} ${statusColor(t.active ? 'COMPLETED' : 'FAILED')(t.active ? 'active' : 'inactive')}${t.targetType ? ` type: ${t.targetType}` : ''}`, - ); - } - console.log(); -} - -/** Render attack category tree. */ -export function renderCategories( - categories: Array<{ - id: string; - displayName: string; - description?: string; - subCategories: Array<{ id: string; displayName: string; description?: string }>; - }>, -): void { - if (categories.length === 0) { - console.log(chalk.dim(' No categories found.\n')); - return; - } - console.log(chalk.bold('\n Attack Categories:\n')); - for (const c of categories) { - console.log( - ` ${chalk.bold(c.displayName)}${c.description ? chalk.dim(` — ${c.description}`) : ''}`, - ); - for (const sc of c.subCategories) { - console.log( - ` ${chalk.dim('•')} ${sc.displayName}${sc.description ? chalk.dim(` — ${sc.description}`) : ''}`, - ); - } - console.log(); - } -} - -/** Render prompt set list. */ -export function renderPromptSetList( - promptSets: Array<{ uuid: string; name: string; active: boolean }>, -): void { - if (promptSets.length === 0) { - console.log(chalk.dim(' No prompt sets found.\n')); - return; - } - console.log(chalk.bold('\n Prompt Sets:\n')); - for (const ps of promptSets) { - console.log(` ${chalk.dim(ps.uuid)}`); - console.log( - ` ${ps.name} ${statusColor(ps.active ? 'COMPLETED' : 'FAILED')(ps.active ? 'active' : 'inactive')}`, - ); - } - console.log(); -} - -/** Render target detail. */ -export function renderTargetDetail(target: { - uuid: string; - name: string; - status: string; - targetType?: string; - active: boolean; - connectionParams?: Record; - background?: Record; - metadata?: Record; -}): void { - console.log(chalk.bold('\n Target Detail:\n')); - console.log(` UUID: ${chalk.dim(target.uuid)}`); - console.log(` Name: ${target.name}`); - console.log( - ` Status: ${statusColor(target.active ? 'COMPLETED' : 'FAILED')(target.active ? 'active' : 'inactive')}`, - ); - if (target.targetType) console.log(` Type: ${target.targetType}`); - if (target.connectionParams) { - console.log(chalk.bold('\n Connection:')); - for (const [k, v] of Object.entries(target.connectionParams)) { - console.log(` ${k}: ${chalk.dim(String(v))}`); - } - } - if (target.background) { - console.log(chalk.bold('\n Background:')); - for (const [k, v] of Object.entries(target.background)) { - if (v != null) console.log(` ${k}: ${chalk.dim(String(v))}`); - } - } - if (target.metadata) { - console.log(chalk.bold('\n Metadata:')); - for (const [k, v] of Object.entries(target.metadata)) { - if (v != null) console.log(` ${k}: ${chalk.dim(String(v))}`); - } - } - console.log(); -} - -/** Render prompt set detail. */ -export function renderPromptSetDetail(ps: { - uuid: string; - name: string; - active: boolean; - archive: boolean; - description?: string; - createdAt?: string; - updatedAt?: string; -}): void { - console.log(chalk.bold('\n Prompt Set Detail:\n')); - console.log(` UUID: ${chalk.dim(ps.uuid)}`); - console.log(` Name: ${ps.name}`); - console.log( - ` Status: ${statusColor(ps.active ? 'COMPLETED' : 'FAILED')(ps.active ? 'active' : 'inactive')}`, - ); - console.log(` Archived: ${ps.archive ? 'yes' : 'no'}`); - if (ps.description) console.log(` Description: ${ps.description}`); - if (ps.createdAt) console.log(` Created: ${chalk.dim(ps.createdAt)}`); - if (ps.updatedAt) console.log(` Updated: ${chalk.dim(ps.updatedAt)}`); - console.log(); -} - -/** Render prompt set version info. */ -export function renderVersionInfo(info: { - uuid: string; - version: number; - stats: { total: number; active: number; inactive: number }; -}): void { - console.log(chalk.bold('\n Version Info:\n')); - console.log(` Version: ${info.version}`); - console.log(` Total: ${info.stats.total}`); - console.log(` Active: ${chalk.green(String(info.stats.active))}`); - console.log(` Inactive: ${chalk.dim(String(info.stats.inactive))}`); - console.log(); -} - -/** Render a list of prompts. */ -export function renderPromptList( - prompts: Array<{ - uuid: string; - prompt: string; - goal?: string; - active: boolean; - }>, -): void { - if (prompts.length === 0) { - console.log(chalk.dim(' No prompts found.\n')); - return; - } - console.log(chalk.bold('\n Prompts:\n')); - for (const p of prompts) { - const status = p.active ? chalk.green('active') : chalk.dim('inactive'); - const text = p.prompt.length > 80 ? `${p.prompt.substring(0, 77)}...` : p.prompt; - console.log(` ${chalk.dim(p.uuid)} ${status}`); - console.log(` ${text}`); - if (p.goal) console.log(` ${chalk.dim(`Goal: ${p.goal}`)}`); - } - console.log(); -} - -/** Render prompt detail. */ -export function renderPromptDetail(p: { - uuid: string; - prompt: string; - goal?: string; - active: boolean; - promptSetId: string; -}): void { - console.log(chalk.bold('\n Prompt Detail:\n')); - console.log(` UUID: ${chalk.dim(p.uuid)}`); - console.log(` Set UUID: ${chalk.dim(p.promptSetId)}`); - console.log(` Status: ${p.active ? chalk.green('active') : chalk.dim('inactive')}`); - console.log(` Prompt: ${p.prompt}`); - if (p.goal) console.log(` Goal: ${p.goal}`); - console.log(); -} - -/** Render property names list. */ -export function renderPropertyNames(names: Array<{ name: string }>): void { - if (names.length === 0) { - console.log(chalk.dim(' No property names found.\n')); - return; - } - console.log(chalk.bold('\n Property Names:\n')); - for (const n of names) { - console.log(` ${chalk.dim('•')} ${n.name}`); - } - console.log(); -} - -/** Render property values. */ -export function renderPropertyValues(values: Array<{ name: string; value: string }>): void { - if (values.length === 0) { - console.log(chalk.dim(' No property values found.\n')); - return; - } - console.log(chalk.bold('\n Property Values:\n')); - for (const v of values) { - console.log(` ${v.name}: ${chalk.dim(v.value)}`); - } - console.log(); -} - -/** Render polling progress inline. */ -export function renderScanProgress(job: { - status: string; - completed?: number | null; - total?: number | null; -}): void { - if (job.total != null && job.completed != null && job.total > 0) { - const pct = Math.round((job.completed / job.total) * 100); - const bar = '█'.repeat(Math.round(pct / 5)) + '░'.repeat(20 - Math.round(pct / 5)); - process.stdout.write( - `\r ${statusColor(job.status)(job.status)} ${bar} ${pct}% (${job.completed}/${job.total})`, - ); - } else { - process.stdout.write(`\r ${statusColor(job.status)(job.status)}...`); - } -} - -// --------------------------------------------------------------------------- -// Guardrail loop rendering -// --------------------------------------------------------------------------- - -/** Render test composition summary (carried failures + regression + generated). */ -export function renderTestsComposed( - generated: number, - carriedFailures: number, - regressionTier: number, - total: number, -): void { - console.log( - chalk.dim( - ` Tests: ${generated} generated, ${carriedFailures} carried failures, ${regressionTier} regression, ${total} total`, - ), - ); -} - -/** Render accumulated test count with optional dropped info. */ -export function renderTestsAccumulated( - newCount: number, - totalCount: number, - droppedCount: number, -): void { - let msg = ` Tests: ${newCount} new, ${totalCount} total (accumulated)`; - if (droppedCount > 0) { - msg += chalk.yellow(` (${droppedCount} dropped by cap)`); - } - console.log(chalk.dim(msg)); -} - -// --------------------------------------------------------------------------- -// Audit rendering -// --------------------------------------------------------------------------- - -/** Render profile topics discovered during audit. */ -export function renderAuditTopics(topics: ProfileTopic[]): void { - for (const t of topics) { - const actionColor = t.action === 'block' ? chalk.red : chalk.green; - console.log(` ${actionColor(`[${t.action}]`)} ${chalk.bold(t.topicName)}`); - if (t.description) console.log(chalk.dim(` ${t.description}`)); - } - console.log(); -} - -/** Render audit completion with per-topic metrics table. */ -export function renderAuditComplete(result: AuditResult): void { - console.log(chalk.bold('\n Per-Topic Results:\n')); - console.log( - chalk.dim(' Topic Coverage TPR TNR Accuracy Tests'), - ); - console.log(chalk.dim(` ${'─'.repeat(72)}`)); - for (const tr of result.topics) { - const m = tr.metrics; - const name = tr.topic.topicName.padEnd(30); - const cov = `${(m.coverage * 100).toFixed(0)}%`.padStart(6); - const tpr = `${(m.truePositiveRate * 100).toFixed(0)}%`.padStart(6); - const tnr = `${(m.trueNegativeRate * 100).toFixed(0)}%`.padStart(6); - const acc = `${(m.accuracy * 100).toFixed(0)}%`.padStart(6); - const tests = String(tr.testResults.length).padStart(5); - const covColor = m.coverage >= 0.9 ? chalk.green : m.coverage >= 0.5 ? chalk.yellow : chalk.red; - console.log(` ${name} ${covColor(cov)} ${tpr} ${tnr} ${acc} ${tests}`); - } -} - -/** Render detected cross-topic conflicts. */ -export function renderConflicts(conflicts: ConflictPair[]): void { - console.log(chalk.bold.yellow(`\n Conflicts Detected: ${conflicts.length}\n`)); - for (const c of conflicts) { - console.log(chalk.yellow(` ${c.topicA} ↔ ${c.topicB}`)); - console.log(chalk.dim(` ${c.description}`)); - for (const e of c.evidence.slice(0, 3)) { - console.log(chalk.dim(` • "${e}"`)); - } - if (c.evidence.length > 3) { - console.log(chalk.dim(` ...and ${c.evidence.length - 3} more`)); - } - } - console.log(); -} - -// --------------------------------------------------------------------------- -// Runtime Config rendering — profiles, topics, api keys, etc. -// --------------------------------------------------------------------------- - -/** Render the runtime config banner. */ -export function renderRuntimeConfigHeader(): void { - console.log(chalk.bold.cyan('\n Prisma AIRS — Runtime Configuration')); - console.log(chalk.dim(' Security profile and topic management\n')); -} - -/** Render security profile list. */ -export function renderProfileList( - profiles: Array<{ - profileId: string; - profileName: string; - revision?: number; - active?: boolean; - lastModifiedTs?: string; - }>, -): void { - if (profiles.length === 0) { - console.log(chalk.dim(' No profiles found.\n')); - return; - } - console.log(chalk.bold('\n Security Profiles:\n')); - for (const p of profiles) { - console.log(` ${chalk.dim(p.profileId)}`); - const status = p.active ? chalk.green('active') : chalk.yellow('inactive'); - const rev = p.revision != null ? chalk.dim(` rev:${p.revision}`) : ''; - console.log(` ${p.profileName} ${status}${rev}`); - } - console.log(); -} - -/** Render security profile detail. */ -export function renderProfileDetail(profile: { - profileId: string; - profileName: string; - revision?: number; - active?: boolean; - createdBy?: string; - updatedBy?: string; - lastModifiedTs?: string; - policy?: Record; -}): void { - console.log(chalk.bold('\n Profile Detail:\n')); - console.log(` ID: ${chalk.dim(profile.profileId)}`); - console.log(` Name: ${profile.profileName}`); - console.log(` Status: ${profile.active ? chalk.green('active') : chalk.yellow('inactive')}`); - if (profile.revision != null) console.log(` Revision: ${profile.revision}`); - if (profile.createdBy) console.log(` Created: ${chalk.dim(profile.createdBy)}`); - if (profile.updatedBy) console.log(` Updated: ${chalk.dim(profile.updatedBy)}`); - if (profile.lastModifiedTs) console.log(` Modified: ${chalk.dim(profile.lastModifiedTs)}`); - if (profile.policy) { - console.log( - ` Policy: ${chalk.dim(JSON.stringify(profile.policy, null, 2).slice(0, 500))}`, - ); - } - console.log(); -} - -/** Render custom topic list. */ -export function renderTopicList( - topics: Array<{ - topic_id?: string; - topic_name: string; - description?: string; - revision?: number; - }>, -): void { - if (topics.length === 0) { - console.log(chalk.dim(' No topics found.\n')); - return; - } - console.log(chalk.bold('\n Custom Topics:\n')); - for (const t of topics) { - console.log(` ${chalk.dim(t.topic_id)}`); - const rev = t.revision != null ? chalk.dim(` rev:${t.revision}`) : ''; - const desc = t.description ? chalk.dim(` — ${t.description.slice(0, 80)}`) : ''; - console.log(` ${t.topic_name}${rev}${desc}`); - } - console.log(); -} - -/** Render custom topic detail. */ -export function renderTopicDetail(topic: { - topic_id?: string; - topic_name: string; - description?: string; - examples?: string[]; - revision?: number; - created_by?: string; - updated_by?: string; - last_modified_ts?: string; -}): void { - console.log(chalk.bold('\n Topic Detail:\n')); - console.log(` ID: ${chalk.dim(topic.topic_id)}`); - console.log(` Name: ${topic.topic_name}`); - if (topic.revision != null) console.log(` Revision: ${topic.revision}`); - if (topic.description) console.log(` Description: ${topic.description}`); - if (topic.examples?.length) { - console.log(' Examples:'); - for (const ex of topic.examples) { - console.log(` ${chalk.dim('•')} ${ex}`); - } - } - if (topic.created_by) console.log(` Created: ${chalk.dim(topic.created_by)}`); - if (topic.updated_by) console.log(` Updated: ${chalk.dim(topic.updated_by)}`); - if (topic.last_modified_ts) console.log(` Modified: ${chalk.dim(topic.last_modified_ts)}`); - console.log(); -} - -/** Render API key list. */ -export function renderApiKeyList( - keys: Array<{ id: string; name: string; createdAt?: string; expiresAt?: string }>, -): void { - if (keys.length === 0) { - console.log(chalk.dim(' No API keys found.\n')); - return; - } - console.log(chalk.bold('\n API Keys:\n')); - for (const k of keys) { - console.log(` ${chalk.dim(k.id)}`); - const expires = k.expiresAt ? chalk.dim(` expires: ${k.expiresAt}`) : ''; - console.log(` ${k.name}${expires}`); - } - console.log(); -} - -/** Render API key detail. */ -export function renderApiKeyDetail(key: { - id: string; - name: string; - createdAt?: string; - expiresAt?: string; -}): void { - console.log(chalk.bold('\n API Key Detail:\n')); - console.log(` ID: ${chalk.dim(key.id)}`); - console.log(` Name: ${key.name}`); - if (key.createdAt) console.log(` Created: ${chalk.dim(key.createdAt)}`); - if (key.expiresAt) console.log(` Expires: ${chalk.dim(key.expiresAt)}`); - console.log(); -} - -/** Render customer app list. */ -export function renderCustomerAppList( - apps: Array<{ id?: string; name: string; description?: string }>, -): void { - if (apps.length === 0) { - console.log(chalk.dim(' No customer apps found.\n')); - return; - } - console.log(chalk.bold('\n Customer Apps:\n')); - for (const a of apps) { - if (a.id) console.log(` ${chalk.dim(a.id)}`); - const desc = a.description ? chalk.dim(` — ${a.description.slice(0, 80)}`) : ''; - console.log(` ${a.name}${desc}`); - } - console.log(); -} - -/** Render customer app detail. */ -export function renderCustomerAppDetail(app: { - id?: string; - name: string; - description?: string; - raw: Record; -}): void { - console.log(chalk.bold('\n Customer App Detail:\n')); - if (app.id) console.log(` ID: ${chalk.dim(app.id)}`); - console.log(` Name: ${app.name}`); - if (app.description) console.log(` Desc: ${app.description}`); - console.log(` Data: ${chalk.dim(JSON.stringify(app.raw, null, 2).slice(0, 500))}`); - console.log(); -} - -/** Render deployment profile list. */ -export function renderDeploymentProfileList( - profiles: Array<{ raw: Record }>, -): void { - if (profiles.length === 0) { - console.log(chalk.dim(' No deployment profiles found.\n')); - return; - } - console.log(chalk.bold('\n Deployment Profiles:\n')); - for (const p of profiles) { - const name = (p.raw.dp_name ?? p.raw.profile_name ?? p.raw.name ?? 'unknown') as string; - const status = p.raw.status as string | undefined; - const authCode = p.raw.auth_code as string | undefined; - const statusColor = status === 'active' ? chalk.green : chalk.dim; - console.log( - ` ${name}${status ? ` ${statusColor(status)}` : ''}${authCode ? ` ${chalk.dim(authCode)}` : ''}`, - ); - } - console.log(); -} - -/** Render DLP profile list. */ -export function renderDlpProfileList(profiles: Array<{ raw: Record }>): void { - if (profiles.length === 0) { - console.log(chalk.dim(' No DLP profiles found.\n')); - return; - } - console.log(chalk.bold('\n DLP Profiles:\n')); - for (const p of profiles) { - const name = (p.raw.name ?? p.raw.profile_name ?? 'unknown') as string; - const uuid = (p.raw.uuid ?? '') as string; - if (uuid) console.log(` ${chalk.dim(uuid)}`); - console.log(` ${name}`); - } - console.log(); -} - -/** Render scan log results. */ -export function renderScanLogList(results: Record[], pageToken?: string): void { - if (results.length === 0) { - console.log(chalk.dim(' No scan logs found.\n')); - return; - } - console.log(chalk.bold(`\n Scan Logs (${results.length} results):\n`)); - for (const r of results) { - const action = (r.action ?? r.verdict) as string | undefined; - const app = r.app_name as string | undefined; - const profile = r.profile_name as string | undefined; - const ts = (r.received_ts ?? r.timestamp) as string | undefined; - const scanId = r.scan_id as string | undefined; - const actionColor = action === 'block' ? chalk.red : chalk.green; - if (scanId) console.log(` ${chalk.dim(scanId)}`); - console.log( - ` ${ts ? chalk.dim(ts) : ''} ${action ? actionColor(action) : ''} ${profile ? `[${profile}]` : ''} ${app ?? ''}`, - ); - } - if (pageToken) { - console.log(chalk.dim(`\n Page token: ${pageToken}`)); - } - console.log(); -} - -// --------------------------------------------------------------------------- -// Model Security rendering -// --------------------------------------------------------------------------- - -/** Render the model security banner. */ -export function renderModelSecurityHeader(): void { - console.log(chalk.bold.blue('\n Prisma AIRS — Model Security')); - console.log(chalk.dim(' ML model supply chain security\n')); -} - -/** Render security group list. */ -export function renderGroupList(groups: ModelSecurityGroup[]): void { - if (groups.length === 0) { - console.log(chalk.dim(' No security groups found.\n')); - return; - } - console.log(chalk.bold('\n Security Groups:\n')); - for (const g of groups) { - console.log(` ${chalk.dim(g.uuid)}`); - const stateColor = g.state === 'ACTIVE' ? chalk.green : chalk.yellow; - console.log(` ${g.name} ${stateColor(g.state)} source: ${chalk.dim(g.sourceType)}`); - } - console.log(); -} - -/** Render security group detail. */ -export function renderGroupDetail(group: ModelSecurityGroup): void { - console.log(chalk.bold('\n Security Group Detail:\n')); - console.log(` UUID: ${chalk.dim(group.uuid)}`); - console.log(` Name: ${group.name}`); - console.log(` Description: ${group.description || chalk.dim('(none)')}`); - console.log(` Source Type: ${group.sourceType}`); - const stateColor = group.state === 'ACTIVE' ? chalk.green : chalk.yellow; - console.log(` State: ${stateColor(group.state)}`); - console.log(` Created: ${chalk.dim(group.createdAt)}`); - console.log(` Updated: ${chalk.dim(group.updatedAt)}`); - console.log(); -} - -/** Render security rule list. */ -export function renderRuleList(rules: ModelSecurityRule[]): void { - if (rules.length === 0) { - console.log(chalk.dim(' No security rules found.\n')); - return; - } - console.log(chalk.bold('\n Security Rules:\n')); - for (const r of rules) { - console.log(` ${chalk.dim(r.uuid)}`); - console.log( - ` ${r.name} type: ${chalk.dim(r.ruleType)} default: ${chalk.dim(r.defaultState)}`, - ); - console.log(` ${chalk.dim(r.description)}`); - console.log(` Sources: ${r.compatibleSources.map((s) => chalk.dim(s)).join(', ')}`); - } - console.log(); -} - -/** Render security rule detail. */ -export function renderRuleDetail(rule: ModelSecurityRule): void { - console.log(chalk.bold('\n Security Rule Detail:\n')); - console.log(` UUID: ${chalk.dim(rule.uuid)}`); - console.log(` Name: ${rule.name}`); - console.log(` Description: ${rule.description}`); - console.log(` Rule Type: ${rule.ruleType}`); - console.log(` Default State: ${rule.defaultState}`); - console.log(` Sources: ${rule.compatibleSources.join(', ')}`); - - if (rule.remediation.description) { - console.log(chalk.bold('\n Remediation:')); - console.log(` ${rule.remediation.description}`); - if (rule.remediation.steps.length > 0) { - for (const step of rule.remediation.steps) { - console.log(` ${chalk.dim('•')} ${step}`); - } - } - if (rule.remediation.url) { - console.log(` ${chalk.dim(rule.remediation.url)}`); - } - } - - if (rule.editableFields.length > 0) { - console.log(chalk.bold('\n Editable Fields:')); - for (const f of rule.editableFields) { - console.log(` ${f.displayName} (${chalk.dim(f.attributeName)}): ${f.displayType}`); - if (f.description) console.log(` ${chalk.dim(f.description)}`); - } - } - console.log(); -} - -/** Render rule instance list. */ -export function renderRuleInstanceList(instances: ModelSecurityRuleInstance[]): void { - if (instances.length === 0) { - console.log(chalk.dim(' No rule instances found.\n')); - return; - } - console.log(chalk.bold('\n Rule Instances:\n')); - for (const ri of instances) { - const stateColor = - ri.state === 'BLOCKING' ? chalk.red : ri.state === 'ALLOWING' ? chalk.green : chalk.dim; - const ruleName = (ri.rule as { name?: string })?.name ?? ri.securityRuleUuid; - console.log(` ${chalk.dim(ri.uuid)}`); - console.log(` ${ruleName} ${stateColor(ri.state)}`); - } - console.log(); -} - -/** Render rule instance detail. */ -export function renderRuleInstanceDetail(instance: ModelSecurityRuleInstance): void { - console.log(chalk.bold('\n Rule Instance Detail:\n')); - console.log(` UUID: ${chalk.dim(instance.uuid)}`); - console.log(` Group UUID: ${chalk.dim(instance.securityGroupUuid)}`); - console.log(` Rule UUID: ${chalk.dim(instance.securityRuleUuid)}`); - const stateColor = - instance.state === 'BLOCKING' - ? chalk.red - : instance.state === 'ALLOWING' - ? chalk.green - : chalk.dim; - console.log(` State: ${stateColor(instance.state)}`); - const ruleName = (instance.rule as { name?: string })?.name; - if (ruleName) console.log(` Rule Name: ${ruleName}`); - console.log(` Created: ${chalk.dim(instance.createdAt)}`); - console.log(` Updated: ${chalk.dim(instance.updatedAt)}`); - - if (Object.keys(instance.fieldValues).length > 0) { - console.log(chalk.bold('\n Field Values:')); - for (const [key, value] of Object.entries(instance.fieldValues)) { - const display = Array.isArray(value) ? value.join(', ') : String(value); - console.log(` ${key}: ${chalk.dim(display)}`); - } - } - console.log(); -} - -// --------------------------------------------------------------------------- -// Model Security — Scans -// --------------------------------------------------------------------------- - -/** Render a list of model security scans. */ -export function renderMsScanList(scans: ModelSecurityScan[]): void { - if (scans.length === 0) { - console.log(chalk.dim(' No scans found.\n')); - return; - } - console.log(chalk.bold('\n Model Security Scans:\n')); - for (const s of scans) { - const outcomeColor = - s.evalOutcome === 'ALLOWED' - ? chalk.green - : s.evalOutcome === 'BLOCKED' - ? chalk.red - : chalk.yellow; - console.log(` ${chalk.dim(s.uuid)}`); - console.log( - ` ${outcomeColor(s.evalOutcome)} ${chalk.dim(s.scanOrigin)} ${chalk.dim(s.createdAt)}`, - ); - if (s.modelUri) console.log(` ${chalk.dim(s.modelUri)}`); - if (s.evalSummary) { - const { rulesPassed, rulesFailed, totalRules } = s.evalSummary; - console.log( - ` Rules: ${chalk.green(`${rulesPassed} passed`)} ${chalk.red(`${rulesFailed} failed`)} / ${totalRules} total`, - ); - } - } - console.log(); -} - -/** Render full scan detail. */ -export function renderMsScanDetail(scan: ModelSecurityScan): void { - console.log(chalk.bold('\n Scan Detail:\n')); - console.log(` UUID: ${chalk.dim(scan.uuid)}`); - const outcomeColor = - scan.evalOutcome === 'ALLOWED' - ? chalk.green - : scan.evalOutcome === 'BLOCKED' - ? chalk.red - : chalk.yellow; - console.log(` Outcome: ${outcomeColor(scan.evalOutcome)}`); - if (scan.modelUri) console.log(` Model URI: ${scan.modelUri}`); - console.log(` Origin: ${scan.scanOrigin}`); - console.log(` Source: ${scan.sourceType}`); - console.log(` Group: ${scan.securityGroupName}`); - console.log(` Created: ${chalk.dim(scan.createdAt)}`); - console.log(` Updated: ${chalk.dim(scan.updatedAt)}`); - if (scan.evalSummary) { - const { rulesPassed, rulesFailed, totalRules } = scan.evalSummary; - console.log( - ` Rules: ${chalk.green(`${rulesPassed} passed`)} ${chalk.red(`${rulesFailed} failed`)} / ${totalRules} total`, - ); - } - if (scan.labels.length > 0) { - console.log(chalk.bold('\n Labels:')); - for (const l of scan.labels) { - console.log(` ${l.key}: ${chalk.dim(l.value)}`); - } - } - console.log(); -} - -// --------------------------------------------------------------------------- -// Model Security — Evaluations -// --------------------------------------------------------------------------- - -/** Render a list of evaluations. */ -export function renderEvaluationList(evaluations: ModelSecurityEvaluation[]): void { - if (evaluations.length === 0) { - console.log(chalk.dim(' No evaluations found.\n')); - return; - } - console.log(chalk.bold('\n Rule Evaluations:\n')); - for (const e of evaluations) { - const color = - e.result === 'PASSED' ? chalk.green : e.result === 'FAILED' ? chalk.red : chalk.yellow; - console.log(` ${chalk.dim(e.uuid)}`); - console.log(` ${e.ruleName} ${color(e.result)} ${chalk.dim(e.ruleInstanceState)}`); - } - console.log(); -} - -/** Render a single evaluation detail. */ -export function renderEvaluationDetail(evaluation: ModelSecurityEvaluation): void { - console.log(chalk.bold('\n Evaluation Detail:\n')); - console.log(` UUID: ${chalk.dim(evaluation.uuid)}`); - console.log(` Rule: ${evaluation.ruleName}`); - console.log(` Description: ${chalk.dim(evaluation.ruleDescription)}`); - console.log(` Instance UUID: ${chalk.dim(evaluation.ruleInstanceUuid)}`); - console.log(` Instance State: ${evaluation.ruleInstanceState}`); - const color = - evaluation.result === 'PASSED' - ? chalk.green - : evaluation.result === 'FAILED' - ? chalk.red - : chalk.yellow; - console.log(` Result: ${color(evaluation.result)}`); - console.log(` Violations: ${evaluation.violationCount}`); - console.log(); -} - -// --------------------------------------------------------------------------- -// Model Security — Violations -// --------------------------------------------------------------------------- - -/** Render a list of violations. */ -export function renderViolationList(violations: ModelSecurityViolation[]): void { - if (violations.length === 0) { - console.log(chalk.dim(' No violations found.\n')); - return; - } - console.log(chalk.bold('\n Violations:\n')); - for (const v of violations) { - console.log(` ${chalk.dim(v.uuid)}`); - console.log(` ${chalk.red(v.ruleName)} ${chalk.dim(v.file)}`); - console.log(` ${v.description}`); - console.log(` Threat: ${chalk.dim(v.threat)}`); - } - console.log(); -} - -/** Render a single violation detail. */ -export function renderViolationDetail(violation: ModelSecurityViolation): void { - console.log(chalk.bold('\n Violation Detail:\n')); - console.log(` UUID: ${chalk.dim(violation.uuid)}`); - console.log(` Rule: ${chalk.red(violation.ruleName)}`); - console.log(` Description: ${violation.ruleDescription}`); - console.log(` State: ${violation.ruleInstanceState}`); - console.log(` File: ${violation.file}`); - console.log(` Threat: ${violation.threat}`); - console.log(` Detail: ${violation.description}`); - console.log(); -} - -// --------------------------------------------------------------------------- -// Model Security — Files -// --------------------------------------------------------------------------- - -/** Render a list of scanned files. */ -export function renderFileList(files: ModelSecurityFile[]): void { - if (files.length === 0) { - console.log(chalk.dim(' No files found.\n')); - return; - } - console.log(chalk.bold('\n Scanned Files:\n')); - for (const f of files) { - const color = - f.result === 'SUCCESS' ? chalk.green : f.result === 'SKIPPED' ? chalk.yellow : chalk.red; - const formats = f.formats.length > 0 ? chalk.dim(` [${f.formats.join(', ')}]`) : ''; - console.log(` ${color(f.result)} ${f.type} ${f.path}${formats}`); - } - console.log(); -} - -// --------------------------------------------------------------------------- -// Model Security — Labels -// --------------------------------------------------------------------------- - -/** Render label keys. */ -export function renderLabelKeys(keys: string[]): void { - if (keys.length === 0) { - console.log(chalk.dim(' No label keys found.\n')); - return; - } - console.log(chalk.bold('\n Label Keys:\n')); - for (const k of keys) { - console.log(` ${k}`); - } - console.log(); -} - -/** Render label values for a key. */ -export function renderLabelValues(key: string, values: string[]): void { - if (values.length === 0) { - console.log(chalk.dim(` No values for key "${key}".\n`)); - return; - } - console.log(chalk.bold(`\n Label Values for "${key}":\n`)); - for (const v of values) { - console.log(` ${v}`); - } - console.log(); -} diff --git a/src/cli/renderer/audit.ts b/src/cli/renderer/audit.ts new file mode 100644 index 0000000..8b64b30 --- /dev/null +++ b/src/cli/renderer/audit.ts @@ -0,0 +1,48 @@ +import chalk from 'chalk'; +import type { AuditResult, ConflictPair, ProfileTopic } from '../../audit/types.js'; + +/** Render profile topics discovered during audit. */ +export function renderAuditTopics(topics: ProfileTopic[]): void { + for (const t of topics) { + const actionColor = t.action === 'block' ? chalk.red : chalk.green; + console.log(` ${actionColor(`[${t.action}]`)} ${chalk.bold(t.topicName)}`); + if (t.description) console.log(chalk.dim(` ${t.description}`)); + } + console.log(); +} + +/** Render audit completion with per-topic metrics table. */ +export function renderAuditComplete(result: AuditResult): void { + console.log(chalk.bold('\n Per-Topic Results:\n')); + console.log( + chalk.dim(' Topic Coverage TPR TNR Accuracy Tests'), + ); + console.log(chalk.dim(` ${'─'.repeat(72)}`)); + for (const tr of result.topics) { + const m = tr.metrics; + const name = tr.topic.topicName.padEnd(30); + const cov = `${(m.coverage * 100).toFixed(0)}%`.padStart(6); + const tpr = `${(m.truePositiveRate * 100).toFixed(0)}%`.padStart(6); + const tnr = `${(m.trueNegativeRate * 100).toFixed(0)}%`.padStart(6); + const acc = `${(m.accuracy * 100).toFixed(0)}%`.padStart(6); + const tests = String(tr.testResults.length).padStart(5); + const covColor = m.coverage >= 0.9 ? chalk.green : m.coverage >= 0.5 ? chalk.yellow : chalk.red; + console.log(` ${name} ${covColor(cov)} ${tpr} ${tnr} ${acc} ${tests}`); + } +} + +/** Render detected cross-topic conflicts. */ +export function renderConflicts(conflicts: ConflictPair[]): void { + console.log(chalk.bold.yellow(`\n Conflicts Detected: ${conflicts.length}\n`)); + for (const c of conflicts) { + console.log(chalk.yellow(` ${c.topicA} ↔ ${c.topicB}`)); + console.log(chalk.dim(` ${c.description}`)); + for (const e of c.evidence.slice(0, 3)) { + console.log(chalk.dim(` • "${e}"`)); + } + if (c.evidence.length > 3) { + console.log(chalk.dim(` ...and ${c.evidence.length - 3} more`)); + } + } + console.log(); +} diff --git a/src/cli/renderer/common.ts b/src/cli/renderer/common.ts new file mode 100644 index 0000000..68ff10b --- /dev/null +++ b/src/cli/renderer/common.ts @@ -0,0 +1,6 @@ +import chalk from 'chalk'; + +/** Render an error message to stderr. */ +export function renderError(message: string): void { + console.error(chalk.red(`\n Error: ${message}\n`)); +} diff --git a/src/cli/renderer/generate.ts b/src/cli/renderer/generate.ts new file mode 100644 index 0000000..6f1279b --- /dev/null +++ b/src/cli/renderer/generate.ts @@ -0,0 +1,161 @@ +import chalk from 'chalk'; +import type { + AnalysisReport, + CustomTopic, + EfficacyMetrics, + IterationResult, + RunState, + TestResult, +} from '../../core/types.js'; +import type { RunStateSummary } from '../../persistence/types.js'; + +/** Render the application banner. */ +export function renderHeader(): void { + console.log(chalk.bold.cyan('\n Prisma AIRS Guardrail Generator')); + console.log(chalk.dim(' Iterative custom topic refinement\n')); +} + +/** Render iteration number header. */ +export function renderIterationStart(iteration: number): void { + console.log(chalk.bold(`\n━━━ Iteration ${iteration} ━━━`)); +} + +/** Render a topic's name, description, and examples. */ +export function renderTopic(topic: CustomTopic): void { + console.log(chalk.bold(' Topic:')); + console.log(` Name: ${chalk.white(topic.name)}`); + console.log(` Desc: ${chalk.white(topic.description)}`); + console.log(' Examples:'); + for (const ex of topic.examples) { + console.log(` ${chalk.dim('•')} ${ex}`); + } +} + +/** Render companion allow topic info (two-phase generation). */ +export function renderCompanionTopic(topic: CustomTopic): void { + console.log(chalk.bold(' Companion Allow Topic:')); + console.log(` Name: ${chalk.white(topic.name)}`); + console.log(` Desc: ${chalk.white(topic.description)}`); +} + +/** Render a scan progress bar with percentage. */ +export function renderTestProgress(completed: number, total: number): void { + const pct = Math.round((completed / total) * 100); + const bar = '█'.repeat(Math.round(pct / 5)) + '░'.repeat(20 - Math.round(pct / 5)); + process.stdout.write(`\r Scanning: ${bar} ${pct}% (${completed}/${total})`); + if (completed === total) console.log(); +} + +/** Render efficacy metrics with color-coded coverage. */ +export function renderMetrics(metrics: EfficacyMetrics): void { + const coverageColor = + metrics.coverage >= 0.9 ? chalk.green : metrics.coverage >= 0.7 ? chalk.yellow : chalk.red; + + console.log(chalk.bold('\n Metrics:')); + console.log(` Coverage: ${coverageColor(`${(metrics.coverage * 100).toFixed(1)}%`)}`); + console.log(` Accuracy: ${(metrics.accuracy * 100).toFixed(1)}%`); + console.log(` TPR: ${(metrics.truePositiveRate * 100).toFixed(1)}%`); + console.log(` TNR: ${(metrics.trueNegativeRate * 100).toFixed(1)}%`); + console.log(` F1 Score: ${metrics.f1Score.toFixed(3)}`); + let countsLine = ` TP: ${metrics.truePositives} TN: ${metrics.trueNegatives} FP: ${metrics.falsePositives} FN: ${metrics.falseNegatives}`; + if (metrics.regressionCount > 0) { + countsLine += chalk.red(` Regressions: ${metrics.regressionCount}`); + } + console.log(chalk.dim(countsLine)); +} + +/** Render analysis summary with FP/FN pattern details. */ +export function renderAnalysis(analysis: AnalysisReport): void { + console.log(chalk.bold('\n Analysis:')); + console.log(` ${analysis.summary}`); + if (analysis.falsePositivePatterns.length > 0) { + console.log(chalk.yellow(' FP patterns:')); + for (const p of analysis.falsePositivePatterns) { + console.log(` ${chalk.dim('•')} ${p}`); + } + } + if (analysis.falseNegativePatterns.length > 0) { + console.log(chalk.red(' FN patterns:')); + for (const p of analysis.falseNegativePatterns) { + console.log(` ${chalk.dim('•')} ${p}`); + } + } +} + +/** Render per-test-case results table. */ +export function renderTestResults(results: TestResult[]): void { + console.log(chalk.bold('\n Test Results:')); + for (const r of results) { + const icon = r.correct ? chalk.green('✓') : chalk.red('✗'); + const expected = r.testCase.expectedTriggered ? 'triggered' : 'safe'; + const actual = r.actualTriggered ? 'triggered' : 'safe'; + const status = r.correct ? '' : chalk.red(` (expected ${expected}, got ${actual})`); + console.log(` ${icon} ${r.testCase.prompt}${status}`); + } +} + +/** Render loop completion summary with best iteration and run ID. */ +export function renderLoopComplete(runState: RunState): void { + const best = runState.iterations[runState.bestIteration - 1]; + console.log(chalk.bold.green('\n━━━ Complete ━━━')); + console.log( + ` Best iteration: ${runState.bestIteration} (coverage: ${(runState.bestCoverage * 100).toFixed(1)}%)`, + ); + console.log(` Total iterations: ${runState.iterations.length}`); + if (best) { + renderTopic(best.topic); + } + console.log(`\n Run ID: ${chalk.dim(runState.id)}\n`); +} + +/** Render a list of saved runs with status, coverage, and topic description. */ +export function renderRunList(runs: RunStateSummary[]): void { + if (runs.length === 0) { + console.log(chalk.dim(' No saved runs found.\n')); + return; + } + console.log(chalk.bold('\n Saved Runs:\n')); + for (const run of runs) { + const statusColor = + run.status === 'completed' + ? chalk.green + : run.status === 'running' + ? chalk.blue + : run.status === 'paused' + ? chalk.yellow + : chalk.red; + console.log(` ${chalk.dim(run.id)}`); + console.log( + ` Status: ${statusColor(run.status)} Coverage: ${(run.bestCoverage * 100).toFixed(1)}% Iterations: ${run.currentIteration}`, + ); + console.log(` Topic: ${run.topicDescription}`); + console.log(` Created: ${run.createdAt}\n`); + } +} + +/** Render a one-line iteration summary with duration and coverage. */ +export function renderIterationSummary(result: IterationResult): void { + const coverageColor = + result.metrics.coverage >= 0.9 + ? chalk.green + : result.metrics.coverage >= 0.7 + ? chalk.yellow + : chalk.red; + console.log( + ` ${chalk.dim(`[${result.durationMs}ms]`)} Coverage: ${coverageColor(`${(result.metrics.coverage * 100).toFixed(1)}%`)} | Accuracy: ${(result.metrics.accuracy * 100).toFixed(1)}%`, + ); +} + +/** Render memory loading status (count of learnings loaded or "none found"). */ +export function renderMemoryLoaded(learningCount: number): void { + if (learningCount > 0) { + console.log(chalk.cyan(` Memory: loaded ${learningCount} learnings from previous runs`)); + } else { + console.log(chalk.dim(' Memory: no previous learnings found')); + } +} + +/** Render count of learnings extracted from the current run. */ +export function renderMemoryExtracted(learningCount: number): void { + console.log(chalk.cyan(` Memory: extracted ${learningCount} learnings from this run`)); +} diff --git a/src/cli/renderer/index.ts b/src/cli/renderer/index.ts new file mode 100644 index 0000000..f9593d7 --- /dev/null +++ b/src/cli/renderer/index.ts @@ -0,0 +1,6 @@ +export * from './audit.js'; +export * from './common.js'; +export * from './generate.js'; +export * from './modelsecurity.js'; +export * from './redteam.js'; +export * from './runtime.js'; diff --git a/src/cli/renderer/modelsecurity.ts b/src/cli/renderer/modelsecurity.ts new file mode 100644 index 0000000..3eb764c --- /dev/null +++ b/src/cli/renderer/modelsecurity.ts @@ -0,0 +1,328 @@ +import chalk from 'chalk'; +import type { + ModelSecurityEvaluation, + ModelSecurityFile, + ModelSecurityGroup, + ModelSecurityRule, + ModelSecurityRuleInstance, + ModelSecurityScan, + ModelSecurityViolation, +} from '../../airs/types.js'; + +/** Render the model security banner. */ +export function renderModelSecurityHeader(): void { + console.log(chalk.bold.blue('\n Prisma AIRS — Model Security')); + console.log(chalk.dim(' ML model supply chain security\n')); +} + +/** Render security group list. */ +export function renderGroupList(groups: ModelSecurityGroup[]): void { + if (groups.length === 0) { + console.log(chalk.dim(' No security groups found.\n')); + return; + } + console.log(chalk.bold('\n Security Groups:\n')); + for (const g of groups) { + console.log(` ${chalk.dim(g.uuid)}`); + const stateColor = g.state === 'ACTIVE' ? chalk.green : chalk.yellow; + console.log(` ${g.name} ${stateColor(g.state)} source: ${chalk.dim(g.sourceType)}`); + } + console.log(); +} + +/** Render security group detail. */ +export function renderGroupDetail(group: ModelSecurityGroup): void { + console.log(chalk.bold('\n Security Group Detail:\n')); + console.log(` UUID: ${chalk.dim(group.uuid)}`); + console.log(` Name: ${group.name}`); + console.log(` Description: ${group.description || chalk.dim('(none)')}`); + console.log(` Source Type: ${group.sourceType}`); + const stateColor = group.state === 'ACTIVE' ? chalk.green : chalk.yellow; + console.log(` State: ${stateColor(group.state)}`); + console.log(` Created: ${chalk.dim(group.createdAt)}`); + console.log(` Updated: ${chalk.dim(group.updatedAt)}`); + console.log(); +} + +/** Render security rule list. */ +export function renderRuleList(rules: ModelSecurityRule[]): void { + if (rules.length === 0) { + console.log(chalk.dim(' No security rules found.\n')); + return; + } + console.log(chalk.bold('\n Security Rules:\n')); + for (const r of rules) { + console.log(` ${chalk.dim(r.uuid)}`); + console.log( + ` ${r.name} type: ${chalk.dim(r.ruleType)} default: ${chalk.dim(r.defaultState)}`, + ); + console.log(` ${chalk.dim(r.description)}`); + console.log(` Sources: ${r.compatibleSources.map((s) => chalk.dim(s)).join(', ')}`); + } + console.log(); +} + +/** Render security rule detail. */ +export function renderRuleDetail(rule: ModelSecurityRule): void { + console.log(chalk.bold('\n Security Rule Detail:\n')); + console.log(` UUID: ${chalk.dim(rule.uuid)}`); + console.log(` Name: ${rule.name}`); + console.log(` Description: ${rule.description}`); + console.log(` Rule Type: ${rule.ruleType}`); + console.log(` Default State: ${rule.defaultState}`); + console.log(` Sources: ${rule.compatibleSources.join(', ')}`); + + if (rule.remediation.description) { + console.log(chalk.bold('\n Remediation:')); + console.log(` ${rule.remediation.description}`); + if (rule.remediation.steps.length > 0) { + for (const step of rule.remediation.steps) { + console.log(` ${chalk.dim('•')} ${step}`); + } + } + if (rule.remediation.url) { + console.log(` ${chalk.dim(rule.remediation.url)}`); + } + } + + if (rule.editableFields.length > 0) { + console.log(chalk.bold('\n Editable Fields:')); + for (const f of rule.editableFields) { + console.log(` ${f.displayName} (${chalk.dim(f.attributeName)}): ${f.displayType}`); + if (f.description) console.log(` ${chalk.dim(f.description)}`); + } + } + console.log(); +} + +/** Render rule instance list. */ +export function renderRuleInstanceList(instances: ModelSecurityRuleInstance[]): void { + if (instances.length === 0) { + console.log(chalk.dim(' No rule instances found.\n')); + return; + } + console.log(chalk.bold('\n Rule Instances:\n')); + for (const ri of instances) { + const stateColor = + ri.state === 'BLOCKING' ? chalk.red : ri.state === 'ALLOWING' ? chalk.green : chalk.dim; + const ruleName = (ri.rule as { name?: string })?.name ?? ri.securityRuleUuid; + console.log(` ${chalk.dim(ri.uuid)}`); + console.log(` ${ruleName} ${stateColor(ri.state)}`); + } + console.log(); +} + +/** Render rule instance detail. */ +export function renderRuleInstanceDetail(instance: ModelSecurityRuleInstance): void { + console.log(chalk.bold('\n Rule Instance Detail:\n')); + console.log(` UUID: ${chalk.dim(instance.uuid)}`); + console.log(` Group UUID: ${chalk.dim(instance.securityGroupUuid)}`); + console.log(` Rule UUID: ${chalk.dim(instance.securityRuleUuid)}`); + const stateColor = + instance.state === 'BLOCKING' + ? chalk.red + : instance.state === 'ALLOWING' + ? chalk.green + : chalk.dim; + console.log(` State: ${stateColor(instance.state)}`); + const ruleName = (instance.rule as { name?: string })?.name; + if (ruleName) console.log(` Rule Name: ${ruleName}`); + console.log(` Created: ${chalk.dim(instance.createdAt)}`); + console.log(` Updated: ${chalk.dim(instance.updatedAt)}`); + + if (Object.keys(instance.fieldValues).length > 0) { + console.log(chalk.bold('\n Field Values:')); + for (const [key, value] of Object.entries(instance.fieldValues)) { + const display = Array.isArray(value) ? value.join(', ') : String(value); + console.log(` ${key}: ${chalk.dim(display)}`); + } + } + console.log(); +} + +// --------------------------------------------------------------------------- +// Model Security — Scans +// --------------------------------------------------------------------------- + +/** Render a list of model security scans. */ +export function renderMsScanList(scans: ModelSecurityScan[]): void { + if (scans.length === 0) { + console.log(chalk.dim(' No scans found.\n')); + return; + } + console.log(chalk.bold('\n Model Security Scans:\n')); + for (const s of scans) { + const outcomeColor = + s.evalOutcome === 'ALLOWED' + ? chalk.green + : s.evalOutcome === 'BLOCKED' + ? chalk.red + : chalk.yellow; + console.log(` ${chalk.dim(s.uuid)}`); + console.log( + ` ${outcomeColor(s.evalOutcome)} ${chalk.dim(s.scanOrigin)} ${chalk.dim(s.createdAt)}`, + ); + if (s.modelUri) console.log(` ${chalk.dim(s.modelUri)}`); + if (s.evalSummary) { + const { rulesPassed, rulesFailed, totalRules } = s.evalSummary; + console.log( + ` Rules: ${chalk.green(`${rulesPassed} passed`)} ${chalk.red(`${rulesFailed} failed`)} / ${totalRules} total`, + ); + } + } + console.log(); +} + +/** Render full scan detail. */ +export function renderMsScanDetail(scan: ModelSecurityScan): void { + console.log(chalk.bold('\n Scan Detail:\n')); + console.log(` UUID: ${chalk.dim(scan.uuid)}`); + const outcomeColor = + scan.evalOutcome === 'ALLOWED' + ? chalk.green + : scan.evalOutcome === 'BLOCKED' + ? chalk.red + : chalk.yellow; + console.log(` Outcome: ${outcomeColor(scan.evalOutcome)}`); + if (scan.modelUri) console.log(` Model URI: ${scan.modelUri}`); + console.log(` Origin: ${scan.scanOrigin}`); + console.log(` Source: ${scan.sourceType}`); + console.log(` Group: ${scan.securityGroupName}`); + console.log(` Created: ${chalk.dim(scan.createdAt)}`); + console.log(` Updated: ${chalk.dim(scan.updatedAt)}`); + if (scan.evalSummary) { + const { rulesPassed, rulesFailed, totalRules } = scan.evalSummary; + console.log( + ` Rules: ${chalk.green(`${rulesPassed} passed`)} ${chalk.red(`${rulesFailed} failed`)} / ${totalRules} total`, + ); + } + if (scan.labels.length > 0) { + console.log(chalk.bold('\n Labels:')); + for (const l of scan.labels) { + console.log(` ${l.key}: ${chalk.dim(l.value)}`); + } + } + console.log(); +} + +// --------------------------------------------------------------------------- +// Model Security — Evaluations +// --------------------------------------------------------------------------- + +/** Render a list of evaluations. */ +export function renderEvaluationList(evaluations: ModelSecurityEvaluation[]): void { + if (evaluations.length === 0) { + console.log(chalk.dim(' No evaluations found.\n')); + return; + } + console.log(chalk.bold('\n Rule Evaluations:\n')); + for (const e of evaluations) { + const color = + e.result === 'PASSED' ? chalk.green : e.result === 'FAILED' ? chalk.red : chalk.yellow; + console.log(` ${chalk.dim(e.uuid)}`); + console.log(` ${e.ruleName} ${color(e.result)} ${chalk.dim(e.ruleInstanceState)}`); + } + console.log(); +} + +/** Render a single evaluation detail. */ +export function renderEvaluationDetail(evaluation: ModelSecurityEvaluation): void { + console.log(chalk.bold('\n Evaluation Detail:\n')); + console.log(` UUID: ${chalk.dim(evaluation.uuid)}`); + console.log(` Rule: ${evaluation.ruleName}`); + console.log(` Description: ${chalk.dim(evaluation.ruleDescription)}`); + console.log(` Instance UUID: ${chalk.dim(evaluation.ruleInstanceUuid)}`); + console.log(` Instance State: ${evaluation.ruleInstanceState}`); + const color = + evaluation.result === 'PASSED' + ? chalk.green + : evaluation.result === 'FAILED' + ? chalk.red + : chalk.yellow; + console.log(` Result: ${color(evaluation.result)}`); + console.log(` Violations: ${evaluation.violationCount}`); + console.log(); +} + +// --------------------------------------------------------------------------- +// Model Security — Violations +// --------------------------------------------------------------------------- + +/** Render a list of violations. */ +export function renderViolationList(violations: ModelSecurityViolation[]): void { + if (violations.length === 0) { + console.log(chalk.dim(' No violations found.\n')); + return; + } + console.log(chalk.bold('\n Violations:\n')); + for (const v of violations) { + console.log(` ${chalk.dim(v.uuid)}`); + console.log(` ${chalk.red(v.ruleName)} ${chalk.dim(v.file)}`); + console.log(` ${v.description}`); + console.log(` Threat: ${chalk.dim(v.threat)}`); + } + console.log(); +} + +/** Render a single violation detail. */ +export function renderViolationDetail(violation: ModelSecurityViolation): void { + console.log(chalk.bold('\n Violation Detail:\n')); + console.log(` UUID: ${chalk.dim(violation.uuid)}`); + console.log(` Rule: ${chalk.red(violation.ruleName)}`); + console.log(` Description: ${violation.ruleDescription}`); + console.log(` State: ${violation.ruleInstanceState}`); + console.log(` File: ${violation.file}`); + console.log(` Threat: ${violation.threat}`); + console.log(` Detail: ${violation.description}`); + console.log(); +} + +// --------------------------------------------------------------------------- +// Model Security — Files +// --------------------------------------------------------------------------- + +/** Render a list of scanned files. */ +export function renderFileList(files: ModelSecurityFile[]): void { + if (files.length === 0) { + console.log(chalk.dim(' No files found.\n')); + return; + } + console.log(chalk.bold('\n Scanned Files:\n')); + for (const f of files) { + const color = + f.result === 'SUCCESS' ? chalk.green : f.result === 'SKIPPED' ? chalk.yellow : chalk.red; + const formats = f.formats.length > 0 ? chalk.dim(` [${f.formats.join(', ')}]`) : ''; + console.log(` ${color(f.result)} ${f.type} ${f.path}${formats}`); + } + console.log(); +} + +// --------------------------------------------------------------------------- +// Model Security — Labels +// --------------------------------------------------------------------------- + +/** Render label keys. */ +export function renderLabelKeys(keys: string[]): void { + if (keys.length === 0) { + console.log(chalk.dim(' No label keys found.\n')); + return; + } + console.log(chalk.bold('\n Label Keys:\n')); + for (const k of keys) { + console.log(` ${k}`); + } + console.log(); +} + +/** Render label values for a key. */ +export function renderLabelValues(key: string, values: string[]): void { + if (values.length === 0) { + console.log(chalk.dim(` No values for key "${key}".\n`)); + return; + } + console.log(chalk.bold(`\n Label Values for "${key}":\n`)); + for (const v of values) { + console.log(` ${v}`); + } + console.log(); +} diff --git a/src/cli/renderer/redteam.ts b/src/cli/renderer/redteam.ts new file mode 100644 index 0000000..2cad5f0 --- /dev/null +++ b/src/cli/renderer/redteam.ts @@ -0,0 +1,435 @@ +import chalk from 'chalk'; + +/** Render the red team banner. */ +export function renderRedteamHeader(): void { + console.log(chalk.bold.red('\n Prisma AIRS — AI Red Team')); + console.log(chalk.dim(' Adversarial scan operations\n')); +} + +type ChalkFn = (text: string) => string; + +/** Severity → chalk color mapping. */ +function severityColor(severity: string): ChalkFn { + switch (severity.toUpperCase()) { + case 'CRITICAL': + return chalk.red; + case 'HIGH': + return chalk.magenta; + case 'MEDIUM': + return chalk.yellow; + case 'LOW': + return chalk.cyan; + default: + return chalk.dim; + } +} + +/** Status → chalk color mapping. */ +function statusColor(status: string): ChalkFn { + switch (status) { + case 'COMPLETED': + return chalk.green; + case 'RUNNING': + return chalk.blue; + case 'QUEUED': + case 'INIT': + return chalk.yellow; + case 'FAILED': + case 'ABORTED': + return chalk.red; + case 'PARTIALLY_COMPLETE': + return chalk.yellow; + default: + return chalk.white; + } +} + +/** Render a scan's status summary. */ +export function renderScanStatus(job: { + uuid: string; + name: string; + status: string; + jobType: string; + targetName?: string; + score?: number | null; + asr?: number | null; + completed?: number | null; + total?: number | null; +}): void { + console.log(chalk.bold(' Scan Status:')); + console.log(` ID: ${chalk.dim(job.uuid)}`); + console.log(` Name: ${job.name}`); + console.log(` Type: ${job.jobType}`); + if (job.targetName) console.log(` Target: ${job.targetName}`); + console.log(` Status: ${statusColor(job.status)(job.status)}`); + if (job.total != null && job.completed != null) { + console.log(` Progress: ${job.completed}/${job.total}`); + } + if (job.score != null) console.log(` Score: ${job.score}`); + if (job.asr != null) console.log(` ASR: ${job.asr.toFixed(1)}%`); + console.log(); +} + +/** Render a table of scans. */ +export function renderScanList( + jobs: Array<{ + uuid: string; + name: string; + status: string; + jobType: string; + score?: number | null; + createdAt?: string | null; + }>, +): void { + if (jobs.length === 0) { + console.log(chalk.dim(' No scans found.\n')); + return; + } + console.log(chalk.bold('\n Recent Scans:\n')); + for (const job of jobs) { + console.log(` ${chalk.dim(job.uuid)}`); + console.log( + ` ${job.name} ${statusColor(job.status)(job.status)} ${job.jobType}${job.score != null ? ` score: ${job.score}` : ''}`, + ); + if (job.createdAt) console.log(` ${chalk.dim(job.createdAt)}`); + console.log(); + } +} + +/** Render a static scan report. */ +export function renderStaticReport(report: { + score?: number | null; + asr?: number | null; + severityBreakdown: Array<{ severity: string; successful: number; failed: number }>; + reportSummary?: string | null; + categories: Array<{ + id: string; + displayName: string; + asr: number; + successful: number; + failed: number; + total: number; + }>; +}): void { + console.log(chalk.bold('\n Static Scan Report:')); + if (report.score != null) console.log(` Score: ${report.score}`); + if (report.asr != null) console.log(` ASR: ${report.asr.toFixed(1)}%`); + + if (report.severityBreakdown.length > 0) { + console.log(chalk.bold('\n Severity Breakdown:')); + for (const s of report.severityBreakdown) { + const color = severityColor(s.severity); + console.log( + ` ${color(s.severity.padEnd(10))} ${chalk.red(`${s.successful} bypassed`)} ${chalk.green(`${s.failed} blocked`)}`, + ); + } + } + + if (report.categories.length > 0) { + console.log(chalk.bold('\n Categories:')); + for (const c of report.categories) { + console.log( + ` ${c.displayName.padEnd(30)} ASR: ${c.asr.toFixed(1)}% (${c.successful}/${c.total})`, + ); + } + } + + if (report.reportSummary) { + console.log(chalk.bold('\n Summary:')); + console.log(` ${report.reportSummary}`); + } + console.log(); +} + +/** Render a custom attack report. */ +export function renderCustomReport(report: { + totalPrompts: number; + totalAttacks: number; + totalThreats: number; + score: number; + asr: number; + promptSets: Array<{ + promptSetName: string; + totalPrompts: number; + totalThreats: number; + threatRate: number; + }>; +}): void { + console.log(chalk.bold('\n Custom Attack Report:')); + console.log(` Score: ${report.score}`); + console.log(` ASR: ${report.asr.toFixed(1)}%`); + console.log(` Attacks: ${report.totalAttacks} Threats: ${report.totalThreats}`); + + if (report.promptSets.length > 0) { + console.log(chalk.bold('\n Prompt Sets:')); + for (const ps of report.promptSets) { + console.log( + ` ${ps.promptSetName.padEnd(40)} ${ps.totalThreats}/${ps.totalPrompts} threats (${ps.threatRate.toFixed(1)}%)`, + ); + } + } + console.log(); +} + +/** Render attack list with severity coloring. */ +export function renderAttackList( + attacks: Array<{ + name: string; + severity?: string; + category?: string; + successful: boolean; + }>, +): void { + if (attacks.length === 0) { + console.log(chalk.dim(' No attacks found.\n')); + return; + } + console.log(chalk.bold('\n Attacks:\n')); + for (const a of attacks) { + const sev = a.severity + ? severityColor(a.severity)(a.severity.padEnd(10)) + : chalk.dim('N/A'.padEnd(10)); + const result = a.successful ? chalk.red('BYPASSED') : chalk.green('BLOCKED'); + console.log( + ` ${sev} ${result} ${a.name}${a.category ? chalk.dim(` [${a.category}]`) : ''}`, + ); + } + console.log(); +} + +/** Render custom attack list (prompt-level results). */ +export function renderCustomAttackList( + attacks: Array<{ + promptText: string; + goal?: string; + threat: boolean; + asr?: number; + promptSetName?: string; + }>, +): void { + if (attacks.length === 0) { + console.log(chalk.dim(' No custom attacks found.\n')); + return; + } + console.log(chalk.bold('\n Custom Attacks:\n')); + for (const a of attacks) { + const result = a.threat ? chalk.red('THREAT') : chalk.green('SAFE'); + const prompt = a.promptText.length > 80 ? `${a.promptText.substring(0, 77)}...` : a.promptText; + const asrStr = a.asr != null ? chalk.dim(` ASR: ${a.asr.toFixed(1)}%`) : ''; + console.log(` ${result}${asrStr} ${prompt}`); + if (a.goal) console.log(` ${chalk.dim(a.goal)}`); + } + console.log(); +} + +/** Render target list. */ +export function renderTargetList( + targets: Array<{ + uuid: string; + name: string; + status: string; + targetType?: string; + active: boolean; + }>, +): void { + if (targets.length === 0) { + console.log(chalk.dim(' No targets found.\n')); + return; + } + console.log(chalk.bold('\n Targets:\n')); + for (const t of targets) { + console.log(` ${chalk.dim(t.uuid)}`); + console.log( + ` ${t.name} ${statusColor(t.active ? 'COMPLETED' : 'FAILED')(t.active ? 'active' : 'inactive')}${t.targetType ? ` type: ${t.targetType}` : ''}`, + ); + } + console.log(); +} + +/** Render attack category tree. */ +export function renderCategories( + categories: Array<{ + id: string; + displayName: string; + description?: string; + subCategories: Array<{ id: string; displayName: string; description?: string }>; + }>, +): void { + if (categories.length === 0) { + console.log(chalk.dim(' No categories found.\n')); + return; + } + console.log(chalk.bold('\n Attack Categories:\n')); + for (const c of categories) { + console.log( + ` ${chalk.bold(c.displayName)}${c.description ? chalk.dim(` — ${c.description}`) : ''}`, + ); + for (const sc of c.subCategories) { + console.log( + ` ${chalk.dim('•')} ${sc.displayName}${sc.description ? chalk.dim(` — ${sc.description}`) : ''}`, + ); + } + console.log(); + } +} + +/** Render prompt set list. */ +export function renderPromptSetList( + promptSets: Array<{ uuid: string; name: string; active: boolean }>, +): void { + if (promptSets.length === 0) { + console.log(chalk.dim(' No prompt sets found.\n')); + return; + } + console.log(chalk.bold('\n Prompt Sets:\n')); + for (const ps of promptSets) { + console.log(` ${chalk.dim(ps.uuid)}`); + console.log( + ` ${ps.name} ${statusColor(ps.active ? 'COMPLETED' : 'FAILED')(ps.active ? 'active' : 'inactive')}`, + ); + } + console.log(); +} + +/** Render target detail. */ +export function renderTargetDetail(target: { + uuid: string; + name: string; + status: string; + targetType?: string; + active: boolean; + connectionParams?: Record; + background?: Record; + metadata?: Record; +}): void { + console.log(chalk.bold('\n Target Detail:\n')); + console.log(` UUID: ${chalk.dim(target.uuid)}`); + console.log(` Name: ${target.name}`); + console.log( + ` Status: ${statusColor(target.active ? 'COMPLETED' : 'FAILED')(target.active ? 'active' : 'inactive')}`, + ); + if (target.targetType) console.log(` Type: ${target.targetType}`); + if (target.connectionParams) { + console.log(chalk.bold('\n Connection:')); + for (const [k, v] of Object.entries(target.connectionParams)) { + console.log(` ${k}: ${chalk.dim(String(v))}`); + } + } + if (target.background) { + console.log(chalk.bold('\n Background:')); + for (const [k, v] of Object.entries(target.background)) { + if (v != null) console.log(` ${k}: ${chalk.dim(String(v))}`); + } + } + if (target.metadata) { + console.log(chalk.bold('\n Metadata:')); + for (const [k, v] of Object.entries(target.metadata)) { + if (v != null) console.log(` ${k}: ${chalk.dim(String(v))}`); + } + } + console.log(); +} + +/** Render prompt set detail. */ +export function renderPromptSetDetail(ps: { + uuid: string; + name: string; + active: boolean; + archive: boolean; + description?: string; + createdAt?: string; + updatedAt?: string; +}): void { + console.log(chalk.bold('\n Prompt Set Detail:\n')); + console.log(` UUID: ${chalk.dim(ps.uuid)}`); + console.log(` Name: ${ps.name}`); + console.log( + ` Status: ${statusColor(ps.active ? 'COMPLETED' : 'FAILED')(ps.active ? 'active' : 'inactive')}`, + ); + console.log(` Archived: ${ps.archive ? 'yes' : 'no'}`); + if (ps.description) console.log(` Description: ${ps.description}`); + if (ps.createdAt) console.log(` Created: ${chalk.dim(ps.createdAt)}`); + if (ps.updatedAt) console.log(` Updated: ${chalk.dim(ps.updatedAt)}`); + console.log(); +} + +/** Render prompt set version info. */ +export function renderVersionInfo(info: { + uuid: string; + version: number; + stats: { total: number; active: number; inactive: number }; +}): void { + console.log(chalk.bold('\n Version Info:\n')); + console.log(` Version: ${info.version}`); + console.log(` Total: ${info.stats.total}`); + console.log(` Active: ${chalk.green(String(info.stats.active))}`); + console.log(` Inactive: ${chalk.dim(String(info.stats.inactive))}`); + console.log(); +} + +/** Render a list of prompts. */ +export function renderPromptList( + prompts: Array<{ + uuid: string; + prompt: string; + goal?: string; + active: boolean; + }>, +): void { + if (prompts.length === 0) { + console.log(chalk.dim(' No prompts found.\n')); + return; + } + console.log(chalk.bold('\n Prompts:\n')); + for (const p of prompts) { + const status = p.active ? chalk.green('active') : chalk.dim('inactive'); + const text = p.prompt.length > 80 ? `${p.prompt.substring(0, 77)}...` : p.prompt; + console.log(` ${chalk.dim(p.uuid)} ${status}`); + console.log(` ${text}`); + if (p.goal) console.log(` ${chalk.dim(`Goal: ${p.goal}`)}`); + } + console.log(); +} + +/** Render prompt detail. */ +export function renderPromptDetail(p: { + uuid: string; + prompt: string; + goal?: string; + active: boolean; + promptSetId: string; +}): void { + console.log(chalk.bold('\n Prompt Detail:\n')); + console.log(` UUID: ${chalk.dim(p.uuid)}`); + console.log(` Set UUID: ${chalk.dim(p.promptSetId)}`); + console.log(` Status: ${p.active ? chalk.green('active') : chalk.dim('inactive')}`); + console.log(` Prompt: ${p.prompt}`); + if (p.goal) console.log(` Goal: ${p.goal}`); + console.log(); +} + +/** Render property names list. */ +export function renderPropertyNames(names: Array<{ name: string }>): void { + if (names.length === 0) { + console.log(chalk.dim(' No property names found.\n')); + return; + } + console.log(chalk.bold('\n Property Names:\n')); + for (const n of names) { + console.log(` ${chalk.dim('•')} ${n.name}`); + } + console.log(); +} + +/** Render property values. */ +export function renderPropertyValues(values: Array<{ name: string; value: string }>): void { + if (values.length === 0) { + console.log(chalk.dim(' No property values found.\n')); + return; + } + console.log(chalk.bold('\n Property Values:\n')); + for (const v of values) { + console.log(` ${v.name}: ${chalk.dim(v.value)}`); + } + console.log(); +} diff --git a/src/cli/renderer/runtime.ts b/src/cli/renderer/runtime.ts new file mode 100644 index 0000000..788ed0d --- /dev/null +++ b/src/cli/renderer/runtime.ts @@ -0,0 +1,305 @@ +import chalk from 'chalk'; + +/** Render polling progress inline. */ +export function renderScanProgress(job: { + status: string; + completed?: number | null; + total?: number | null; +}): void { + if (job.total != null && job.completed != null && job.total > 0) { + const pct = Math.round((job.completed / job.total) * 100); + const bar = '█'.repeat(Math.round(pct / 5)) + '░'.repeat(20 - Math.round(pct / 5)); + process.stdout.write( + `\r ${statusColor(job.status)(job.status)} ${bar} ${pct}% (${job.completed}/${job.total})`, + ); + } else { + process.stdout.write(`\r ${statusColor(job.status)(job.status)}...`); + } +} + +/** Render test composition summary (carried failures + regression + generated). */ +export function renderTestsComposed( + generated: number, + carriedFailures: number, + regressionTier: number, + total: number, +): void { + console.log( + chalk.dim( + ` Tests: ${generated} generated, ${carriedFailures} carried failures, ${regressionTier} regression, ${total} total`, + ), + ); +} + +/** Render accumulated test count with optional dropped info. */ +export function renderTestsAccumulated( + newCount: number, + totalCount: number, + droppedCount: number, +): void { + let msg = ` Tests: ${newCount} new, ${totalCount} total (accumulated)`; + if (droppedCount > 0) { + msg += chalk.yellow(` (${droppedCount} dropped by cap)`); + } + console.log(chalk.dim(msg)); +} + +type ChalkFn = (text: string) => string; + +/** Status → chalk color mapping. */ +function statusColor(status: string): ChalkFn { + switch (status) { + case 'COMPLETED': + return chalk.green; + case 'RUNNING': + return chalk.blue; + case 'QUEUED': + case 'INIT': + return chalk.yellow; + case 'FAILED': + case 'ABORTED': + return chalk.red; + case 'PARTIALLY_COMPLETE': + return chalk.yellow; + default: + return chalk.white; + } +} + +// --------------------------------------------------------------------------- +// Runtime Config rendering — profiles, topics, api keys, etc. +// --------------------------------------------------------------------------- + +/** Render the runtime config banner. */ +export function renderRuntimeConfigHeader(): void { + console.log(chalk.bold.cyan('\n Prisma AIRS — Runtime Configuration')); + console.log(chalk.dim(' Security profile and topic management\n')); +} + +/** Render security profile list. */ +export function renderProfileList( + profiles: Array<{ + profileId: string; + profileName: string; + revision?: number; + active?: boolean; + lastModifiedTs?: string; + }>, +): void { + if (profiles.length === 0) { + console.log(chalk.dim(' No profiles found.\n')); + return; + } + console.log(chalk.bold('\n Security Profiles:\n')); + for (const p of profiles) { + console.log(` ${chalk.dim(p.profileId)}`); + const status = p.active ? chalk.green('active') : chalk.yellow('inactive'); + const rev = p.revision != null ? chalk.dim(` rev:${p.revision}`) : ''; + console.log(` ${p.profileName} ${status}${rev}`); + } + console.log(); +} + +/** Render security profile detail. */ +export function renderProfileDetail(profile: { + profileId: string; + profileName: string; + revision?: number; + active?: boolean; + createdBy?: string; + updatedBy?: string; + lastModifiedTs?: string; + policy?: Record; +}): void { + console.log(chalk.bold('\n Profile Detail:\n')); + console.log(` ID: ${chalk.dim(profile.profileId)}`); + console.log(` Name: ${profile.profileName}`); + console.log(` Status: ${profile.active ? chalk.green('active') : chalk.yellow('inactive')}`); + if (profile.revision != null) console.log(` Revision: ${profile.revision}`); + if (profile.createdBy) console.log(` Created: ${chalk.dim(profile.createdBy)}`); + if (profile.updatedBy) console.log(` Updated: ${chalk.dim(profile.updatedBy)}`); + if (profile.lastModifiedTs) console.log(` Modified: ${chalk.dim(profile.lastModifiedTs)}`); + if (profile.policy) { + console.log( + ` Policy: ${chalk.dim(JSON.stringify(profile.policy, null, 2).slice(0, 500))}`, + ); + } + console.log(); +} + +/** Render custom topic list. */ +export function renderTopicList( + topics: Array<{ + topic_id?: string; + topic_name: string; + description?: string; + revision?: number; + }>, +): void { + if (topics.length === 0) { + console.log(chalk.dim(' No topics found.\n')); + return; + } + console.log(chalk.bold('\n Custom Topics:\n')); + for (const t of topics) { + console.log(` ${chalk.dim(t.topic_id)}`); + const rev = t.revision != null ? chalk.dim(` rev:${t.revision}`) : ''; + const desc = t.description ? chalk.dim(` — ${t.description.slice(0, 80)}`) : ''; + console.log(` ${t.topic_name}${rev}${desc}`); + } + console.log(); +} + +/** Render custom topic detail. */ +export function renderTopicDetail(topic: { + topic_id?: string; + topic_name: string; + description?: string; + examples?: string[]; + revision?: number; + created_by?: string; + updated_by?: string; + last_modified_ts?: string; +}): void { + console.log(chalk.bold('\n Topic Detail:\n')); + console.log(` ID: ${chalk.dim(topic.topic_id)}`); + console.log(` Name: ${topic.topic_name}`); + if (topic.revision != null) console.log(` Revision: ${topic.revision}`); + if (topic.description) console.log(` Description: ${topic.description}`); + if (topic.examples?.length) { + console.log(' Examples:'); + for (const ex of topic.examples) { + console.log(` ${chalk.dim('•')} ${ex}`); + } + } + if (topic.created_by) console.log(` Created: ${chalk.dim(topic.created_by)}`); + if (topic.updated_by) console.log(` Updated: ${chalk.dim(topic.updated_by)}`); + if (topic.last_modified_ts) console.log(` Modified: ${chalk.dim(topic.last_modified_ts)}`); + console.log(); +} + +/** Render API key list. */ +export function renderApiKeyList( + keys: Array<{ id: string; name: string; createdAt?: string; expiresAt?: string }>, +): void { + if (keys.length === 0) { + console.log(chalk.dim(' No API keys found.\n')); + return; + } + console.log(chalk.bold('\n API Keys:\n')); + for (const k of keys) { + console.log(` ${chalk.dim(k.id)}`); + const expires = k.expiresAt ? chalk.dim(` expires: ${k.expiresAt}`) : ''; + console.log(` ${k.name}${expires}`); + } + console.log(); +} + +/** Render API key detail. */ +export function renderApiKeyDetail(key: { + id: string; + name: string; + createdAt?: string; + expiresAt?: string; +}): void { + console.log(chalk.bold('\n API Key Detail:\n')); + console.log(` ID: ${chalk.dim(key.id)}`); + console.log(` Name: ${key.name}`); + if (key.createdAt) console.log(` Created: ${chalk.dim(key.createdAt)}`); + if (key.expiresAt) console.log(` Expires: ${chalk.dim(key.expiresAt)}`); + console.log(); +} + +/** Render customer app list. */ +export function renderCustomerAppList( + apps: Array<{ id?: string; name: string; description?: string }>, +): void { + if (apps.length === 0) { + console.log(chalk.dim(' No customer apps found.\n')); + return; + } + console.log(chalk.bold('\n Customer Apps:\n')); + for (const a of apps) { + if (a.id) console.log(` ${chalk.dim(a.id)}`); + const desc = a.description ? chalk.dim(` — ${a.description.slice(0, 80)}`) : ''; + console.log(` ${a.name}${desc}`); + } + console.log(); +} + +/** Render customer app detail. */ +export function renderCustomerAppDetail(app: { + id?: string; + name: string; + description?: string; + raw: Record; +}): void { + console.log(chalk.bold('\n Customer App Detail:\n')); + if (app.id) console.log(` ID: ${chalk.dim(app.id)}`); + console.log(` Name: ${app.name}`); + if (app.description) console.log(` Desc: ${app.description}`); + console.log(` Data: ${chalk.dim(JSON.stringify(app.raw, null, 2).slice(0, 500))}`); + console.log(); +} + +/** Render deployment profile list. */ +export function renderDeploymentProfileList( + profiles: Array<{ raw: Record }>, +): void { + if (profiles.length === 0) { + console.log(chalk.dim(' No deployment profiles found.\n')); + return; + } + console.log(chalk.bold('\n Deployment Profiles:\n')); + for (const p of profiles) { + const name = (p.raw.dp_name ?? p.raw.profile_name ?? p.raw.name ?? 'unknown') as string; + const status = p.raw.status as string | undefined; + const authCode = p.raw.auth_code as string | undefined; + const statusColor = status === 'active' ? chalk.green : chalk.dim; + console.log( + ` ${name}${status ? ` ${statusColor(status)}` : ''}${authCode ? ` ${chalk.dim(authCode)}` : ''}`, + ); + } + console.log(); +} + +/** Render DLP profile list. */ +export function renderDlpProfileList(profiles: Array<{ raw: Record }>): void { + if (profiles.length === 0) { + console.log(chalk.dim(' No DLP profiles found.\n')); + return; + } + console.log(chalk.bold('\n DLP Profiles:\n')); + for (const p of profiles) { + const name = (p.raw.name ?? p.raw.profile_name ?? 'unknown') as string; + const uuid = (p.raw.uuid ?? '') as string; + if (uuid) console.log(` ${chalk.dim(uuid)}`); + console.log(` ${name}`); + } + console.log(); +} + +/** Render scan log results. */ +export function renderScanLogList(results: Record[], pageToken?: string): void { + if (results.length === 0) { + console.log(chalk.dim(' No scan logs found.\n')); + return; + } + console.log(chalk.bold(`\n Scan Logs (${results.length} results):\n`)); + for (const r of results) { + const action = (r.action ?? r.verdict) as string | undefined; + const app = r.app_name as string | undefined; + const profile = r.profile_name as string | undefined; + const ts = (r.received_ts ?? r.timestamp) as string | undefined; + const scanId = r.scan_id as string | undefined; + const actionColor = action === 'block' ? chalk.red : chalk.green; + if (scanId) console.log(` ${chalk.dim(scanId)}`); + console.log( + ` ${ts ? chalk.dim(ts) : ''} ${action ? actionColor(action) : ''} ${profile ? `[${profile}]` : ''} ${app ?? ''}`, + ); + } + if (pageToken) { + console.log(chalk.dim(`\n Page token: ${pageToken}`)); + } + console.log(); +} From a3d60060fb782da3c78fd4c2620fa71365209960 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 14 Mar 2026 05:10:16 -0500 Subject: [PATCH 2/2] docs: update CLAUDE.md directory structure for renderer split Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 49c8c3f..5c27925 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,14 @@ src/ │ ├── bulk-scan-state.ts # Save/load bulk scan IDs for resume after poll failure │ ├── parse-input.ts # Input file parsing — CSV (prompt column) or plain text (line-per-prompt) │ ├── prompts.ts # Inquirer interactive input collection -│ └── renderer.ts # Terminal output (chalk) +│ └── renderer/ # Terminal output (chalk), split by command group +│ ├── index.ts # Barrel re-exports +│ ├── common.ts # renderError +│ ├── generate.ts # Guardrail loop rendering (header, topic, metrics, analysis) +│ ├── redteam.ts # Red team scan/target/prompt-set rendering +│ ├── runtime.ts # Runtime scan + config management rendering +│ ├── audit.ts # Audit topics, results, conflicts +│ └── modelsecurity.ts # Model security groups/rules/scans rendering ├── config/ │ ├── schema.ts # Zod ConfigSchema — all config fields w/ defaults │ └── loader.ts # Config cascade: CLI > env > file > Zod defaults