diff --git a/README.md b/README.md index 26f3184..df3a64f 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,18 @@ It profiles application repositories, runs registered deterministic security too npm install npm run build npm run dev -- init +npm run dev npm run dev -- audit . ``` By default, LLM runtimes are disabled and scanner tools are detected from your local `PATH`. Enable runtimes in `.secflow/config.yaml` and pass `--approve-context` when you want a curated, redacted context package sent to a configured provider or local agent CLI. +Running `npm run dev` launches the interactive TUI audit wizard. It walks through target selection, preflight checks, live audit progress, optional LLM context approval, and results/report review. + +## WSL Terminal Note + +When running the TUI in WSL through Windows Terminal, there is a known unresolved issue where terminal input mode can remain in an odd state after exit. SecFlow attempts to reset the known modes on shutdown, but this does not fully resolve the issue in all WSL setups. If arrow keys start printing raw sequences or input behaves strangely, open a new terminal tab. + ## Commands ```bash diff --git a/package-lock.json b/package-lock.json index 52bca6e..83c1c44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "commander": "^14.0.2", "execa": "^9.6.0", "ink": "^6.5.0", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "react": "^19.2.0", "yaml": "^2.8.1", "zod": "^4.1.12" @@ -21,6 +24,7 @@ "devDependencies": { "@types/node": "^24.10.1", "@types/react": "^19.2.6", + "ink-testing-library": "^4.0.0", "tsx": "^4.20.6", "typescript": "^5.9.3", "vitest": "^4.0.13" @@ -1140,6 +1144,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-truncate": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", @@ -1515,6 +1531,86 @@ } } }, + "node_modules/ink-select-input": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", + "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", + "license": "MIT", + "dependencies": { + "figures": "^6.1.0", + "to-rotated": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", + "license": "MIT", + "dependencies": { + "cli-spinners": "^2.7.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": ">=4.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink-text-input/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ink/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -2358,6 +2454,18 @@ "node": ">=14.0.0" } }, + "node_modules/to-rotated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", + "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index e5244e4..b81dd5b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "commander": "^14.0.2", "execa": "^9.6.0", "ink": "^6.5.0", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "react": "^19.2.0", "yaml": "^2.8.1", "zod": "^4.1.12" @@ -32,6 +35,7 @@ "devDependencies": { "@types/node": "^24.10.1", "@types/react": "^19.2.6", + "ink-testing-library": "^4.0.0", "tsx": "^4.20.6", "typescript": "^5.9.3", "vitest": "^4.0.13" diff --git a/src/core/auditEngine.ts b/src/core/auditEngine.ts index ff8d401..33a78a2 100644 --- a/src/core/auditEngine.ts +++ b/src/core/auditEngine.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import type {AuditRun, LlmResponse, LlmTask, NormalizedFinding, SecFlowConfig} from './types.js'; +import type {AuditEvent, AuditRun, AuditStep, ContextPreview, LlmResponse, LlmTask, NormalizedFinding, SecFlowConfig} from './types.js'; import {extractBusinessWorkflowModel} from './business.js'; import {buildContextPackage, contextSizeBytes, redactContext} from './context.js'; import {collectFindings} from './findings.js'; @@ -14,59 +14,86 @@ export interface AuditOptions { targetPath: string; config: SecFlowConfig; contextApproved?: boolean; + approveContext?: (preview: ContextPreview) => boolean | Promise; + onEvent?: (event: AuditEvent) => void; runtime?: string; } export async function runAudit(options: AuditOptions): Promise { - const targetPath = path.resolve(options.targetPath); - const runId = new Date().toISOString().replace(/[:.]/g, '-'); - const runDir = path.join(targetPath, options.config.outputs.directory, 'runs', runId); - await ensureDir(runDir); + try { + const targetPath = path.resolve(options.targetPath); + emit(options, 'step:start', 'initialize', `Preparing audit for ${targetPath}.`); + const runId = new Date().toISOString().replace(/[:.]/g, '-'); + const runDir = path.join(targetPath, options.config.outputs.directory, 'runs', runId); + await ensureDir(runDir); + emit(options, 'step:complete', 'initialize', `Run directory created.`, {runId, runDir}); - const profile = await profileRepository(targetPath); - await writeJson(path.join(runDir, 'repo-profile.json'), profile); + emit(options, 'step:start', 'profile', 'Profiling repository.'); + const profile = await profileRepository(targetPath); + await writeJson(path.join(runDir, 'repo-profile.json'), profile); + emit(options, 'step:complete', 'profile', `Profiled ${profile.fileCount} files.`, {fileCount: profile.fileCount}); - const business = await extractBusinessWorkflowModel(targetPath, profile); - await writeJson(path.join(runDir, 'business-workflow.json'), business); + emit(options, 'step:start', 'business-workflows', 'Extracting business workflow signals.'); + const business = await extractBusinessWorkflowModel(targetPath, profile); + await writeJson(path.join(runDir, 'business-workflow.json'), business); + emit(options, 'step:complete', 'business-workflows', `Extracted ${business.risks.length} business logic hypotheses.`, {riskCount: business.risks.length}); - const toolResults = await runConfiguredTools(targetPath, runDir, options.config); - await writeJson(path.join(runDir, 'tool-results.json'), toolResults); + emit(options, 'step:start', 'tools', 'Running registered deterministic security tools.'); + const toolResults = await runConfiguredTools(targetPath, runDir, options.config, (result) => { + options.onEvent?.({type: 'tool:complete', step: 'tools', timestamp: new Date().toISOString(), result}); + }); + await writeJson(path.join(runDir, 'tool-results.json'), toolResults); + emit(options, 'step:complete', 'tools', `Completed ${toolResults.length} tool checks.`, {toolCount: toolResults.length}); - const findings = collectFindings(toolResults, business); - await writeJson(path.join(runDir, 'normalized-findings.json'), findings); + const findings = collectFindings(toolResults, business); + await writeJson(path.join(runDir, 'normalized-findings.json'), findings); - const llmResponses = await maybeInvokeLlm(options, targetPath, profile, business, findings, runDir); - await writeJson(path.join(runDir, 'llm-responses.json'), llmResponses); + const llmResponses = await maybeInvokeLlm(options, targetPath, profile, business, findings, runDir); + await writeJson(path.join(runDir, 'llm-responses.json'), llmResponses); - const reportWithoutPaths = { - runId, - targetPath, - runDir, - profile, - business, - toolResults, - findings, - llmResponses - }; - const reportPath = path.join(runDir, 'report.md'); - const sarifPath = path.join(runDir, 'report.sarif'); - await writeText(reportPath, renderMarkdownReport(reportWithoutPaths)); - await writeJson(sarifPath, renderSarif(findings)); - await writeJson(path.join(runDir, 'manifest.json'), { - runId, - targetPath, - generatedAt: new Date().toISOString(), - reportPath, - sarifPath, - findingCount: findings.length, - llmInvoked: llmResponses.length > 0 - }); + emit(options, 'step:start', 'reports', 'Writing Markdown, JSON, and SARIF reports.'); + const reportWithoutPaths = { + runId, + targetPath, + runDir, + profile, + business, + toolResults, + findings, + llmResponses + }; + const reportPath = path.join(runDir, 'report.md'); + const sarifPath = path.join(runDir, 'report.sarif'); + await writeText(reportPath, renderMarkdownReport(reportWithoutPaths)); + await writeJson(sarifPath, renderSarif(findings)); + await writeJson(path.join(runDir, 'manifest.json'), { + runId, + targetPath, + generatedAt: new Date().toISOString(), + reportPath, + sarifPath, + findingCount: findings.length, + llmInvoked: llmResponses.length > 0 + }); + emit(options, 'step:complete', 'reports', 'Reports written.', {reportPath, sarifPath}); - return { - ...reportWithoutPaths, - reportPath, - sarifPath - }; + const run = { + ...reportWithoutPaths, + reportPath, + sarifPath + }; + options.onEvent?.({type: 'run:complete', step: 'complete', timestamp: new Date().toISOString(), run}); + return run; + } catch (error) { + options.onEvent?.({ + type: 'error', + step: 'error', + timestamp: new Date().toISOString(), + message: error instanceof Error ? error.message : String(error), + error + }); + throw error; + } } async function maybeInvokeLlm( @@ -81,23 +108,47 @@ async function maybeInvokeLlm( const runtime = options.runtime ?? options.config.defaultRuntime; if (!runtime) { await writeJson(path.join(runDir, 'llm-skip.json'), {reason: 'No default runtime configured.'}); - return responses; - } - if (options.config.context.requireApproval && !options.contextApproved) { - await writeJson(path.join(runDir, 'llm-skip.json'), {reason: 'Context approval was required but not provided.'}); + options.onEvent?.({type: 'llm:skipped', step: 'llm', timestamp: new Date().toISOString(), reason: 'No default runtime configured.'}); return responses; } + emit(options, 'step:start', 'context-preview', 'Building LLM context preview.'); const registry = await PromptRegistry.fromDirectory(targetPath, options.config.prompts.directory); registry.validateRequired(options.config.prompts.required); const context = redactContext(buildContextPackage(profile, business, findings), options.config.context.redactions); const size = contextSizeBytes(context); - await writeJson(path.join(runDir, 'llm-context-preview.json'), {sizeBytes: size, context}); + const contextPath = path.join(runDir, 'llm-context-preview.json'); + await writeJson(contextPath, {sizeBytes: size, context}); + const preview: ContextPreview = { + runtime, + promptId: 'report-synthesis', + sizeBytes: size, + maxBytes: options.config.context.maxBytes, + requireApproval: options.config.context.requireApproval, + redactionPatternCount: options.config.context.redactions.length, + contextPath + }; + options.onEvent?.({type: 'context:preview', step: 'context-preview', timestamp: new Date().toISOString(), preview}); + emit(options, 'step:complete', 'context-preview', `LLM context preview is ${size} bytes.`, {sizeBytes: size, contextPath}); if (size > options.config.context.maxBytes) { await writeJson(path.join(runDir, 'llm-skip.json'), {reason: `Context package exceeded ${options.config.context.maxBytes} bytes.`, sizeBytes: size}); + options.onEvent?.({ + type: 'llm:skipped', + step: 'llm', + timestamp: new Date().toISOString(), + reason: `Context package exceeded ${options.config.context.maxBytes} bytes.` + }); return responses; } + const approved = await resolveContextApproval(options, preview); + if (options.config.context.requireApproval && !approved) { + await writeJson(path.join(runDir, 'llm-skip.json'), {reason: 'Context approval was required but not provided.', preview}); + options.onEvent?.({type: 'llm:skipped', step: 'llm', timestamp: new Date().toISOString(), reason: 'Context approval was required but not provided.'}); + return responses; + } + + emit(options, 'step:start', 'llm', `Invoking LLM runtime ${runtime}.`); const task: LlmTask = { id: 'report-synthesis', promptId: 'report-synthesis', @@ -114,5 +165,32 @@ async function maybeInvokeLlm( if (response) { responses.push(response); } + emit(options, 'step:complete', 'llm', response ? `Runtime ${runtime} completed.` : `Runtime ${runtime} was not invoked.`); return responses; } + +export function createAuditRunner(options: AuditOptions): {run: () => Promise} { + return { + run: () => runAudit(options) + }; +} + +async function resolveContextApproval(options: AuditOptions, preview: ContextPreview): Promise { + if (!options.config.context.requireApproval) { + return true; + } + if (options.approveContext) { + return Boolean(await options.approveContext(preview)); + } + return Boolean(options.contextApproved); +} + +function emit(options: AuditOptions, type: 'step:start' | 'step:complete', step: AuditStep, message: string, data?: Record): void { + options.onEvent?.({ + type, + step, + message, + timestamp: new Date().toISOString(), + data + }); +} diff --git a/src/core/types.ts b/src/core/types.ts index 3e44a1a..b70fa98 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -160,3 +160,64 @@ export interface AuditRun { reportPath: string; sarifPath: string; } + +export type AuditStep = + | 'initialize' + | 'profile' + | 'business-workflows' + | 'tools' + | 'context-preview' + | 'llm' + | 'reports' + | 'complete' + | 'error'; + +export interface ContextPreview { + runtime: string; + promptId: string; + sizeBytes: number; + maxBytes: number; + requireApproval: boolean; + redactionPatternCount: number; + contextPath: string; +} + +export type AuditEvent = + | { + type: 'step:start' | 'step:complete'; + step: AuditStep; + message: string; + timestamp: string; + data?: Record; + } + | { + type: 'tool:complete'; + step: 'tools'; + timestamp: string; + result: ToolRunResult; + } + | { + type: 'context:preview'; + step: 'context-preview'; + timestamp: string; + preview: ContextPreview; + } + | { + type: 'llm:skipped'; + step: 'llm'; + timestamp: string; + reason: string; + } + | { + type: 'run:complete'; + step: 'complete'; + timestamp: string; + run: AuditRun; + } + | { + type: 'error'; + step: 'error'; + timestamp: string; + message: string; + error?: unknown; + }; diff --git a/src/index.tsx b/src/index.tsx index f77a84b..4661b91 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,13 +1,12 @@ #!/usr/bin/env node import React from 'react'; import {Command} from 'commander'; -import {render} from 'ink'; import {auditCommand} from './cli/audit.js'; import {doctorCommand} from './cli/doctor.js'; import {initProject} from './cli/init.js'; import {modelsCommand} from './cli/models.js'; import {validatePlaybookCommand} from './cli/playbooks.js'; -import {App} from './tui/App.js'; +import {renderFullscreenTui} from './tui/render.js'; const program = new Command(); @@ -15,8 +14,8 @@ program .name('secflow') .description('LLM harness for application security engineers and application defenders') .version('0.1.0') - .action(() => { - render(); + .action(async () => { + await renderFullscreenTui(process.cwd()); }); program diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 5f7a5b8..872bacf 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -6,14 +6,21 @@ import {commandExists, commandVersion} from '../util/process.js'; export const builtInToolAdapters = [semgrepAdapter, trivyAdapter, joernAdapter]; -export async function runConfiguredTools(targetPath: string, runDir: string, config: SecFlowConfig): Promise { +export async function runConfiguredTools( + targetPath: string, + runDir: string, + config: SecFlowConfig, + onToolComplete?: (result: ToolRunResult) => void +): Promise { const results: ToolRunResult[] = []; for (const adapter of builtInToolAdapters) { const toolConfig = config.tools[adapter.name]; if (!toolConfig) { continue; } - results.push(await adapter.run(targetPath, runDir, toolConfig)); + const result = await adapter.run(targetPath, runDir, toolConfig); + results.push(result); + onToolComplete?.(result); } return results; } diff --git a/src/tui/App.tsx b/src/tui/App.tsx index c1e6649..b4b2f27 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -1,26 +1,400 @@ -import React from 'react'; -import {Box, Text} from 'ink'; +import React, {useRef, useState} from 'react'; +import path from 'node:path'; +import {Box, Text, useApp, useInput} from 'ink'; +import SelectInput from 'ink-select-input'; +import TextInput from 'ink-text-input'; +import Spinner from 'ink-spinner'; +import type {AuditEvent, AuditRun, ContextPreview} from '../core/types.js'; +import {runAudit as defaultRunAudit, type AuditOptions} from '../core/auditEngine.js'; +import {ContextApprovalSummary, EventLog, PreflightSummary, ProgressRail, ResultsSummary, Section} from './components.js'; +import {fitText, TerminalSizeProvider, useTerminalSize} from './layout.js'; +import {loadPreflightData, type PreflightData} from './preflight.js'; +import {theme} from './theme.js'; + +type Screen = 'home' | 'commands' | 'target' | 'preflight-loading' | 'preflight' | 'running' | 'context-approval' | 'results' | 'error' | 'confirm-exit'; +type HomeAction = 'start' | 'commands' | 'quit'; +type PreflightAction = 'run' | 'back' | 'quit'; +type ApprovalAction = 'approve' | 'skip'; +type ConfirmExitAction = 'stay' | 'exit'; export interface AppProps { cwd: string; + screenMode?: 'alternate' | 'viewport'; + loadPreflight?: (cwd: string, targetPath: string) => Promise; + runAudit?: (options: AuditOptions) => Promise; } -export function App({cwd}: AppProps): React.ReactElement { +export function App({cwd, screenMode = 'viewport', loadPreflight = loadPreflightData, runAudit = defaultRunAudit}: AppProps): React.ReactElement { return ( - - - SecFlow - - Application security LLM harness for repository profiling, deterministic scanners, and business logic analysis. - Workspace: {cwd} - - Useful commands - secflow init - secflow audit . --approve-context - secflow tools doctor - secflow models list - secflow playbooks validate playbooks/default-audit.yaml + + + + ); +} + +interface AppBodyProps { + cwd: string; + screenMode: 'alternate' | 'viewport'; + loadPreflight: (cwd: string, targetPath: string) => Promise; + runAudit: (options: AuditOptions) => Promise; +} + +function AppBody({cwd, screenMode, loadPreflight, runAudit}: AppBodyProps): React.ReactElement { + const {exit} = useApp(); + const terminal = useTerminalSize(); + const fullHeight = screenMode === 'alternate'; + const [screen, setScreen] = useState('home'); + const [targetPath, setTargetPath] = useState(cwd); + const [preflight, setPreflight] = useState(); + const [events, setEvents] = useState([]); + const [run, setRun] = useState(); + const [error, setError] = useState(); + const [approvalRequest, setApprovalRequest] = useState<{preview: ContextPreview; resolve: (approved: boolean) => void} | undefined>(); + const [selectionIndexes, setSelectionIndexes] = useState>({}); + const activeRun = useRef(false); + + useInput((input, key) => { + if (key.tab || input === '\t') { + advanceSelection(screen, setSelectionIndexes); + } + if ((key.return || input === '\r' || input === '\n') && screen === 'target') { + void preparePreflight(targetPath); + } + if (input === 'q') { + if (activeRun.current) { + setScreen('confirm-exit'); + } else { + exit(); + } + } + if (key.escape) { + if (screen === 'target') setScreen('home'); + if (screen === 'commands') setScreen('home'); + if (screen === 'preflight') setScreen('target'); + if (screen === 'results' || screen === 'error') setScreen('home'); + if (screen === 'running' || screen === 'context-approval') setScreen('confirm-exit'); + if (screen === 'confirm-exit') setScreen(activeRun.current ? 'running' : 'home'); + } + }); + + async function preparePreflight(value: string): Promise { + const requestedPath = value.trim() || cwd; + setTargetPath(requestedPath); + setError(undefined); + setScreen('preflight-loading'); + try { + setPreflight(await loadPreflight(cwd, requestedPath)); + setScreen('preflight'); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + setScreen('error'); + } + } + + async function startAudit(): Promise { + if (!preflight) { + return; + } + activeRun.current = true; + setEvents([]); + setRun(undefined); + setApprovalRequest(undefined); + setError(undefined); + setScreen('running'); + + try { + const completedRun = await runAudit({ + targetPath: preflight.targetPath, + config: preflight.config, + runtime: preflight.defaultRuntime, + onEvent: (event) => setEvents((current) => [...current, event]), + approveContext: (preview) => + new Promise((resolve) => { + setApprovalRequest({preview, resolve}); + setScreen('context-approval'); + }) + }); + setRun(completedRun); + setScreen('results'); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + setScreen('error'); + } finally { + activeRun.current = false; + setApprovalRequest(undefined); + } + } + + function resolveApproval(approved: boolean): void { + approvalRequest?.resolve(approved); + setApprovalRequest(undefined); + setScreen('running'); + } + + return ( + + +
+ {terminal.rows < 18 ? Terminal is short; expand it for the full audit view. : null} + {screen === 'home' && handleHomeAction(action, setScreen, exit)} />} + {screen === 'commands' && } + {screen === 'target' && } + {screen === 'preflight-loading' && } + {screen === 'preflight' && preflight && handlePreflightAction(action, startAudit, setScreen, exit)} />} + {screen === 'running' && } + {screen === 'context-approval' && approvalRequest && } + {screen === 'results' && run && (action === 'home' ? setScreen('home') : exit())} />} + {screen === 'error' && } + {screen === 'confirm-exit' && handleExitConfirmation(action, approvalRequest?.resolve, setScreen, exit)} />} + +