mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-13 13:39:04 +02:00
feat(cli)!: default the scan target and add a JSON error contract
List local scans, resolve the active or most recent workspace automatically, and make logs, status, and stop use one canonical scan identity. Add stable machine-readable failures, richer status output, explicit help errors, and seven-day Temporal retention. Treat absent Temporal pending-activity failures as absent whether the decoder represents them as `null` or missing. BREAKING CHANGE: `status --json` now returns a fixed `failureMessage`. Read `partialReasons`, `agenticSast`, and `workflow.log` for diagnostic detail.
This commit is contained in:
@@ -1,23 +1,28 @@
|
||||
/**
|
||||
* `shannon scans` command — list completed scans and where each report lives.
|
||||
* `shannon scans` command — list scans, running and completed, and where each report lives.
|
||||
*
|
||||
* A scan counts as completed when it produced a report. The report can live in any of a
|
||||
* few locations depending on the version that ran it, so `findReport` probes them in order
|
||||
* and the first hit is both the completion signal and the link target behind the workspace
|
||||
* name. The date and wall-clock duration come from the run's session.json
|
||||
* (createdAt/completedAt), with the report file's mtime as the date fallback for
|
||||
* runs that lack a recorded time.
|
||||
* Running scans come from Docker: every worker container is stamped with the shannon.workspace
|
||||
* label, so `runningScanWorkspaces()` is the authoritative live-scan list (shared with `stop`).
|
||||
* A scan counts as completed once it has produced a report; the report can live in any of a few
|
||||
* locations depending on the version that ran it, so `findReport` probes them in order and the
|
||||
* first hit is both the completion signal and the link target behind the workspace name. Dates and
|
||||
* durations come from each run's session.json (createdAt/completedAt), with the report file's mtime
|
||||
* as the date fallback for runs that lack a recorded time; a running scan's duration is elapsed time
|
||||
* so far (now − createdAt).
|
||||
*
|
||||
* Human-readable by default; `--json` emits the same rows as raw machine values on stdout.
|
||||
* Running scans are listed first, then completed newest-first. Human-readable by default; `--json`
|
||||
* emits the same rows as raw machine values on stdout.
|
||||
*
|
||||
* Filesystem-only (local ./workspaces/ or npx ~/.shannon/workspaces/ via getWorkspacesDir);
|
||||
* no Temporal dependency.
|
||||
* The completed list is filesystem-only (local ./workspaces/ or npx ~/.shannon/workspaces/ via
|
||||
* getWorkspacesDir); the running list needs Docker but degrades to empty when the daemon is down,
|
||||
* which is the correct answer (no scan can be running then).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { BOLD, GOLD, paint } from '../colors.js';
|
||||
import { BOLD, CYAN, GOLD, paint } from '../colors.js';
|
||||
import { runningScanWorkspaces } from '../docker.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import { FINAL_REPORT_PDF_FILENAME, INTERNAL_DIR, resolveRunFile } from '../paths.js';
|
||||
@@ -31,23 +36,25 @@ const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
const DELIVERABLES_SUBDIR = 'deliverables';
|
||||
|
||||
/** One completed scan; raw values so the table and --json render from one source. */
|
||||
/** One scan, running or completed; raw values so the table and --json render from one source. */
|
||||
interface ScanRow {
|
||||
readonly workspace: string;
|
||||
/** Completion time in ms — sort key and date source. */
|
||||
readonly finishedMs: number;
|
||||
/** Wall-clock duration (completedAt − createdAt) in ms, or null when unknown. */
|
||||
readonly state: 'running' | 'completed';
|
||||
/** Completion time in ms — sort key and date source. Null while a scan is still running. */
|
||||
readonly finishedMs: number | null;
|
||||
/** Wall-clock duration in ms: elapsed-so-far for running, total for completed. Null when unknown. */
|
||||
readonly durationMs: number | null;
|
||||
/** Absolute path to the report file — the link target behind the workspace name. */
|
||||
readonly report: string;
|
||||
/** Absolute path to the report file — the link target behind the workspace name. Null while running. */
|
||||
readonly report: string | null;
|
||||
}
|
||||
|
||||
/** The --json row shape: raw machine values, one per completed scan. */
|
||||
/** The --json row shape: raw machine values, one per scan. */
|
||||
interface JsonRow {
|
||||
readonly workspace: string;
|
||||
readonly finishedAt: string;
|
||||
readonly state: 'running' | 'completed';
|
||||
readonly finishedAt: string | null;
|
||||
readonly durationMs: number | null;
|
||||
readonly reportPath: string;
|
||||
readonly reportPath: string | null;
|
||||
}
|
||||
|
||||
/** Compact wall-clock duration from milliseconds: "47s", "1m 32s", "1h 47m". */
|
||||
@@ -130,7 +137,19 @@ function collectCompletedScans(workspacesDir: string): ScanRow[] {
|
||||
const finishedMs = Number.isNaN(completedMs) ? fs.statSync(reportPath).mtimeMs : completedMs;
|
||||
const durationMs = Number.isNaN(completedMs) || Number.isNaN(createdMs) ? null : completedMs - createdMs;
|
||||
|
||||
rows.push({ workspace: entry.name, finishedMs, durationMs, report: reportPath });
|
||||
rows.push({ workspace: entry.name, state: 'completed', finishedMs, durationMs, report: reportPath });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Gather every currently-running scan, one row each. Elapsed time is now − createdAt. */
|
||||
function collectRunningScans(workspacesDir: string, nowMs: number): ScanRow[] {
|
||||
const rows: ScanRow[] = [];
|
||||
for (const workspace of runningScanWorkspaces()) {
|
||||
const { session } = readSession(path.join(workspacesDir, workspace));
|
||||
const createdMs = Date.parse(session.createdAt ?? '');
|
||||
const durationMs = Number.isNaN(createdMs) ? null : nowMs - createdMs;
|
||||
rows.push({ workspace, state: 'running', finishedMs: null, durationMs, report: null });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -138,55 +157,68 @@ function collectCompletedScans(workspacesDir: string): ScanRow[] {
|
||||
function toJsonRow(row: ScanRow): JsonRow {
|
||||
return {
|
||||
workspace: row.workspace,
|
||||
finishedAt: new Date(row.finishedMs).toISOString(),
|
||||
state: row.state,
|
||||
finishedAt: row.finishedMs === null ? null : new Date(row.finishedMs).toISOString(),
|
||||
durationMs: row.durationMs,
|
||||
reportPath: row.report,
|
||||
};
|
||||
}
|
||||
|
||||
/** Print the completed scans as an aligned table with the workspace name linked to its report. */
|
||||
/** Print the scans as an aligned table with each completed workspace name linked to its report. */
|
||||
function printTable(workspacesDir: string, rows: readonly ScanRow[]): void {
|
||||
if (rows.length === 0) {
|
||||
const prefix = commandPrefix();
|
||||
console.log(`No completed scans yet. Run '${prefix} start -u <url> -r <path>' to begin.`);
|
||||
console.log(`No scans yet. Run '${prefix} start -u <url> -r <path>' to begin.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = supportsColor();
|
||||
// On a terminal the workspace name is an OSC 8 hyperlink that opens its report; when
|
||||
// piped there is nothing to click, so it prints as plain text.
|
||||
// On a terminal a completed workspace name is an OSC 8 hyperlink that opens its report; when
|
||||
// piped, or for a running scan that has no report yet, it prints as plain text.
|
||||
const linkable = stdoutIsTerminal();
|
||||
|
||||
const table = rows.map((row) => ({
|
||||
finished: new Date(row.finishedMs).toISOString().slice(0, 10),
|
||||
state: row.state === 'running' ? 'RUNNING' : 'COMPLETED',
|
||||
finished: row.finishedMs === null ? '—' : new Date(row.finishedMs).toISOString().slice(0, 10),
|
||||
duration: row.durationMs === null ? '—' : formatDuration(row.durationMs),
|
||||
workspace: row.workspace,
|
||||
report: row.report,
|
||||
}));
|
||||
|
||||
const stateWidth = Math.max('STATE'.length, ...table.map((row) => row.state.length));
|
||||
const dateWidth = Math.max('FINISHED'.length, 'YYYY-MM-DD'.length);
|
||||
const durationWidth = Math.max('DURATION'.length, ...table.map((row) => row.duration.length));
|
||||
|
||||
console.log(`\nCompleted scans in ${workspacesDir}:\n`);
|
||||
const header = `${'FINISHED'.padEnd(dateWidth)} ${'DURATION'.padEnd(durationWidth)} WORKSPACE`;
|
||||
console.log(`\nScans in ${workspacesDir}:\n`);
|
||||
const header = `${'STATE'.padEnd(stateWidth)} ${'FINISHED'.padEnd(dateWidth)} ${'DURATION'.padEnd(durationWidth)} WORKSPACE`;
|
||||
console.log(paint(header, BOLD, color));
|
||||
|
||||
for (const row of table) {
|
||||
const stateText = row.state.padEnd(stateWidth);
|
||||
const state = row.state === 'RUNNING' ? paint(stateText, CYAN, color) : stateText;
|
||||
const finished = row.finished.padEnd(dateWidth);
|
||||
const duration = row.duration.padEnd(durationWidth);
|
||||
const name = paint(row.workspace, GOLD, color);
|
||||
const workspace = linkable ? hyperlink(name, pathToFileURL(row.report).href) : name;
|
||||
console.log(`${finished} ${duration} ${workspace}`);
|
||||
// A running scan has no report to open, so its name stays plain; completed names are linked.
|
||||
const name = row.report ? paint(row.workspace, GOLD, color) : row.workspace;
|
||||
const workspace = row.report && linkable ? hyperlink(name, pathToFileURL(row.report).href) : name;
|
||||
console.log(`${state} ${finished} ${duration} ${workspace}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export function scans(opts: { readonly json: boolean }): void {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const rows = collectCompletedScans(workspacesDir);
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Latest on top.
|
||||
rows.sort((a, b) => b.finishedMs - a.finishedMs);
|
||||
const running = collectRunningScans(workspacesDir, nowMs);
|
||||
const runningNames = new Set(running.map((row) => row.workspace));
|
||||
// A running scan has no final report, so it can't also be completed; guard anyway.
|
||||
const completed = collectCompletedScans(workspacesDir).filter((row) => !runningNames.has(row.workspace));
|
||||
|
||||
// Running scans on top (most recently started first), then completed newest-first.
|
||||
running.sort((a, b) => (a.durationMs ?? 0) - (b.durationMs ?? 0));
|
||||
completed.sort((a, b) => (b.finishedMs ?? 0) - (a.finishedMs ?? 0));
|
||||
const rows = [...running, ...completed];
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(rows.map(toJsonRow), null, 2));
|
||||
|
||||
@@ -74,8 +74,8 @@ function arraysEqual(left: readonly unknown[], right: readonly unknown[]): boole
|
||||
/**
|
||||
* 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 'other' and
|
||||
* 'other-exploit' only after the other pipeline admits findings. If the worker's 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.
|
||||
*/
|
||||
@@ -85,14 +85,14 @@ function isCurrentDurableState(value: unknown): boolean {
|
||||
|
||||
const participating = value.participating_classes;
|
||||
const validParticipation =
|
||||
arraysEqual(participating, FIXED_CLASSES) || arraysEqual(participating, [...FIXED_CLASSES, 'other']);
|
||||
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, 'other-exploit']);
|
||||
return arraysEqual(expected, baselineAgents) || arraysEqual(expected, [...baselineAgents, 'miscellaneous-exploit']);
|
||||
}
|
||||
|
||||
/** One refusal for damaged CLI-owned or worker-owned workspace records, whichever reads first. */
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
*
|
||||
* While the scan runs, polls Temporal and redraws the phase/agent tree on a
|
||||
* terminal (a pipe or a finished scan gets a single frame). When the scan reaches
|
||||
* a terminal state, prints the overall result and exits. Reads Temporal directly —
|
||||
* no worker, no session files — so it needs Temporal up and shows scans within its
|
||||
* ~24h retention window.
|
||||
* a terminal state, prints the overall result and exits. Local session records prove
|
||||
* the target's canonical workspace/workflow identity; the progress itself is read from
|
||||
* Temporal directly — no worker — so it needs Temporal up and shows scans within its
|
||||
* retention window (Shannon configures seven days by default; see SHANNON_TEMPORAL_RETENTION).
|
||||
*/
|
||||
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fail } from '../errors.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { failWith } from '../errors.js';
|
||||
import { commandPrefix, isLocal } from '../mode.js';
|
||||
import { type RenderInput, renderScan } from '../scan/render.js';
|
||||
import { toStatusJson } from '../scan/status-json.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { displaySplash } from '../splash.js';
|
||||
import {
|
||||
ActivityMirrorError,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../temporal-client.js';
|
||||
import { stdoutIsTerminal, supportsColor } from '../tty.js';
|
||||
import { getVersion } from '../version.js';
|
||||
import { resolveScanIdentity } from '../workspaces.js';
|
||||
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
@@ -45,8 +46,9 @@ async function readScanDescription(workflowId: string): Promise<ScanDescription
|
||||
try {
|
||||
return await describeScan(workflowId);
|
||||
} catch (error) {
|
||||
if (error instanceof ActivityMirrorError) fail(error.message);
|
||||
fail(
|
||||
if (error instanceof ActivityMirrorError) failWith('CLI_SCAN_SCHEMA_UNSUPPORTED', error.message);
|
||||
failWith(
|
||||
'CLI_SCAN_STATUS_UNAVAILABLE',
|
||||
"Could not read this scan's progress.",
|
||||
'If Temporal is not running, start a scan to bring it up. If it is running, this build of the CLI',
|
||||
'does not recognise part of the scan and needs updating.',
|
||||
@@ -155,7 +157,7 @@ async function watch(workspace: string, workflowId: string): Promise<never> {
|
||||
const desc = await readScanDescription(workflowId);
|
||||
if (!desc) {
|
||||
clearInterval(ticker);
|
||||
fail(`Scan "${workspace}" is no longer in Temporal.`);
|
||||
failWith('CLI_SCAN_NOT_FOUND', `Scan "${workspace}" is no longer in Temporal.`);
|
||||
}
|
||||
|
||||
if (isTerminalStatus(desc.status)) {
|
||||
@@ -177,18 +179,40 @@ async function snapshot(workspace: string, workflowId: string, desc: ScanDescrip
|
||||
: buildRunningInput(workspace, workflowId, desc);
|
||||
}
|
||||
|
||||
export async function status(workspace: string, opts: { readonly json: boolean }): Promise<void> {
|
||||
// A resume spawns a new workflow id (recorded in session.json); resolve through there so status
|
||||
// follows the current resume, not the superseded original. Fresh scans: the name is the id.
|
||||
const workflowId = resolveWorkflowId(workspace) ?? workspace;
|
||||
export async function status(target: string, opts: { readonly json: boolean }): Promise<void> {
|
||||
// Target selection picked a string; identity resolution proves the canonical workspace and
|
||||
// workflow pair from session records before Temporal is queried. A workspace name follows its
|
||||
// latest resume; an exact recorded workflow id keeps addressing that execution. A raw id with
|
||||
// no local record is refused rather than echoed into the required workspace field.
|
||||
const identity = resolveScanIdentity(target);
|
||||
if (identity.kind === 'ambiguous') {
|
||||
failWith(
|
||||
'CLI_SCAN_IDENTITY_AMBIGUOUS',
|
||||
`Multiple workspaces claim workflow ID "${target}": ${identity.claims.join(', ')}.`,
|
||||
`Run '${commandPrefix()} scans' and pass the workspace directory name instead.`,
|
||||
);
|
||||
}
|
||||
if (identity.kind === 'not-found') {
|
||||
failWith(
|
||||
'CLI_SCAN_IDENTITY_NOT_FOUND',
|
||||
identity.reason === 'unreadable-record'
|
||||
? `Workspace "${target}" has no readable session record (${identity.sessionPath}).`
|
||||
: `No scan matches "${target}" in the local workspace records.`,
|
||||
`Run '${commandPrefix()} scans' to list scans.`,
|
||||
'Temporal dashboard: http://localhost:8233',
|
||||
);
|
||||
}
|
||||
const { workspace, workflowId } = identity;
|
||||
|
||||
const desc = await readScanDescription(workflowId);
|
||||
|
||||
if (!desc) {
|
||||
fail(
|
||||
failWith(
|
||||
'CLI_SCAN_NOT_FOUND',
|
||||
`No scan found for "${workspace}".`,
|
||||
'',
|
||||
'Scans are visible while running and for ~24h after they finish (Temporal retention).',
|
||||
"Scan histories are available while a scan runs and within Temporal's retention window after it finishes.",
|
||||
"Shannon configures 7 days of retention by default (override: SHANNON_TEMPORAL_RETENTION). Expired histories can't be restored.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { fail, failUsage, warn } from '../errors.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { resolveDefaultWorkspace } from '../workspaces.js';
|
||||
|
||||
export interface StopOptions {
|
||||
all: boolean;
|
||||
@@ -108,6 +109,24 @@ async function stopAllScans(yes: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer which scan `stop` acts on when neither a workspace nor --all was given: the single
|
||||
* running scan, announced on stderr so it is never a silent guess. Zero or several running
|
||||
* scans exit with guidance — there is no most-recent fallback, since stopping a finished
|
||||
* scan is a no-op.
|
||||
*/
|
||||
function resolveStopTarget(): string {
|
||||
const target = resolveDefaultWorkspace({ allowFinished: false });
|
||||
if (target.kind === 'ok') {
|
||||
console.error(`No workspace given; stopping running scan "${target.workspace}".`);
|
||||
return target.workspace;
|
||||
}
|
||||
if (target.kind === 'ambiguous') {
|
||||
failUsage('Multiple scans are running — specify which one, or use --all:', ` ${target.running.join(', ')}`);
|
||||
}
|
||||
fail('No running scans to stop.', 'Pass a workspace name to stop a specific scan.');
|
||||
}
|
||||
|
||||
export async function stop(opts: StopOptions): Promise<void> {
|
||||
ensureDocker();
|
||||
|
||||
@@ -115,12 +134,12 @@ export async function stop(opts: StopOptions): Promise<void> {
|
||||
if (opts.all && opts.workspace) {
|
||||
failUsage('Pass a workspace name or --all, not both.');
|
||||
}
|
||||
if (!opts.all && !opts.workspace) {
|
||||
failUsage('Specify which scan to stop: `stop <workspace>`, or `stop --all` to stop every scan.');
|
||||
}
|
||||
|
||||
if (opts.workspace) {
|
||||
await stopSingleScan(opts.workspace, opts.yes);
|
||||
// With no explicit target and no --all, default to the single running scan.
|
||||
const workspace = opts.all ? undefined : (opts.workspace ?? resolveStopTarget());
|
||||
|
||||
if (workspace) {
|
||||
await stopSingleScan(workspace, opts.yes);
|
||||
} else {
|
||||
await stopAllScans(opts.yes);
|
||||
}
|
||||
|
||||
+111
-6
@@ -14,7 +14,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { SpinnerResult } from '@clack/prompts';
|
||||
import { envBool, PI_AUTH_CONTAINER_PATH } from './env.js';
|
||||
import { fail } from './errors.js';
|
||||
import { fail, warn } from './errors.js';
|
||||
import { getMode, isDevMode } from './mode.js';
|
||||
import { INTERNAL_DIR } from './paths.js';
|
||||
import { runStep, spawnCaptured, surfaceOutput } from './ui.js';
|
||||
@@ -116,10 +116,8 @@ export function isTemporalReady(): boolean {
|
||||
return output.includes('SERVING');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Temporal is running via compose.
|
||||
*/
|
||||
export async function ensureInfra(spinner: SpinnerResult): Promise<void> {
|
||||
/** Start (or find) Temporal via compose and wait until it serves; exits the process on failure. */
|
||||
async function ensureTemporalHealthy(spinner: SpinnerResult): Promise<void> {
|
||||
if (isTemporalReady()) {
|
||||
return;
|
||||
}
|
||||
@@ -146,6 +144,97 @@ export async function ensureInfra(spinner: SpinnerResult): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const DEFAULT_RETENTION_HOURS = 168;
|
||||
const RETENTION_ENV = 'SHANNON_TEMPORAL_RETENTION';
|
||||
const RETENTION_NAMESPACE = 'default';
|
||||
|
||||
/**
|
||||
* Desired retention in whole hours: unset or empty env → 168 (7 days); a positive
|
||||
* whole-hour override like `72h`; anything else warns and returns null (leave unchanged).
|
||||
*/
|
||||
function desiredRetentionHours(): number | null {
|
||||
const raw = process.env[RETENTION_ENV];
|
||||
if (raw === undefined || raw.trim() === '') {
|
||||
return DEFAULT_RETENTION_HOURS;
|
||||
}
|
||||
const match = raw.trim().match(/^([1-9][0-9]*)h$/);
|
||||
if (!match) {
|
||||
warn(
|
||||
`Ignoring invalid ${RETENTION_ENV} "${raw}" — Temporal retention left unchanged.`,
|
||||
'Use a positive whole number of hours, e.g. "168h".',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
/** Convert a Go duration such as "24h0m0s" or "168h" to whole seconds, or null when it doesn't parse. */
|
||||
function parseGoDurationSeconds(text: string): number | null {
|
||||
const match = text.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||
if (!match || (match[1] === undefined && match[2] === undefined && match[3] === undefined)) {
|
||||
return null;
|
||||
}
|
||||
const hours = Number(match[1] ?? 0);
|
||||
const minutes = Number(match[2] ?? 0);
|
||||
const seconds = Number(match[3] ?? 0);
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current retention of the `default` namespace in seconds, or null when it can't be read.
|
||||
* `runOutput` returns '' on a failed describe, so a failed read and an unparseable one both
|
||||
* collapse to null — either way the live value is unknown, which the caller handles the same way.
|
||||
*/
|
||||
function readCurrentRetentionSeconds(): number | null {
|
||||
const output = runOutput('docker', temporalCmd('operator', 'namespace', 'describe', RETENTION_NAMESPACE));
|
||||
const match = output.match(/WorkflowExecutionRetentionTtl\s+(\S+)/);
|
||||
if (!match || match[1] === undefined) {
|
||||
return null;
|
||||
}
|
||||
return parseGoDurationSeconds(match[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converge the `default` namespace's retention to the CLI-owned value after Temporal is
|
||||
* healthy. The CLI is the authority: a manual change is replaced on the next start unless
|
||||
* the operator sets the matching override. A describe or update failure warns once that the
|
||||
* requested value wasn't applied and never blocks the scan.
|
||||
*/
|
||||
function convergeNamespaceRetention(): void {
|
||||
const hours = desiredRetentionHours();
|
||||
if (hours === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSeconds = readCurrentRetentionSeconds();
|
||||
if (currentSeconds === null) {
|
||||
warn(
|
||||
`Could not read Temporal retention for namespace "${RETENTION_NAMESPACE}" — the requested value (${hours}h) was not applied.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSeconds === hours * 3600) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = runQuiet(
|
||||
'docker',
|
||||
temporalCmd('operator', 'namespace', 'update', '--namespace', RETENTION_NAMESPACE, '--retention', `${hours}h`),
|
||||
);
|
||||
if (!updated) {
|
||||
warn(`Could not update Temporal retention to ${hours}h — the requested value was not applied.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Temporal is running via compose, then converge its scan-history retention.
|
||||
*/
|
||||
export async function ensureInfra(spinner: SpinnerResult): Promise<void> {
|
||||
await ensureTemporalHealthy(spinner);
|
||||
convergeNamespaceRetention();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the worker image from the repository, tagged with the name this mode
|
||||
* resolves at run time.
|
||||
@@ -358,7 +447,9 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
// Environment
|
||||
args.push(...opts.envFlags);
|
||||
|
||||
// Container settings
|
||||
// Container settings. Chromium's own sandbox needs syscalls Docker's default seccomp
|
||||
// profile blocks, so it is loosened for the in-container browser automation; the
|
||||
// worker process itself is not granted any extra privilege by this.
|
||||
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');
|
||||
|
||||
// Image
|
||||
@@ -405,6 +496,20 @@ export function runningContainers(filter: readonly string[]): string[] {
|
||||
return output.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace names of every running worker container, read from the shannon.workspace
|
||||
* label each scan is stamped with at spawn. This is the authoritative running-scan →
|
||||
* workspace-name map. Best-effort: empty when Docker is unreachable, which is the
|
||||
* correct answer anyway (no scan can be running without the daemon).
|
||||
*/
|
||||
export function runningScanWorkspaces(): string[] {
|
||||
const output = runOutput('docker', ['ps', ...WORKER_FILTER, '--format', `{{ index .Labels "${WORKSPACE_LABEL}" }}`]);
|
||||
return output
|
||||
.split('\n')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop containers by ID, tolerating any that vanished between being listed and
|
||||
* stopped (a `--rm` worker exiting is success, not an error). Async so a spinner
|
||||
|
||||
+76
-37
@@ -1,37 +1,94 @@
|
||||
/**
|
||||
* Centralized error reporting.
|
||||
*
|
||||
* `fail` — an expected, user-fixable error (bad input, missing prerequisite):
|
||||
* a clean message on stderr and a non-zero exit, never a stack trace.
|
||||
* `fail` / `failWith` — an expected, user-fixable error (bad input, missing
|
||||
* prerequisite): a clean message on stderr and a non-zero exit, never a stack trace.
|
||||
* `failUsage` — a malformed invocation (unknown command, bad or missing
|
||||
* arguments): the same clean message, but a distinct exit code so callers can
|
||||
* tell a usage mistake from an operational failure.
|
||||
* `crash` — an unexpected error (a bug): a brief message, the full stack written
|
||||
* to a log file for a bug report, and a pointer to the issue tracker.
|
||||
* `crash` — an unexpected error (a bug): a fixed code and a pointer to the issue tracker.
|
||||
*
|
||||
* JSON mode (enabled once, before parsing, for the `--json` command surface) replaces
|
||||
* the text lines with one compact envelope on stderr — stdout stays empty — while the
|
||||
* exit-code split is unchanged. Call sites on a JSON-capable path must exit through
|
||||
* `failWith`/`failUsage`/`crash` (never a bare `fail` or `warn`) so every failure
|
||||
* carries a stable code and stderr stays parseable.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const ISSUES_URL = 'https://github.com/KeygraphHQ/shannon/issues';
|
||||
|
||||
/** Report an expected, user-fixable error (with optional extra lines) and exit non-zero. */
|
||||
export function fail(message: string, ...hints: string[]): never {
|
||||
const UNEXPECTED_MESSAGE = 'Shannon encountered an unexpected failure. Reference code: SHANNON_UNEXPECTED_ERROR';
|
||||
const REPORT_HINT = `If this looks like a bug, please report it: ${ISSUES_URL}`;
|
||||
|
||||
/** Stable machine-readable failure codes for the JSON error envelope. */
|
||||
export type ErrorCode =
|
||||
| 'CLI_USAGE'
|
||||
| 'CLI_SCAN_NOT_FOUND'
|
||||
| 'CLI_SCAN_IDENTITY_NOT_FOUND'
|
||||
| 'CLI_SCAN_IDENTITY_AMBIGUOUS'
|
||||
| 'CLI_SCAN_STATUS_UNAVAILABLE'
|
||||
| 'CLI_SCAN_SCHEMA_UNSUPPORTED'
|
||||
| 'CLI_PRECONDITION_FAILED'
|
||||
| 'CLI_INTERNAL_ERROR';
|
||||
|
||||
let jsonMode = false;
|
||||
|
||||
/** Switch failure reporting to the JSON envelope. Set once, before any guard, parse, or dispatch. */
|
||||
export function enableJsonErrors(): void {
|
||||
jsonMode = true;
|
||||
}
|
||||
|
||||
/** Whether failures are reported as the JSON envelope rather than text. */
|
||||
export function jsonErrorsEnabled(): boolean {
|
||||
return jsonMode;
|
||||
}
|
||||
|
||||
/** Fixed unexpected-failure projection shared by the runtime and focused safety tests. */
|
||||
export function unexpectedFailureLines(): readonly string[] {
|
||||
return [`ERROR: ${UNEXPECTED_MESSAGE}`, REPORT_HINT];
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failure on stderr and exit. Text mode prints the message and every hint
|
||||
* verbatim; JSON mode writes one compact envelope (dropping the empty strings used
|
||||
* to space text output) synchronously so `process.exit` cannot truncate it.
|
||||
*/
|
||||
function emit(exitCode: 1 | 2, code: ErrorCode, message: string, hints: readonly string[]): never {
|
||||
if (jsonMode) {
|
||||
const payload = JSON.stringify({ error: { code, message, hints: hints.filter((hint) => hint.trim() !== '') } });
|
||||
fs.writeSync(process.stderr.fd, `${payload}\n`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
console.error(`ERROR: ${message}`);
|
||||
for (const hint of hints) {
|
||||
console.error(hint);
|
||||
}
|
||||
process.exit(1);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an expected, user-fixable error (with optional extra lines) and exit non-zero.
|
||||
* Text-only paths use this; a JSON-capable path must use `failWith` so the envelope
|
||||
* carries a real code — if a bare `fail` is ever reached in JSON mode, the fixed
|
||||
* internal-error envelope is emitted instead of guessing a code for the message.
|
||||
*/
|
||||
export function fail(message: string, ...hints: string[]): never {
|
||||
if (jsonMode) {
|
||||
emit(1, 'CLI_INTERNAL_ERROR', UNEXPECTED_MESSAGE, [REPORT_HINT]);
|
||||
}
|
||||
emit(1, 'CLI_INTERNAL_ERROR', message, hints);
|
||||
}
|
||||
|
||||
/** Report an expected operational failure under a stable code and exit 1. */
|
||||
export function failWith(code: ErrorCode, message: string, ...hints: string[]): never {
|
||||
emit(1, code, message, hints);
|
||||
}
|
||||
|
||||
/** Report a usage/argument error (with optional extra lines) and exit 2. */
|
||||
export function failUsage(message: string, ...hints: string[]): never {
|
||||
console.error(`ERROR: ${message}`);
|
||||
for (const hint of hints) {
|
||||
console.error(hint);
|
||||
}
|
||||
process.exit(2);
|
||||
emit(2, 'CLI_USAGE', message, hints);
|
||||
}
|
||||
|
||||
/** Report a non-fatal warning on stderr (with optional extra lines) without exiting. */
|
||||
@@ -42,29 +99,11 @@ export function warn(message: string, ...hints: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Report an unexpected error: brief message, full stack to a log file, plus the issue link. */
|
||||
export function crash(error: unknown): never {
|
||||
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (process.env.DEBUG) {
|
||||
console.error(error instanceof Error ? error.stack : String(error));
|
||||
/** Report an unexpected error without projecting its message, stack, or attached values. */
|
||||
export function crash(_error: unknown): never {
|
||||
if (jsonMode) {
|
||||
emit(1, 'CLI_INTERNAL_ERROR', UNEXPECTED_MESSAGE, [REPORT_HINT]);
|
||||
}
|
||||
|
||||
const logPath = writeCrashLog(error);
|
||||
if (logPath) {
|
||||
console.error(`Details written to ${logPath}`);
|
||||
}
|
||||
console.error(`If this looks like a bug, please report it: ${ISSUES_URL}`);
|
||||
for (const line of unexpectedFailureLines()) console.error(line);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Write the full error and stack to a log file; return its path, or null if it can't be written. */
|
||||
function writeCrashLog(error: unknown): string | null {
|
||||
try {
|
||||
const logPath = path.join(os.tmpdir(), 'shannon-error.log');
|
||||
const detail = error instanceof Error && error.stack ? error.stack : String(error);
|
||||
fs.writeFileSync(logPath, `${new Date().toISOString()}\n${detail}\n`);
|
||||
return logPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-10
@@ -47,30 +47,32 @@ const COMMAND_HELP: Readonly<Record<string, CommandHelp>> = {
|
||||
],
|
||||
},
|
||||
stop: {
|
||||
usage: ['stop <workspace> [--yes]', 'stop --all [--yes]'],
|
||||
description: 'Stop one scan by workspace, or every scan with --all (Temporal stays up).',
|
||||
usage: ['stop [<workspace>] [--yes]', 'stop --all [--yes]'],
|
||||
description:
|
||||
'Stop one scan by workspace, or every scan with --all (Temporal stays up). With no workspace, stops the single running scan; when several are running, name one or use --all.',
|
||||
options: [['--all', 'Stop all running scans'], YES_OPTION],
|
||||
examples: ['stop q1-audit', 'stop --all'],
|
||||
examples: ['stop', 'stop q1-audit', 'stop --all'],
|
||||
},
|
||||
reset: {
|
||||
usage: ['reset'],
|
||||
description: 'Stop everything and permanently remove all Temporal data and volumes.',
|
||||
},
|
||||
logs: {
|
||||
usage: ['logs <workspace>'],
|
||||
description: "Tail a scan's live log until it completes.",
|
||||
examples: ['logs q1-audit'],
|
||||
usage: ['logs [<workspace>]'],
|
||||
description:
|
||||
"Tail a scan's live log until it completes. With no workspace, follows the single running scan, or the most recent workspace when none is running; when several are running, name one.",
|
||||
examples: ['logs', 'logs q1-audit'],
|
||||
},
|
||||
status: {
|
||||
usage: ['status <workspace> [--json]'],
|
||||
usage: ['status [<workspace>] [--json]'],
|
||||
description:
|
||||
"Show one scan's phase-by-phase progress, read live from Temporal. Watches and redraws until the scan finishes on a terminal; prints one frame when piped or already finished. With --json, prints a single machine-readable snapshot and exits.",
|
||||
"Show one scan's phase-by-phase progress, read live from Temporal. With no workspace, shows the single running scan, or the most recent workspace when none is running; when several are running, name one. Watches and redraws until the scan finishes on a terminal; prints one frame when piped or already finished. With --json, prints a single machine-readable snapshot and exits.",
|
||||
options: [['--json', 'Output a point-in-time snapshot as JSON, then exit']],
|
||||
examples: ['status q1-audit', 'status q1-audit --json'],
|
||||
examples: ['status', 'status q1-audit', 'status q1-audit --json'],
|
||||
},
|
||||
scans: {
|
||||
usage: ['scans [--json]'],
|
||||
description: 'List completed scans and where each report lives.',
|
||||
description: 'List running and completed scans, and where each finished report lives.',
|
||||
options: [['--json', 'Output the scan list as JSON']],
|
||||
examples: ['scans', 'scans --json'],
|
||||
},
|
||||
@@ -102,6 +104,15 @@ export function isHelpableCommand(command: string): boolean {
|
||||
return command in COMMAND_HELP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every explicit help topic, mode-blind, with `help` itself as the known global topic.
|
||||
* Topic lookup is deliberately not mode-filtered (unlike `availableCommands`) so
|
||||
* cross-mode help such as local `help setup` and npx `help build` keeps working.
|
||||
*/
|
||||
export function helpTopics(): readonly string[] {
|
||||
return [...Object.keys(COMMAND_HELP), 'help'];
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing command names available in the current mode, for "did you mean?"
|
||||
* suggestions. Derived from the same table that backs per-command help, so the
|
||||
|
||||
+96
-17
@@ -18,14 +18,21 @@ import { setup } from './commands/setup.js';
|
||||
import { start } from './commands/start.js';
|
||||
import { status } from './commands/status.js';
|
||||
import { stop } from './commands/stop.js';
|
||||
import { crash, fail, failUsage } from './errors.js';
|
||||
import { availableCommands, isHelpableCommand, printCommandHelp, START_OPTIONS } from './help.js';
|
||||
import { crash, enableJsonErrors, fail, failUsage, failWith, jsonErrorsEnabled } from './errors.js';
|
||||
import { availableCommands, helpTopics, isHelpableCommand, printCommandHelp, START_OPTIONS } from './help.js';
|
||||
import { commandPrefix, getMode, isLocal, type Mode } from './mode.js';
|
||||
import { displaySplash } from './splash.js';
|
||||
import { closestMatch } from './suggest.js';
|
||||
import { stdoutIsTerminal } from './tty.js';
|
||||
import { getVersion, getVersionLine } from './version.js';
|
||||
import { resolveDefaultWorkspace } from './workspaces.js';
|
||||
|
||||
/**
|
||||
* Refuse to run as root or under sudo. The worker container's Linux UID remapping
|
||||
* (docker.ts) stamps bind-mounted files with the invoking user's real uid/gid; under
|
||||
* sudo that uid is 0, so the repo, workspace, and report files would come back
|
||||
* owned by root instead of the person who ran the scan.
|
||||
*/
|
||||
function blockSudo(): void {
|
||||
const isSudo = !!process.env.SUDO_USER;
|
||||
const isRoot = process.geteuid?.() === 0;
|
||||
@@ -37,15 +44,38 @@ function blockSudo(): void {
|
||||
: [];
|
||||
|
||||
if (isSudo) {
|
||||
fail('Shannon must not be run with sudo.', 'Re-run this command as your normal user.', ...linuxHints);
|
||||
failWith(
|
||||
'CLI_PRECONDITION_FAILED',
|
||||
'Shannon must not be run with sudo.',
|
||||
'Re-run this command as your normal user.',
|
||||
...linuxHints,
|
||||
);
|
||||
}
|
||||
fail(
|
||||
failWith(
|
||||
'CLI_PRECONDITION_FAILED',
|
||||
'Shannon must not be run as the root user.',
|
||||
'Switch to a regular user account and re-run this command.',
|
||||
...linuxHints,
|
||||
);
|
||||
}
|
||||
|
||||
/** Commands whose `--json` output contract extends to failures. */
|
||||
const JSON_CAPABLE_COMMANDS = new Set(['status', 'scans', 'version', '--version', '-v']);
|
||||
|
||||
/**
|
||||
* Raw-argv sniff for the JSON error latch, decided before any guard or parse so even
|
||||
* a pre-dispatch failure honors it. Latches on `--json` or a malformed `--json=<value>`
|
||||
* (which still fails as a parse error — inside the envelope). Any other command that
|
||||
* receives `--json` keeps its normal unknown-option behavior.
|
||||
*/
|
||||
function wantsJsonErrors(argv: readonly string[]): boolean {
|
||||
const command = argv[0];
|
||||
if (command === undefined || !JSON_CAPABLE_COMMANDS.has(command)) {
|
||||
return false;
|
||||
}
|
||||
return argv.slice(1).some((arg) => arg === '--json' || arg.startsWith('--json='));
|
||||
}
|
||||
|
||||
/** Render `start`'s flags for the global help, from the same source as `start --help`. */
|
||||
function renderStartOptions(): string {
|
||||
const flagWidth = Math.max(...START_OPTIONS.map(([flag]) => flag.length));
|
||||
@@ -60,12 +90,15 @@ function renderUsage(prefix: string, mode: Mode): string {
|
||||
const rows: ReadonlyArray<readonly [string, string]> = [
|
||||
...(mode === 'local' ? [] : [[`${prefix} setup`, 'Configure credentials'] as const]),
|
||||
[`${prefix} start --url <url> --repo <path> [options]`, 'Start a pentest scan'],
|
||||
[`${prefix} stop <workspace> [--yes]`, 'Stop one scan'],
|
||||
[`${prefix} stop [<workspace>] [--yes]`, 'Stop one scan (default: the single running scan)'],
|
||||
[`${prefix} stop --all [--yes]`, 'Stop all scans (Temporal stays up)'],
|
||||
[`${prefix} reset`, 'Stop everything and wipe all Temporal data'],
|
||||
[`${prefix} logs <workspace>`, "Show a scan's live log"],
|
||||
[`${prefix} status <workspace> [--json]`, 'Live phase/agent progress of one scan'],
|
||||
[`${prefix} scans [--json]`, 'List completed scans and their reports'],
|
||||
[`${prefix} logs [<workspace>]`, "Show a scan's live log (default: running or most recent)"],
|
||||
[
|
||||
`${prefix} status [<workspace>] [--json]`,
|
||||
'Live phase/agent progress of one scan (default: running or most recent)',
|
||||
],
|
||||
[`${prefix} scans [--json]`, 'List running and completed scans'],
|
||||
...(mode === 'local' ? [[`${prefix} build [--no-cache]`, 'Build worker image'] as const] : []),
|
||||
[`${prefix} version [--json]`, 'Show version'],
|
||||
[`${prefix} help`, 'Show this help'],
|
||||
@@ -152,6 +185,32 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workspace a viewing command (`logs`, `status`) acts on: the name the user
|
||||
* gave, or an inferred default. An inferred choice is announced on stderr so it is never a
|
||||
* silent guess; when nothing can be inferred, exit with usage guidance.
|
||||
*/
|
||||
function resolveViewingWorkspace(positional: string | undefined, usage: string): string {
|
||||
if (positional) {
|
||||
return positional;
|
||||
}
|
||||
|
||||
const target = resolveDefaultWorkspace({ allowFinished: true });
|
||||
if (target.kind === 'ok') {
|
||||
// In JSON mode stderr is reserved for the single error envelope, so a successful
|
||||
// inference stays silent — the JSON payload itself names the chosen workspace.
|
||||
if (!jsonErrorsEnabled()) {
|
||||
const which = target.running ? 'running scan' : 'most recent scan';
|
||||
console.error(`No workspace given; using ${which} "${target.workspace}".`);
|
||||
}
|
||||
return target.workspace;
|
||||
}
|
||||
if (target.kind === 'ambiguous') {
|
||||
failUsage('Multiple scans are running — specify which one:', ` ${target.running.join(', ')}`, '', usage);
|
||||
}
|
||||
failUsage('Workspace is required', usage);
|
||||
}
|
||||
|
||||
// === Main Dispatch ===
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -163,13 +222,17 @@ async function main(): Promise<void> {
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (wantsJsonErrors(process.argv.slice(2))) {
|
||||
enableJsonErrors();
|
||||
}
|
||||
|
||||
blockSudo();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
|
||||
if (command === undefined || command === '--help' || command === '-h') {
|
||||
const topic = rest[0];
|
||||
if (topic && isHelpableCommand(topic)) {
|
||||
printCommandHelp(topic);
|
||||
@@ -181,6 +244,27 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// An explicit `help <topic>` names a topic on purpose, so an unknown one is a usage
|
||||
// error — unlike `--help <junk>`, where the junk is ignored and global help wins.
|
||||
if (command === 'help') {
|
||||
const topic = rest[0];
|
||||
// A flag (`help --help`) is a help request, not a topic name.
|
||||
if (topic === undefined || topic === 'help' || topic.startsWith('-')) {
|
||||
showHelp(false);
|
||||
return;
|
||||
}
|
||||
if (isHelpableCommand(topic)) {
|
||||
printCommandHelp(topic);
|
||||
return;
|
||||
}
|
||||
const suggestion = closestMatch(topic, helpTopics());
|
||||
failUsage(
|
||||
`Unknown help topic: ${topic}`,
|
||||
...(suggestion ? [`Did you mean '${suggestion}'?`] : []),
|
||||
`Run '${commandPrefix()} help' to see available commands.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reachable from any invocation: `-h`/`--help` anywhere wins over the rest of the line.
|
||||
if (isHelpableCommand(command) && (rest.includes('-h') || rest.includes('--help'))) {
|
||||
printCommandHelp(command);
|
||||
@@ -211,19 +295,14 @@ async function main(): Promise<void> {
|
||||
}
|
||||
case 'logs': {
|
||||
const { positionals } = parseArgs(rest, { maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace ID is required', `Usage: ${commandPrefix()} logs <workspace>`);
|
||||
}
|
||||
const workspaceId = resolveViewingWorkspace(positionals[0], `Usage: ${commandPrefix()} logs [<workspace>]`);
|
||||
logs(workspaceId);
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
const { flags, positionals } = parseArgs(rest, { booleans: { json: ['--json'] }, maxPositionals: 1 });
|
||||
const workspaceId = positionals[0];
|
||||
if (!workspaceId) {
|
||||
failUsage('Workspace is required', `Usage: ${commandPrefix()} status <workspace> [--json]`);
|
||||
}
|
||||
const usage = `Usage: ${commandPrefix()} status [<workspace>] [--json]`;
|
||||
const workspaceId = resolveViewingWorkspace(positionals[0], usage);
|
||||
await status(workspaceId, { json: !!flags.json });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ function agentState(name: string, state: PipelineState | null, running: Set<stri
|
||||
return resolved ? 'skipped' : 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only `failed`'s presence is used here, never its `.error` text: that string is the
|
||||
* worker's raw error for the failed class, not vetted for display, so it is reduced to
|
||||
* a boolean before reaching safeFailureDetail's fixed sentence.
|
||||
*/
|
||||
function agentError(name: string, state: PipelineState | null, byAgent: Map<string, RunningAgent>): string | undefined {
|
||||
const failed = state?.failedPipelines.find((f) => f.vulnType === agentClass(name));
|
||||
return (
|
||||
|
||||
@@ -93,16 +93,16 @@ export const PIPELINE: readonly PhaseSpec[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const OTHER_EXPLOIT_AGENT: AgentSpec = {
|
||||
name: 'other-exploit',
|
||||
label: 'other',
|
||||
activityType: 'runOtherExploitAgent',
|
||||
const MISCELLANEOUS_EXPLOIT_AGENT: AgentSpec = {
|
||||
name: 'miscellaneous-exploit',
|
||||
label: 'miscellaneous',
|
||||
activityType: 'runMiscellaneousExploitAgent',
|
||||
};
|
||||
|
||||
/**
|
||||
* Shape the static PIPELINE to one scan's durable truth. expectedAgents, persisted by the
|
||||
* worker at scan start, names every exploit agent the scan can ever run: exploit rows it
|
||||
* excludes are dropped, 'other-exploit' is appended only once the other pipeline has
|
||||
* excludes are dropped, 'miscellaneous-exploit' is appended only once the miscellaneous pipeline has
|
||||
* admitted findings, and a phase left with no agents disappears entirely. Without state
|
||||
* (the scan has not initialized durable state yet) the full static pipeline is the best
|
||||
* available guess.
|
||||
@@ -113,13 +113,13 @@ export function pipelineForState(state: PipelineState | null): readonly PhaseSpe
|
||||
return PIPELINE.map((phase) => {
|
||||
if (phase.key !== 'exploitation') return phase;
|
||||
const agents = phase.agents.filter((agent) => expected.has(agent.name));
|
||||
if (expected.has(OTHER_EXPLOIT_AGENT.name)) agents.push(OTHER_EXPLOIT_AGENT);
|
||||
if (expected.has(MISCELLANEOUS_EXPLOIT_AGENT.name)) agents.push(MISCELLANEOUS_EXPLOIT_AGENT);
|
||||
return { ...phase, agents };
|
||||
}).filter((phase) => phase.agents.length > 0);
|
||||
}
|
||||
|
||||
const AGENT_ACTIVITY_PROGRESS: Readonly<Record<string, ActivityProgressSpec>> = Object.fromEntries(
|
||||
[...PIPELINE.flatMap((phase) => phase.agents), OTHER_EXPLOIT_AGENT].map((agent) => [
|
||||
[...PIPELINE.flatMap((phase) => phase.agents), MISCELLANEOUS_EXPLOIT_AGENT].map((agent) => [
|
||||
agent.activityType,
|
||||
{ key: agent.name, label: agent.label, kind: 'agent' },
|
||||
]),
|
||||
@@ -141,7 +141,11 @@ const OPERATION_ACTIVITY_PROGRESS: Readonly<Record<string, ActivityProgressSpec>
|
||||
initDeliverableGit: { key: 'scan-initialization', label: 'Initialize deliverables', kind: 'operation' },
|
||||
syncCodePathDenyRules: { key: 'scan-initialization', label: 'Apply source rules', kind: 'operation' },
|
||||
initializeDurableScanState: { key: 'durable-state', label: 'Saving scan state', kind: 'operation' },
|
||||
persistOtherOutcome: { key: 'other-pipeline', label: 'Including other findings', kind: 'operation' },
|
||||
persistMiscellaneousOutcome: {
|
||||
key: 'miscellaneous-pipeline',
|
||||
label: 'Including miscellaneous findings',
|
||||
kind: 'operation',
|
||||
},
|
||||
initializeReportProgress: { key: 'report:initialize', label: 'Initialize report state', kind: 'operation' },
|
||||
renumberClassFindings: { key: 'report:renumber', label: 'Renumber findings', kind: 'operation' },
|
||||
assembleReportActivity: { key: 'report:assemble', label: 'Assemble report inputs', kind: 'operation' },
|
||||
@@ -158,7 +162,11 @@ const OPERATION_ACTIVITY_PROGRESS: Readonly<Record<string, ActivityProgressSpec>
|
||||
logPhaseTransition: { key: 'audit-log', label: 'Update audit log', kind: 'operation' },
|
||||
logWorkflowComplete: { key: 'audit-log', label: 'Finalize audit log', kind: 'operation' },
|
||||
saveCheckpoint: { key: 'checkpoint', label: 'Save checkpoint', kind: 'operation' },
|
||||
seedEmptyProducerQueue: { key: 'other-pipeline', label: 'Preparing other findings', kind: 'operation' },
|
||||
seedEmptyProducerQueue: {
|
||||
key: 'miscellaneous-pipeline',
|
||||
label: 'Preparing miscellaneous findings',
|
||||
kind: 'operation',
|
||||
},
|
||||
prepareClassReconciliation: {
|
||||
key: 'reconciliation',
|
||||
label: 'Preparing findings',
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Workspace → Temporal workflow-id resolution.
|
||||
*
|
||||
* A workspace name is not always its workflow id: a fresh scan's id equals the
|
||||
* workspace name, but each resume spawns a new workflow (`<workspace>_resume_<ts>`).
|
||||
* The workspace's session.json records the authoritative id — the latest resume
|
||||
* attempt, or the original — so commands that query Temporal (status, stop) resolve
|
||||
* through here instead of assuming the name is the id.
|
||||
* A workspace name is not always its workflow id: a fresh named workspace gets
|
||||
* `<workspace>_shannon-<timestamp>` as its workflow id (only an auto-named workspace's
|
||||
* directory name equals its original id), and each resume spawns a new workflow
|
||||
* (`<workspace>_resume_<ts>`). The workspace's session.json records the authoritative
|
||||
* id — the latest resume attempt, or the original — so commands that query Temporal
|
||||
* (status, stop) resolve through here instead of assuming the name is the id.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
@@ -91,7 +91,14 @@ export async function describeScan(workflowId: string): Promise<ScanDescription
|
||||
if (!progress) {
|
||||
throw new ActivityMirrorError(activityType || 'unknown activity');
|
||||
}
|
||||
const lastFailure = pending.lastFailure?.message;
|
||||
// Temporal's own failure message is never forwarded verbatim: it can carry raw
|
||||
// exception text from inside the activity, which this client has no way to vet
|
||||
// before painting it into a terminal. Only its presence is kept; the boolean feeds
|
||||
// a fixed sentence downstream (see safeFailureDetail), and the real detail stays
|
||||
// one `shannon logs` away.
|
||||
// NOTE: the proto decoder writes an absent lastFailure as null, not undefined, so a
|
||||
// loose check is what distinguishes a healthy attempt from a failed one.
|
||||
const lastFailure = pending.lastFailure == null ? undefined : 'This activity attempt failed.';
|
||||
const startedAt = timestampMs(pending.scheduledTime ?? pending.lastStartedTime ?? null);
|
||||
runningAgents.push({
|
||||
agent: progress.key,
|
||||
@@ -179,8 +186,9 @@ export async function waitForWorkflowClose(workflowId: string, opts: WatchOption
|
||||
|
||||
while (!signal?.aborted) {
|
||||
try {
|
||||
const desc = await describeScan(workflowId);
|
||||
if (desc === null || TERMINAL_STATUSES.has(desc.status)) {
|
||||
const client = await getClient();
|
||||
const desc = await client.workflow.getHandle(workflowId).describe();
|
||||
if (TERMINAL_STATUSES.has(desc.status.name)) {
|
||||
return { reason: 'closed' };
|
||||
}
|
||||
// Reachable and still RUNNING — reset the failure streak and note any recovery.
|
||||
@@ -190,6 +198,9 @@ export async function waitForWorkflowClose(workflowId: string, opts: WatchOption
|
||||
}
|
||||
connectFailures = 0;
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) {
|
||||
return { reason: 'closed' };
|
||||
}
|
||||
connectFailures++;
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
if (!warned && connectFailures >= warnAfterFailures) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Workspace enumeration, default-target resolution, and scan identity proof.
|
||||
*
|
||||
* The action commands (`logs`, `status`, `stop`) each take a workspace name. When one
|
||||
* is omitted, `resolveDefaultWorkspace` picks the obvious candidate — the single running
|
||||
* scan, or the most recent workspace — so the common "I just started one scan, show me
|
||||
* its logs" path doesn't require retyping an auto-generated name. Target selection and
|
||||
* identity proof are separate steps: `resolveScanIdentity` turns a selected or explicit
|
||||
* string into the one canonical (workspace, workflowId) pair the session records prove.
|
||||
*
|
||||
* Running scans are identified by Docker label (the authoritative source, shared with
|
||||
* `stop`); recency for finished scans comes from each run's session.json createdAt,
|
||||
* with the workspace directory mtime as the fallback for runs that predate it.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { runningScanWorkspaces } from './docker.js';
|
||||
import { getWorkspacesDir } from './home.js';
|
||||
import { resolveRunFile } from './paths.js';
|
||||
import { resolveWorkflowId } from './session.js';
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
readonly name: string;
|
||||
/** Creation time in ms — the recency sort key. Null when neither session.json nor stat is readable. */
|
||||
readonly createdMs: number | null;
|
||||
}
|
||||
|
||||
/** Creation time of a workspace: session.json createdAt, else directory mtime, else null. */
|
||||
function readCreatedMs(runDir: string): number | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(resolveRunFile(runDir, 'session.json'), 'utf-8'));
|
||||
const createdMs = Date.parse(parsed?.session?.createdAt ?? '');
|
||||
if (!Number.isNaN(createdMs)) {
|
||||
return createdMs;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the directory mtime.
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.statSync(runDir).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every workspace directory, newest-first by createdAt (directory mtime fallback). */
|
||||
export function listWorkspaces(): WorkspaceInfo[] {
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(workspacesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Workspaces directory does not exist yet — no scans have ever run.
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspaces: WorkspaceInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
workspaces.push({ name: entry.name, createdMs: readCreatedMs(path.join(workspacesDir, entry.name)) });
|
||||
}
|
||||
|
||||
// Newest first; workspaces with no known time sort last.
|
||||
workspaces.sort((a, b) => (b.createdMs ?? 0) - (a.createdMs ?? 0));
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
export type ScanIdentity =
|
||||
| { readonly kind: 'ok'; readonly workspace: string; readonly workflowId: string }
|
||||
| {
|
||||
readonly kind: 'not-found';
|
||||
readonly reason: 'no-match' | 'unreadable-record';
|
||||
/** For 'unreadable-record': the session.json path that could not prove the identity. */
|
||||
readonly sessionPath?: string;
|
||||
}
|
||||
| { readonly kind: 'ambiguous'; readonly claims: readonly string[] };
|
||||
|
||||
/** Every workflow id a run's session record has ever claimed: the original plus each resume. */
|
||||
function readRecordedWorkflowIds(runDir: string): readonly string[] {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(resolveRunFile(runDir, 'session.json'), 'utf-8'));
|
||||
const resumeAttempts: { workflowId?: string }[] = session.session?.resumeAttempts ?? [];
|
||||
const ids = [session.session?.originalWorkflowId, ...resumeAttempts.map((attempt) => attempt.workflowId)];
|
||||
return ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the canonical (workspace, workflowId) pair for a status target.
|
||||
*
|
||||
* A directory with a readable session record takes precedence and follows its latest
|
||||
* resume (an auto-named directory whose name equals its original workflow id resolves
|
||||
* here). Otherwise the input is matched exactly against every workflow id the session
|
||||
* records have claimed — session.json is the only trustworthy reverse mapping, and a
|
||||
* valid workspace name may itself end in `_shannon-<digits>`, so the id naming
|
||||
* convention is never used to guess.
|
||||
*/
|
||||
export function resolveScanIdentity(input: string): ScanIdentity {
|
||||
const runDir = path.join(getWorkspacesDir(), input);
|
||||
let isDirectory = false;
|
||||
try {
|
||||
isDirectory = fs.statSync(runDir).isDirectory();
|
||||
} catch {
|
||||
// Not a workspace directory — fall through to the exact workflow-id match.
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
const workflowId = resolveWorkflowId(input);
|
||||
if (workflowId !== undefined) {
|
||||
return { kind: 'ok', workspace: input, workflowId };
|
||||
}
|
||||
return { kind: 'not-found', reason: 'unreadable-record', sessionPath: resolveRunFile(runDir, 'session.json') };
|
||||
}
|
||||
|
||||
const claims: string[] = [];
|
||||
for (const workspace of listWorkspaces()) {
|
||||
const recorded = readRecordedWorkflowIds(path.join(getWorkspacesDir(), workspace.name));
|
||||
if (recorded.includes(input)) {
|
||||
claims.push(workspace.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (claims.length === 1) {
|
||||
// The exact requested id is kept, so an older workflow id keeps addressing that older execution.
|
||||
return { kind: 'ok', workspace: claims[0] as string, workflowId: input };
|
||||
}
|
||||
if (claims.length > 1) {
|
||||
return { kind: 'ambiguous', claims: [...claims].sort() };
|
||||
}
|
||||
return { kind: 'not-found', reason: 'no-match' };
|
||||
}
|
||||
|
||||
export type DefaultTarget =
|
||||
| { readonly kind: 'ok'; readonly workspace: string; readonly running: boolean }
|
||||
| { readonly kind: 'none' }
|
||||
| { readonly kind: 'ambiguous'; readonly running: readonly string[] };
|
||||
|
||||
/**
|
||||
* Pick the default workspace when the user gave none.
|
||||
*
|
||||
* Exactly one scan running → that scan. Multiple running → ambiguous, so the caller can
|
||||
* list them and ask for an explicit name. None running → the most recent workspace when
|
||||
* `allowFinished` (viewing commands), otherwise none (stopping a finished scan is a no-op).
|
||||
*/
|
||||
export function resolveDefaultWorkspace(opts: { readonly allowFinished: boolean }): DefaultTarget {
|
||||
const running = runningScanWorkspaces();
|
||||
if (running.length === 1) {
|
||||
return { kind: 'ok', workspace: running[0] as string, running: true };
|
||||
}
|
||||
if (running.length > 1) {
|
||||
return { kind: 'ambiguous', running };
|
||||
}
|
||||
|
||||
if (!opts.allowFinished) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
const workspaces = listWorkspaces();
|
||||
const mostRecent = workspaces[0];
|
||||
if (!mostRecent) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
return { kind: 'ok', workspace: mostRecent.name, running: false };
|
||||
}
|
||||
Reference in New Issue
Block a user