Files
shannon/apps/cli/src/commands/start.ts
T
ezl-keygraph 0ab7c0b41b feat(preflight): add exploit-readiness probe and --validate-auth mode, and refresh suggested models (#476)
* feat(preflight): gate scans on an exploit-workload readiness probe

* feat: add --validate-auth to run authentication validation only

* feat: refuse reusing an auth-validation workspace for a scan

* chore: refresh suggested model IDs (Grok 4.7, OpenAI gpt-6-sol, Claude 5)

* fix(preflight): make the exploit-readiness probe trip the cyber safeguard reliably

* chore(preflight): update the exploit-readiness probe prompt

* feat: add --validate-model to run the preflight model checks only

* feat(cli): name cyber-access and app-login steps in the start loader

* feat(cli): refine start loader — skip app-login step when following, annotate preflight label

* feat(status): show Preflight and Cyber access verification rows for gated providers

* chore(preflight): suggest a fallback model in cyber-access remediation hints

* chore(preflight): drop env-var syntax from cyber-access fallback hints

* fix(preflight): separate finding heading from Target line in readiness probe

* refactor(preflight): rename exploit-readiness probe to cyber access verification

* fix(preflight): re-join finding heading with Target line in readiness probe

Reverts the heading/Target split from 22d84f2, gluing each finding's
heading back onto its Target line in the cyber-access probe's user
content.

* feat(validation): show a Checks summary in the validation log

* fix(cli): say a validation run failed, not that it could not start

* docs(ai-providers): replace broken Pi subscription link with /login steps

* feat(preflight): gate the openai-codex subscription on cyber access
2026-10-05 23:58:14 +05:30

801 lines
32 KiB
TypeScript

/**
* `shannon start` command — launch a pentest scan.
*
* Handles both local mode (local build, ./workspaces/, mounted prompts)
* and npx mode (Docker Hub pull, ~/.shannon/).
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import * as p from '@clack/prompts';
import { ensureDocker, ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js';
import { fail, warn } from '../errors.js';
import { getWorkspacesDir, initHome } from '../home.js';
import { commandPrefix, isLocal } from '../mode.js';
import { resolveModelSpec } from '../model-spec.js';
import {
expandHome,
FINAL_REPORT_MD_FILENAME,
FINAL_REPORT_PDF_FILENAME,
INTERNAL_DIR,
resolveConfig,
resolveModelsConfig,
resolveRepo,
resolveRunFile,
STARTUP_ERROR_FILENAME,
} from '../paths.js';
import { clearPendingWorkflowIdentity, writePendingWorkflowIdentity } from '../pending-workflow.js';
import { indentFailureSegments, parseFailureSegments } from '../scan/failure.js';
import { resolveWorkflowId } from '../session.js';
import { displayPlainBanner, displaySplash } from '../splash.js';
import {
describeWorkflowLifecycle,
getTerminalOutcome,
queryProgress,
runningActivityTypes,
} from '../temporal-client.js';
import { stdoutIsTerminal } from '../tty.js';
import { tailUntilComplete } from './logs.js';
export interface StartArgs {
url: string;
repo: string;
config?: string;
modelsConfig?: string;
workspace?: string;
output?: string;
pipelineTesting: boolean;
keepContainer: boolean;
follow: boolean;
authOnly: boolean;
validateModel: boolean;
version: string;
}
const LAUNCH_STATE_SCHEMA_VERSION = 1 as const;
const LAUNCH_STATE_FILENAME = 'launch.json';
const FIXED_CLASSES = ['injection', 'xss', 'auth', 'authz', 'ssrf'] as const;
/**
* CLI-owned launch record at INTERNAL_DIR/launch.json, written once when a workspace is
* created and never rewritten. It pins the customer output destination so a resume with a
* different -o cannot silently redirect the final report. The worker does not read it.
*/
interface LaunchState {
readonly schema_version: typeof LAUNCH_STATE_SCHEMA_VERSION;
readonly customer_output_path?: string;
/** True when the workspace was created by an auth-validation run; such a workspace is not a scan. */
readonly auth_only?: boolean;
/** True when the workspace was created by a model-validation run; such a workspace is not a scan. */
readonly model_only?: boolean;
}
export interface WorkspaceLaunchDecision {
readonly isResume: boolean;
readonly outputDir?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function arraysEqual(left: readonly unknown[], right: readonly unknown[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/**
* Hand-rolled twin of the worker's durable-state validator in
* apps/worker/src/types/run-state.ts, which owns the session.json.durableScanState shape.
* Each array check accepts two variants because the worker appends 'miscellaneous' and
* 'miscellaneous-exploit' only after the miscellaneous pipeline admits findings. If the worker's shape
* changes and this twin lags, resume fails fast as incompatible instead of launching a
* worker against state it would misread.
*/
function isCurrentDurableState(value: unknown): boolean {
if (!isRecord(value) || value.schema_version !== 1 || typeof value.exploit !== 'boolean') return false;
if (!Array.isArray(value.participating_classes) || !Array.isArray(value.expected_agents)) return false;
const participating = value.participating_classes;
const validParticipation =
arraysEqual(participating, FIXED_CLASSES) || arraysEqual(participating, [...FIXED_CLASSES, 'miscellaneous']);
if (!validParticipation) return false;
const baselineAgents = ['pre-recon', 'recon', ...FIXED_CLASSES.map((name) => `${name}-vuln`)];
if (value.exploit) baselineAgents.push(...FIXED_CLASSES.map((name) => `${name}-exploit`));
baselineAgents.push('report');
const expected = value.expected_agents;
return arraysEqual(expected, baselineAgents) || arraysEqual(expected, [...baselineAgents, 'miscellaneous-exploit']);
}
/** One refusal for damaged CLI-owned or worker-owned workspace records, whichever reads first. */
const DAMAGED_RECORDS_MESSAGE =
"This workspace's internal records are damaged and it cannot be resumed. Its report files are untouched. Start a new scan with a different -w name.";
const NEWER_RELEASE_MESSAGE =
'This workspace was created by a newer version of Shannon. Upgrade Shannon, or start a new scan with a different -w name.';
function readJsonFile(filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
fail(DAMAGED_RECORDS_MESSAGE);
}
}
function readLaunchState(filePath: string): LaunchState {
if (!fs.existsSync(filePath)) {
fail(
'This workspace was created by an earlier version of Shannon and cannot be resumed. Its files and report are untouched. Start a new scan with a different -w name.',
);
}
const value = readJsonFile(filePath);
if (!isRecord(value)) fail(NEWER_RELEASE_MESSAGE);
// Unknown keys mean a newer release wrote this workspace; refuse rather than half-read it.
const keys = Object.keys(value).sort();
const keysAreValid = keys.every(
(key) => key === 'auth_only' || key === 'model_only' || key === 'customer_output_path' || key === 'schema_version',
);
const customerPath = value.customer_output_path;
const pathIsValid =
customerPath === undefined ||
(typeof customerPath === 'string' && path.isAbsolute(customerPath) && path.resolve(customerPath) === customerPath);
const authOnly = value.auth_only;
const authOnlyIsValid = authOnly === undefined || typeof authOnly === 'boolean';
const modelOnly = value.model_only;
const modelOnlyIsValid = modelOnly === undefined || typeof modelOnly === 'boolean';
if (
value.schema_version !== LAUNCH_STATE_SCHEMA_VERSION ||
!keysAreValid ||
!pathIsValid ||
!authOnlyIsValid ||
!modelOnlyIsValid
) {
fail(NEWER_RELEASE_MESSAGE);
}
return {
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
...(typeof customerPath === 'string' && { customer_output_path: customerPath }),
...(authOnly === true && { auth_only: true }),
...(modelOnly === true && { model_only: true }),
};
}
/**
* Decide fresh-versus-resume from on-disk state alone, before start() mutates anything.
* A fresh launch requires the workspace directory to be absent or empty; a resume requires
* current-release session state, a matching target URL, and a customer output path that
* agrees with the recorded one. Every other combination fails the launch, so a typo in
* -w or -o stops here instead of spawning a worker into the wrong workspace.
*/
export function classifyWorkspaceLaunch(
workspacePath: string,
expectedUrl: string,
requestedOutputDir: string | undefined,
requestedAuthOnly: boolean,
requestedModelOnly: boolean,
): WorkspaceLaunchDecision {
const sessionPath = resolveRunFile(workspacePath, 'session.json');
const sessionExists = fs.existsSync(sessionPath);
if (!sessionExists) {
if (fs.existsSync(workspacePath) && fs.readdirSync(workspacePath).length > 0) {
fail(
'This directory is not a Shannon workspace, or its scan state is missing. Start a new scan with a different -w name.',
);
}
return { isResume: false, ...(requestedOutputDir !== undefined && { outputDir: requestedOutputDir }) };
}
const launchPath = path.join(workspacePath, INTERNAL_DIR, LAUNCH_STATE_FILENAME);
const launch = readLaunchState(launchPath);
if (launch.auth_only && !requestedAuthOnly) {
fail(
'This workspace was created to validate authentication only, so it cannot be run as a scan. Start a new scan with a different -w name.',
);
}
if (launch.model_only && !requestedModelOnly) {
fail(
'This workspace was created to validate the AI model only, so it cannot be run as a scan. Start a new scan with a different -w name.',
);
}
const session = readJsonFile(sessionPath);
if (!isRecord(session) || !isRecord(session.session) || session.session.webUrl !== expectedUrl) {
fail(
'This workspace was created for a different target URL, so it cannot be resumed against this one. Check -u, or start a new scan with a different -w name.',
);
}
if (!isCurrentDurableState(session.durableScanState)) {
fail(
"This workspace's scan state cannot be read by this version. Its files are untouched. Start a new scan with a different -w name.",
);
}
const storedOutputDir = launch.customer_output_path;
if (requestedOutputDir !== undefined && requestedOutputDir !== storedOutputDir) {
fail(
'This workspace already copies its report to a different location than the -o path you passed. Re-run without -o to keep the original location, or start a new scan with a different -w name.',
);
}
return { isResume: true, ...(storedOutputDir !== undefined && { outputDir: storedOutputDir }) };
}
/**
* Crash-safe single write: exclusive temp file (pid plus random suffix keeps concurrent
* starts apart), fsync, rename into place, then directory fsync so the entry survives a
* host crash. Callers invoke this only for a fresh workspace; an existing launch.json is
* the resume contract and must never be replaced.
*/
export function writeLaunchStateAtomically(
internalPath: string,
outputDir: string | undefined,
authOnly: boolean,
modelOnly: boolean,
): void {
const finalPath = path.join(internalPath, LAUNCH_STATE_FILENAME);
const temporaryPath = path.join(internalPath, `${LAUNCH_STATE_FILENAME}.tmp-${process.pid}-${randomSuffix()}`);
const launchState: LaunchState = {
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
...(outputDir !== undefined && { customer_output_path: outputDir }),
...(authOnly && { auth_only: true }),
...(modelOnly && { model_only: true }),
};
const descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
try {
fs.writeFileSync(descriptor, `${JSON.stringify(launchState, null, 2)}\n`, 'utf8');
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
try {
fs.renameSync(temporaryPath, finalPath);
const directory = fs.openSync(internalPath, 'r');
try {
fs.fsyncSync(directory);
} finally {
fs.closeSync(directory);
}
} catch (error) {
fs.rmSync(temporaryPath, { force: true });
throw error;
}
}
/** Select the workflow ID before Docker starts so the container can carry it as immutable identity. */
export function createWorkflowId(workspace: string, isResume: boolean, timestamp: number = Date.now()): string {
if (isResume) return `${workspace}_resume_${timestamp}`;
return /_shannon-\d+$/.test(workspace) ? workspace : `${workspace}_shannon-${timestamp}`;
}
export async function start(args: StartArgs): Promise<void> {
// Validation-only runs are short and have no report to come back for, so they always stream to the end.
const validationOnly = args.authOnly || args.validateModel;
if (validationOnly) args.follow = true;
// 1. Resolve non-mutating inputs and classify the workspace before changing it.
initHome();
loadEnv();
const creds = validateCredentials();
if (!creds.valid) {
fail(creds.error ?? 'Invalid credentials');
}
const repo = resolveRepo(args.repo);
const config = args.config ? resolveConfig(args.config) : undefined;
const modelsConfig = args.modelsConfig ? resolveModelsConfig(args.modelsConfig) : undefined;
const workspacesDir = getWorkspacesDir();
const workspace =
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
const workspacePath = path.join(workspacesDir, workspace);
const requestedOutputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
const launchDecision = classifyWorkspaceLaunch(
workspacePath,
args.url,
requestedOutputDir,
args.authOnly,
args.validateModel,
);
// Validation-only runs write no resumable state, so they always run fresh; reusing a workspace would resume it.
if (validationOnly && launchDecision.isResume) {
const what = args.authOnly ? 'An auth-validation run' : 'A model-validation run';
fail(`${what} needs a fresh workspace. Omit -w to auto-name one, or choose a -w name that is not in use.`);
}
// User-facing status wording. Auth-only and model-only are both "validation" runs, but each
// names what it validated. A validation run *is* the checks, so a failure means it ran and
// failed, not that it could not start. A plain scan keeps its original phrasing.
let startingLabel = 'Starting scan';
let waitingLabel = 'Waiting for the scan to start';
let couldNotStartLabel = 'The scan could not start';
let startedLabel = `Scan started — ${workspace}`;
if (args.authOnly) {
startingLabel = 'Starting authentication validation';
waitingLabel = 'Waiting for authentication validation to start';
couldNotStartLabel = 'Authentication validation failed';
startedLabel = `Validating authentication — ${workspace}`;
} else if (args.validateModel) {
startingLabel = 'Starting model validation';
waitingLabel = 'Waiting for model validation to start';
couldNotStartLabel = 'Model validation failed';
startedLabel = `Validating model — ${workspace}`;
}
// 2. Inputs are valid; identify the run before initializing shared infrastructure.
const bannerVersion = isLocal() ? undefined : args.version;
if (stdoutIsTerminal()) {
displaySplash(bannerVersion);
} else {
displayPlainBanner(bannerVersion);
}
fs.mkdirSync(workspacesDir, { recursive: true });
fs.chmodSync(workspacesDir, 0o777);
ensureDocker();
ensureImage(args.version);
const spinner = p.spinner();
spinner.start(startingLabel);
await ensureInfra(spinner);
// 3. Generate the invocation identity.
const suffix = randomSuffix();
const taskQueue = `shannon-${suffix}`;
const containerName = `shannon-worker-${suffix}`;
const workflowId = createWorkflowId(workspace, launchDecision.isResume);
// 4. Create writable overlay directories after resume validation has succeeded.
// The run dir and its INTERNAL_DIR must be 0o777 so the container user can create audit
// subdirs and the overlay backing dirs.
const internalPath = path.join(workspacePath, INTERNAL_DIR);
fs.mkdirSync(workspacePath, { recursive: true });
fs.chmodSync(workspacePath, 0o777);
fs.mkdirSync(internalPath, { recursive: true });
fs.chmodSync(internalPath, 0o777);
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
const dirPath = path.join(internalPath, dir);
fs.mkdirSync(dirPath, { recursive: true });
fs.chmodSync(dirPath, 0o777);
}
if (!launchDecision.isResume) {
writeLaunchStateAtomically(internalPath, launchDecision.outputDir, args.authOnly, args.validateModel);
}
// 5. Pre-create overlay mount points (:ro mounts cannot create them).
const shannonDir = path.join(repo.hostPath, '.shannon');
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
}
fs.mkdirSync(path.join(repo.hostPath, '.playwright'), { recursive: true });
// 6. Create the validated customer-copy destination, if configured.
const outputDir = launchDecision.outputDir;
if (outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 7. Resolve prompts and capture the pre-launch resume counter.
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
const sessionJson = resolveRunFile(workspacePath, 'session.json');
const isResume = launchDecision.isResume;
let initialResumeCount = 0;
if (isResume) {
// Docker and Temporal startup sit between this read and the classification that validated the
// same file, so a file that changed in between is a workspace-state failure, not a CLI bug.
const session = readJsonFile(sessionJson);
const attempts = isRecord(session) && isRecord(session.session) ? session.session.resumeAttempts : undefined;
initialResumeCount = Array.isArray(attempts) ? attempts.length : 0;
}
// 8. Persist the exact launch candidate before Docker can start the worker. Session
// registration later replaces this bridge as the durable workflow identity.
try {
writePendingWorkflowIdentity(workspacePath, workflowId, taskQueue);
} catch {
spinner.error('Could not record the scan workflow identity');
process.exit(1);
}
// Clear a stale startup-error from a previous launch so the poll reacts only to this worker's.
const startupErrorPath = path.join(internalPath, STARTUP_ERROR_FILENAME);
fs.rmSync(startupErrorPath, { force: true });
// 9. Spawn the worker container.
const proc = spawnWorker({
version: args.version,
url: args.url,
repo,
workspacesDir,
taskQueue,
workflowId,
containerName,
envFlags: buildEnvFlags(),
...(config && { config }),
...(modelsConfig && { modelsConfig }),
...(promptsDir && { promptsDir }),
...(outputDir && { outputDir }),
workspace,
...(args.pipelineTesting && { pipelineTesting: true }),
...(args.keepContainer && { keepContainer: true }),
...(args.authOnly && { authOnly: true }),
...(args.validateModel && { validateModel: true }),
...(shouldUsePiAuth() && { piAuthHostPath: resolveHostPiAuthPath() }),
});
// Bail if `docker run -d` itself fails (mount error, image missing, etc.)
const dockerExitCode = await new Promise<number>((resolve) => {
proc.once('exit', (code) => resolve(code ?? 1));
proc.once('error', () => resolve(1));
});
if (dockerExitCode !== 0) {
spinner.error('Could not start the scan');
process.exit(1);
}
let started = false;
// Set when the startup poll times out but session.json already holds durable state this
// release understands: the workflow is executing, so the exit handler must not stop its
// worker. An operator abort is a different intent and still stops it.
let scanRunningUnconfirmed = false;
// Stop the worker only if the scan hasn't registered yet (e.g. Ctrl-C mid-startup).
let cleaned = false;
const stopWorker = (): void => {
if (cleaned || started) return;
cleaned = true;
spinner.stop('Stopping scan');
try {
execFileSync('docker', ['stop', containerName], { stdio: 'pipe' });
} catch {
// Container may have already exited
}
if (args.keepContainer) {
printPreservedContainerHint(containerName);
}
};
process.on('SIGINT', () => {
stopWorker();
process.exit(0);
});
process.on('SIGTERM', () => {
stopWorker();
process.exit(0);
});
process.on('exit', () => {
if (scanRunningUnconfirmed) return;
stopWorker();
});
// Poll for the workflow to register in session.json; the spinner resolves once it does.
spinner.message(waitingLabel);
for (let attempts = 0; attempts < 60; attempts++) {
// A pre-workflow failure leaves its reason here (nothing reached Temporal); surface it
// rather than polling out to a generic timeout.
const startupError = readStartupError(startupErrorPath);
if (startupError) {
cleaned = true; // The worker already exited; nothing to stop.
spinner.error('The scan could not start');
printStartupError(startupError);
process.exit(1);
}
try {
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? [];
// Fresh: session.json appears with originalWorkflowId. Resume: new resumeAttempts entry.
const ready = isResume
? resumeAttempts.slice(initialResumeCount).some((attempt) => attempt.workflowId === workflowId)
: session.session?.originalWorkflowId === workflowId;
if (ready) {
started = true;
try {
clearPendingWorkflowIdentity(workspacePath, taskQueue);
} catch {
warn(`Scan ${workspace} started, but its launch record could not be removed.`);
}
// Hold until startup clears, so an unreachable target or bad credential is reported here
// rather than after "Scan started".
spinner.message(PREFLIGHT_LABEL);
const spec = resolveModelSpec();
const providerId = typeof spec === 'string' ? '' : spec.providerId;
// Cyber-access verification only runs for OpenAI/Anthropic; when following, the tailed log shows the login.
// Mirrors CYBER_GATED_PROVIDERS in the worker (apps/worker/src/services/cyber-access-verification.ts).
const showCyberAccess = providerId === 'anthropic' || providerId === 'openai' || providerId === 'openai-codex';
const outcome = await awaitStartupOutcome(workflowId, (label) => spinner.message(label), {
showCyberAccess,
showAppLogin: !args.follow,
});
if (outcome.kind === 'failed') {
spinner.error(couldNotStartLabel);
printScanStartFailure(outcome.message);
process.exit(1);
}
spinner.stop(startedLabel);
printInfo(args, workspace, repo.hostPath, workspacesDir);
if (args.follow) {
await followScan(workspace, workspacesDir, validationOnly);
}
return;
}
} catch {
// File doesn't exist yet
}
await sleep(2000);
}
if (classifyStartupTimeout(sessionJson) === 'scan-running') {
scanRunningUnconfirmed = true;
spinner.error('The scan started, but this CLI could not confirm it');
printUnconfirmedScanHint(workspace, taskQueue, containerName);
process.exit(1);
}
spinner.error('Timed out waiting for the scan to start');
process.exit(1);
}
/**
* Read the startup timeout: 'scan-running' when session.json already holds durable state this
* release understands, which only the worker writes and only after Temporal began executing the
* workflow; 'unregistered' when nothing proves the scan started. The distinction decides whether
* timing out may stop the worker container.
*/
export function classifyStartupTimeout(sessionJsonPath: string): 'unregistered' | 'scan-running' {
let session: unknown;
try {
session = JSON.parse(fs.readFileSync(sessionJsonPath, 'utf-8'));
} catch {
return 'unregistered';
}
if (!isRecord(session) || !isCurrentDurableState(session.durableScanState)) {
return 'unregistered';
}
return 'scan-running';
}
/** A pre-workflow failure the worker persisted; mirrors StartupErrorRecord in the worker. */
interface StartupError {
phase?: string;
code?: string;
message?: string;
}
/**
* Read the worker's pre-workflow failure record, if it wrote one. Undefined until the file exists
* and parses, so a partial write is simply re-read on the next poll rather than treated as failure.
*/
function readStartupError(startupErrorPath: string): StartupError | undefined {
let raw: string;
try {
raw = fs.readFileSync(startupErrorPath, 'utf-8');
} catch {
return undefined;
}
try {
const parsed = JSON.parse(raw);
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
/** Outcome of waiting for in-workflow startup (preflight + auth validation) to clear. */
type PreflightOutcome = { kind: 'passed' } | { kind: 'failed'; message: string } | { kind: 'unconfirmed' };
const PREFLIGHT_LABEL = 'Running preflight checks (LLM credentials, target URL)';
const CYBER_ACCESS_LABEL = 'Checking cyber access';
const APP_LOGIN_LABEL = 'Verifying app login with provided credentials';
/**
* Drive the startup spinner until the pentest begins, naming the cyber-access verification and the app
* login while their activity runs. Labels only advance, so a gap between them holds the last step
* rather than reverting to the generic line. Passed once the phase moves past preflight/auth (or
* the scan closed ok), failed on a terminal error, unconfirmed if a query outage outlasts the bound.
*/
async function awaitStartupOutcome(
workflowId: string,
onLabel: (label: string) => void,
opts: { showCyberAccess: boolean; showAppLogin: boolean },
): Promise<PreflightOutcome> {
// Wait through auth-validation only when naming the login step; otherwise stop once it begins.
const startupPhases = opts.showAppLogin ? new Set(['preflight', 'auth-validation']) : new Set(['preflight']);
let rank = 0;
let label = PREFLIGHT_LABEL;
for (let attempts = 0; attempts < 80; attempts++) {
try {
const lifecycle = await describeWorkflowLifecycle(workflowId);
if (lifecycle.kind === 'terminal') {
const outcome = await getTerminalOutcome(workflowId);
return outcome.kind === 'failed' ? { kind: 'failed', message: outcome.message } : { kind: 'passed' };
}
const running = await runningActivityTypes(workflowId);
if (opts.showCyberAccess && rank < 1 && running.includes('runCyberAccessVerification')) {
rank = 1;
label = CYBER_ACCESS_LABEL;
}
if (opts.showAppLogin && rank < 2 && running.includes('runAuthenticationValidation')) {
rank = 2;
label = APP_LOGIN_LABEL;
}
onLabel(label);
const progress = await queryProgress(workflowId);
if (progress && progress.currentPhase !== null && !startupPhases.has(progress.currentPhase)) {
return { kind: 'passed' };
}
} catch {
// Transient query failure; keep waiting within the bound.
}
await sleep(1500);
}
return { kind: 'unconfirmed' };
}
/** Print a preflight failure: context line, then the indented reason and hint, then the reference code. */
function printScanStartFailure(message: string): void {
const segments = parseFailureSegments(message);
const phaseContext = segments.shift() ?? 'The scan failed';
const last = segments[segments.length - 1];
const reference = last?.startsWith('Reference code:') ? segments.pop() : undefined;
const lines = [` ${phaseContext}`, '', ...segments.map((segment) => ` ${segment}`)];
if (reference) {
lines.push('', ` ${reference}`);
}
console.error(`\n${lines.join('\n')}\n`);
}
/** Print the worker's persisted startup-failure reason, with its reference code when present. */
function printStartupError(startupError: StartupError): void {
const message =
typeof startupError.message === 'string' && startupError.message.trim()
? startupError.message.trim()
: 'The worker rejected the scan before it could start. Check the configuration file passed with -c.';
console.error('');
for (const line of message.split('\n')) {
console.error(line.length > 0 ? ` ${line}` : '');
}
if (typeof startupError.code === 'string' && startupError.code.trim()) {
console.error('');
console.error(` Reference code: ${startupError.code.trim()}`);
}
console.error('');
}
/** Point the operator at a scan that is running but whose startup this CLI could not confirm. */
function printUnconfirmedScanHint(workspace: string, taskQueue: string, containerName: string): void {
console.log('');
console.log(' The scan is running and was left alone; only its startup confirmation is missing.');
console.log('');
console.log(` Workspace: ${workspace}`);
console.log(` Task queue: ${taskQueue}`);
console.log(` Container: ${containerName}`);
console.log('');
console.log(' Inspect it:');
console.log(` Live logs: ${commandPrefix()} logs ${workspace}`);
console.log(` Worker logs: docker logs ${containerName}`);
console.log(' Dashboard: http://localhost:8233');
console.log('');
}
/**
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log while Temporal drives
* completion, then exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed.
* That tracks whether the pipeline ran, not whether vulnerabilities were found. On failure the
* root-cause message is printed so a red CI build says why.
*/
async function followScan(workspace: string, workspacesDir: string, validationOnly = false): Promise<never> {
const logFile = resolveRunFile(path.join(workspacesDir, workspace), 'workflow.log');
const workflowId = resolveWorkflowId(workspace);
// The worker creates workflow.log as it starts; wait briefly so the first read doesn't
// mistake a not-yet-created file for an already-finished scan.
for (let attempts = 0; attempts < 30 && !fs.existsSync(logFile); attempts++) {
await sleep(1000);
}
if (stdoutIsTerminal()) {
const what = validationOnly ? 'validation' : 'scan';
console.error(`\n Following ${what} log (Ctrl-C to stop watching):\n`);
}
let temporalUnreachable = false;
const { sawFailure } = await tailUntilComplete(logFile, {
...(workflowId && { workflowId }),
onUnreachable: () => {
temporalUnreachable = true;
},
});
// The tail already printed the diagnostic; reading the outcome would only fail the same way.
if (temporalUnreachable) {
process.exit(1);
}
if (!workflowId) {
fail('Scan finished but its workflow id could not be resolved from session.json.');
}
try {
const outcome = await getTerminalOutcome(workflowId);
if (outcome.kind === 'failed') {
// Print the reason only when the streamed log didn't already show the worker's failure
// summary — otherwise the worker crashed before writing it, and this is the only report.
if (!sawFailure) {
console.error(`\nScan failed:\n${indentFailureSegments(outcome.message)}`);
}
process.exit(1);
}
process.exit(0);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
fail('Could not read the scan outcome from Temporal at 127.0.0.1:7233.', ` ${detail}`);
}
}
function printPreservedContainerHint(containerName: string): void {
console.log('');
console.log(` Worker container preserved: ${containerName}`);
console.log(` Inspect logs: docker logs ${containerName}`);
console.log(` Remove: docker rm ${containerName}`);
console.log('');
}
function printInfo(args: StartArgs, workspace: string, repoPath: string, workspacesDir: string): void {
const interactive = stdoutIsTerminal();
if (interactive && !args.follow) {
console.log(' It runs in the background — you can close this terminal.');
console.log('');
}
console.log(` Target: ${args.url}`);
console.log(` Repository: ${interactive ? repoPath : path.basename(repoPath)}`);
console.log(` Workspace: ${workspace}`);
if (args.config) {
console.log(` Config: ${interactive ? path.resolve(args.config) : path.basename(args.config)}`);
}
if (args.modelsConfig) {
const shown = interactive ? path.resolve(args.modelsConfig) : path.basename(args.modelsConfig);
console.log(` Models: ${shown}`);
}
if (args.pipelineTesting) {
console.log(' Mode: Pipeline Testing');
}
const spec = resolveModelSpec();
if (typeof spec !== 'string') {
console.log(` Model: ${spec.providerId}:${spec.modelId}`);
}
if (!interactive) {
return;
}
const reportDir = path.join(workspacesDir, workspace);
// When following, the scan log streams inline next, so the "run these to watch it" hints
// would only contradict that.
if (!args.follow) {
const prefix = commandPrefix();
console.log('');
console.log(' Watch scan progress:');
console.log(` Live logs: ${prefix} logs ${workspace}`);
console.log(` Progress: ${prefix} status ${workspace}`);
}
if (!args.authOnly && !args.validateModel) {
console.log('');
console.log(' Report (when the scan finishes):');
console.log(` ${reportDir}${path.sep}`);
console.log(` ${FINAL_REPORT_PDF_FILENAME}`);
console.log(` ${FINAL_REPORT_MD_FILENAME}`);
console.log('');
}
}