mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-20 16:57:13 +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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user