feat(logging): trace tool calls and write a log per agent

Record complete tool-call arguments in the workflow log and project each agent's events into its own durable log.

Add agent listing and agent-specific log tailing while preserving byte-exact output and draining log handles before
activities return.
This commit is contained in:
ajmallesh
2026-08-26 20:13:03 -07:00
parent 242f85f158
commit f5e7143619
43 changed files with 2694 additions and 1177 deletions
+170 -23
View File
@@ -9,6 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { StringDecoder } from 'node:string_decoder';
import { setTimeout as sleep } from 'node:timers/promises';
import { watch } from 'chokidar';
import { fail } from '../errors.js';
@@ -18,8 +19,69 @@ import { resolveWorkflowId } from '../session.js';
import { waitForWorkflowClose } from '../temporal-client.js';
import { stdoutIsTerminal } from '../tty.js';
/** Read a byte range from a file and return it as a UTF-8 string. */
function readRange(filePath: string, start: number, end: number): string {
const TERMINAL_HEADINGS = new Set(['Scan COMPLETED', 'Scan PARTIAL', 'Scan FAILED', 'Scan CANCELLED']);
// The combined log resets completion on the bare `RESUMED` heading; a per-agent file carries the
// distinct `--- RESUMED (<workflow id>) ---` boundary that WorkflowLogger.logResumeBoundary writes
// (kept distinct per resume so it stays idempotent per file). Both mean a new execution began, so a
// `--agent` tail must clear a stale terminal marker on either, matching the combined tail.
const AGENT_RESUME_BOUNDARY = /^--- RESUMED \(.+\) ---$/u;
function isResumeBoundary(line: string): boolean {
return line === 'RESUMED' || AGENT_RESUME_BOUNDARY.test(line);
}
/** Tracks only complete structural lines while output remains byte-for-byte unchanged. */
export class LogCompletionState {
private pendingLine = '';
private terminalIsLastMarker = false;
private failureIsLastMarker = false;
ingest(chunk: string): void {
const lines = `${this.pendingLine}${chunk}`.split('\n');
this.pendingLine = lines.pop() ?? '';
for (const line of lines) {
if (isResumeBoundary(line)) {
this.terminalIsLastMarker = false;
this.failureIsLastMarker = false;
} else if (TERMINAL_HEADINGS.has(line)) {
this.terminalIsLastMarker = true;
this.failureIsLastMarker = line === 'Scan FAILED';
}
}
}
isComplete(): boolean {
return this.terminalIsLastMarker;
}
hasFailureMarker(): boolean {
return this.failureIsLastMarker;
}
}
/** Append the forced-stop marker after the worker has exited, unless this execution already ended. */
export function appendCancellationFallback(logFile: string): void {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
const state = new LogCompletionState();
try {
state.ingest(fs.readFileSync(logFile, 'utf8'));
} catch {
// A pre-registration stop may not have created the file yet.
}
if (state.isComplete()) return;
const descriptor = fs.openSync(logFile, 'a', 0o600);
try {
fs.writeSync(descriptor, '\nScan CANCELLED\n');
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
/** Read a byte range without decoding across an arbitrary live-write boundary. */
function readRange(filePath: string, start: number, end: number): Buffer {
const length = end - start;
const buffer = Buffer.alloc(length);
const fd = fs.openSync(filePath, 'r');
@@ -28,7 +90,7 @@ function readRange(filePath: string, start: number, end: number): string {
} finally {
fs.closeSync(fd);
}
return buffer.toString('utf-8');
return buffer;
}
/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
@@ -76,9 +138,6 @@ export interface TailResult {
readonly sawFailure: boolean;
}
// The worker writes this exact line at the head of its terminal failure summary.
const FAILURE_MARKER = /^Scan FAILED$/m;
/**
* Stream a scan's log to the terminal until the workflow closes (completion comes from Temporal,
* or Ctrl-C). A Temporal outage is warned about and, if sustained, ends the tail with a diagnostic.
@@ -88,24 +147,25 @@ const FAILURE_MARKER = /^Scan FAILED$/m;
export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Promise<TailResult> {
return new Promise((resolve) => {
let position = 0;
const completion = new LogCompletionState();
let done = false;
let sawFailure = false;
const controller = new AbortController();
let watcher: ReturnType<typeof watch> | undefined;
const completionDecoder = new StringDecoder('utf8');
/** Output any new content appended since the last read. */
function flush(): void {
function flush(): boolean {
try {
const { size } = fs.statSync(logFile);
if (size <= position) return;
if (size <= position) return completion.isComplete();
const data = readRange(logFile, position, size);
process.stdout.write(data);
position = size;
if (!sawFailure && FAILURE_MARKER.test(data)) {
sawFailure = true;
}
completion.ingest(completionDecoder.write(data));
return completion.isComplete();
} catch {
// File not present yet or transiently unreadable — nothing to flush this round.
return false;
}
}
@@ -113,22 +173,32 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
if (done) return;
done = true;
controller.abort();
process.off('SIGINT', finish);
const result = { sawFailure: completion.hasFailureMarker() };
if (watcher) {
watcher.close().finally(() => resolve({ sawFailure }));
watcher.close().finally(() => resolve(result));
// Safety net — resolve anyway if watcher.close() stalls.
setTimeout(() => resolve({ sawFailure }), 1000).unref();
setTimeout(() => resolve(result), 1000).unref();
} else {
resolve({ sawFailure });
resolve(result);
}
}
// 1. Output existing content, then stream anything appended.
flush();
// 1. Output existing content, then stream anything appended. A per-agent file can be created
// after the watcher starts, so `add` is handled too and streams it from its first line.
watcher = watch(logFile, { persistent: true });
watcher.on('change', () => flush());
const onFsEvent = (): void => {
if (flush() && !opts.workflowId) finish();
};
watcher.on('change', onFsEvent);
watcher.on('add', onFsEvent);
if (flush() && !opts.workflowId) {
finish();
return;
}
// 2. Ctrl-C stops watching.
process.on('SIGINT', finish);
process.once('SIGINT', finish);
// 3. Temporal decides completion. Without a workflow id, the tail relies on Ctrl-C alone.
if (opts.workflowId) {
@@ -162,11 +232,51 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
});
}
export function logs(workspaceId: string): void {
const logFile = resolveLogFile(workspaceId);
const workflowId = resolveWorkflowId(workspaceId);
console.error(stdoutIsTerminal() ? `Tailing scan log: ${logFile}` : 'Tailing scan log');
/** The `.shannon/agents/` directory that sits beside a scan's combined workflow.log. */
function agentsDirFor(logFile: string): string {
return path.join(path.dirname(logFile), 'agents');
}
/** List the per-agent log names available for a scan (filename stems, sorted), or an empty list. */
export function listAgentLogNames(logFile: string): string[] {
try {
return fs
.readdirSync(agentsDirFor(logFile))
.filter((entry) => entry.endsWith('.log'))
.map((entry) => entry.slice(0, -'.log'.length))
.sort();
} catch {
return [];
}
}
/**
* Resolve an agent name to its per-agent log path. The name must be a closed-charset basename, and
* the resolved file must stay inside the agents directory: traversal and symlink escapes are
* rejected. Returns undefined when the name is structurally invalid or escapes the directory.
*/
export function resolveAgentLogFile(logFile: string, agentName: string): string | undefined {
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(agentName)) return undefined;
const agentsDir = agentsDirFor(logFile);
const target = path.join(agentsDir, `${agentName}.log`);
try {
const realDir = fs.realpathSync(agentsDir);
const realTarget = fs.realpathSync(target);
if (realTarget !== path.join(realDir, `${agentName}.log`)) return undefined;
} catch {
// The file does not exist yet (scan still starting); the closed-charset check already proved
// the path cannot traverse out of the agents directory, so it is safe to watch for creation.
}
return target;
}
export interface LogsOptions {
readonly agent?: string;
readonly listAgents?: boolean;
}
function tailFileToExit(logFile: string, workflowId: string | undefined, label: string): void {
console.error(stdoutIsTerminal() ? `${label}: ${logFile}` : label);
let unreachable = false;
tailUntilComplete(logFile, {
...(workflowId ? { workflowId } : {}),
@@ -175,3 +285,40 @@ export function logs(workspaceId: string): void {
},
}).finally(() => process.exit(unreachable ? 1 : 0));
}
export function logs(workspaceId: string, options: LogsOptions = {}): void {
const logFile = resolveLogFile(workspaceId);
if (options.listAgents) {
const names = listAgentLogNames(logFile);
if (names.length === 0) {
console.error('No per-agent logs for this scan yet.');
process.exit(0);
}
for (const name of names) console.log(name);
process.exit(0);
}
const workflowId = resolveWorkflowId(workspaceId);
if (options.agent !== undefined) {
const agentFile = resolveAgentLogFile(logFile, options.agent);
if (agentFile === undefined) {
fail(`No agent log named: ${options.agent}`, '', 'Available agents:', ...withBullets(listAgentLogNames(logFile)));
}
const known = listAgentLogNames(logFile);
// If the directory already lists agents, a name not among them is a typo, not a not-yet-created
// file; fail loudly rather than tailing a path that will never appear.
if (known.length > 0 && !known.includes(options.agent)) {
fail(`No agent log named: ${options.agent}`, '', 'Available agents:', ...withBullets(known));
}
tailFileToExit(agentFile, workflowId, `Tailing ${options.agent} log`);
return;
}
tailFileToExit(logFile, workflowId, 'Tailing scan log');
}
function withBullets(names: readonly string[]): string[] {
return names.length === 0 ? [' (none yet)'] : names.map((name) => ` - ${name}`);
}
+87 -12
View File
@@ -3,24 +3,29 @@
* Never touches infra or data; to wipe Temporal state entirely, use `shannon reset`.
*/
import path from 'node:path';
import * as p from '@clack/prompts';
import { confirmOrExit } from '../confirm.js';
import {
anyRunningScanWorkflow,
cancelWorkflow,
ensureDocker,
isTemporalReady,
isWorkflowRunning,
runningContainers,
runningScanWorkspaces,
scanFilter,
stopContainers,
terminateAllWorkflows,
terminateWorkflow,
WORKER_FILTER,
} from '../docker.js';
import { fail, failUsage, warn } from '../errors.js';
import { getWorkspacesDir } from '../home.js';
import { commandPrefix } from '../mode.js';
import { resolveRunFile } from '../paths.js';
import { resolveWorkflowId } from '../session.js';
import { resolveDefaultWorkspace } from '../workspaces.js';
import { appendCancellationFallback } from './logs.js';
export interface StopOptions {
all: boolean;
@@ -28,11 +33,77 @@ export interface StopOptions {
workspace?: string;
}
const CANCELLATION_GRACE_MS = 10_000;
const CANCELLATION_POLL_MS = 250;
export interface StopTarget {
readonly workspace: string;
readonly workflowId?: string;
readonly workflowRunning: boolean;
}
export interface StopLifecycle {
readonly cancel: (workflowId: string) => boolean;
readonly isRunning: (workflowId: string) => boolean;
readonly terminate: (workflowId: string) => boolean;
readonly containers: (workspace: string) => string[];
readonly stopContainers: (ids: string[]) => Promise<void>;
readonly appendFallback: (workspace: string) => void;
readonly wait: (milliseconds: number) => Promise<void>;
}
const stopLifecycle: StopLifecycle = {
cancel: cancelWorkflow,
isRunning: isWorkflowRunning,
terminate: (workflowId) => terminateWorkflow(workflowId, 'Stopped after cancellation grace period'),
containers: (workspace) => runningContainers(scanFilter(workspace)),
stopContainers,
appendFallback: (workspace) => {
const logFile = resolveRunFile(path.join(getWorkspacesDir(), workspace), 'workflow.log');
appendCancellationFallback(logFile);
},
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
};
/** Cancel first; terminate and write the fallback heading only when graceful closure misses its deadline. */
export async function stopTargetCancelFirst(
target: StopTarget,
lifecycle: StopLifecycle = stopLifecycle,
graceMs: number = CANCELLATION_GRACE_MS,
pollMs: number = CANCELLATION_POLL_MS,
): Promise<'graceful' | 'forced'> {
let forced = !target.workflowRunning || target.workflowId === undefined;
if (!forced && target.workflowId !== undefined) {
lifecycle.cancel(target.workflowId);
const deadline = Date.now() + graceMs;
while (lifecycle.isRunning(target.workflowId) && Date.now() < deadline) {
await lifecycle.wait(pollMs);
}
forced = lifecycle.isRunning(target.workflowId);
if (forced) lifecycle.terminate(target.workflowId);
}
await lifecycle.stopContainers(lifecycle.containers(target.workspace));
if (forced && lifecycle.containers(target.workspace).length === 0) {
lifecycle.appendFallback(target.workspace);
}
return forced ? 'forced' : 'graceful';
}
/** Apply the same captured-target lifecycle concurrently for `stop --all`. */
export function stopTargetsCancelFirst(
targets: readonly StopTarget[],
lifecycle: StopLifecycle = stopLifecycle,
graceMs: number = CANCELLATION_GRACE_MS,
pollMs: number = CANCELLATION_POLL_MS,
): Promise<readonly ('graceful' | 'forced')[]> {
return Promise.all(targets.map((target) => stopTargetCancelFirst(target, lifecycle, graceMs, pollMs)));
}
/**
* Stop a single scan. Terminating the workflow both clears Temporal's record and
* brings the container down (the worker waits on the workflow result), so that runs
* first; `docker stop` is the fallback for the pre-registration window and an
* unreachable Temporal. The stop is then verified rather than assumed.
* Stop a single scan. Cooperative cancellation gets the first ten seconds so the
* workflow can flush its terminal log; termination and a host-written heading are
* the fallback for the pre-registration window or an unavailable finalizer.
*/
async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
const workflowId = resolveWorkflowId(workspace);
@@ -56,10 +127,7 @@ async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
const spinner = p.spinner();
spinner.start(`Stopping scan ${workspace}`);
if (workflowId && workflowRunning) {
terminateWorkflow(workflowId, `Stopped via shannon stop ${workspace}`);
}
await stopContainers(runningContainers(filter));
await stopTargetCancelFirst({ workspace, ...(workflowId !== undefined && { workflowId }), workflowRunning });
const stillRunning = runningContainers(filter);
if (stillRunning.length > 0) {
@@ -78,6 +146,14 @@ async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
async function stopAllScans(yes: boolean): Promise<void> {
const temporalUp = isTemporalReady();
const initial = runningContainers(WORKER_FILTER);
const targets = [...new Set(runningScanWorkspaces())].map((workspace): StopTarget => {
const workflowId = resolveWorkflowId(workspace);
return {
workspace,
...(workflowId !== undefined && { workflowId }),
workflowRunning: Boolean(workflowId && temporalUp && isWorkflowRunning(workflowId)),
};
});
// Resolve what is running before prompting, so we never confirm a no-op.
if (initial.length === 0) {
@@ -90,9 +166,8 @@ async function stopAllScans(yes: boolean): Promise<void> {
const spinner = p.spinner();
spinner.start('Stopping all scans');
if (temporalUp) {
terminateAllWorkflows('Stopped via shannon stop --all');
}
await stopTargetsCancelFirst(targets);
// Keep the legacy safety net for a worker whose workspace label was unavailable.
await stopContainers(runningContainers(WORKER_FILTER));
const stillRunning = runningContainers(WORKER_FILTER);
+5 -12
View File
@@ -519,6 +519,11 @@ export async function stopContainers(ids: string[]): Promise<void> {
await Promise.all(ids.map((id) => spawnQuiet('docker', ['stop', id])));
}
/** Request cooperative cancellation so the workflow can run its terminal finalizer. */
export function cancelWorkflow(workflowId: string): boolean {
return runQuiet('docker', temporalCmd('workflow', 'cancel', '--workflow-id', workflowId));
}
/**
* Terminate a Temporal workflow so a stopped scan doesn't linger as a running
* workflow with no worker. Best-effort: returns false if Temporal is unreachable
@@ -528,18 +533,6 @@ export function terminateWorkflow(workflowId: string, reason: string): boolean {
return runQuiet('docker', temporalCmd('workflow', 'terminate', '--workflow-id', workflowId, '--reason', reason));
}
/**
* Terminate every running pentest workflow in one batch, so `stop --all` doesn't
* leave workflows running with no worker. Best-effort: returns false if Temporal
* is unreachable. Requires Temporal to be up (guard with isTemporalReady).
*/
export function terminateAllWorkflows(reason: string): boolean {
return runQuiet(
'docker',
temporalCmd('workflow', 'terminate', '--query', RUNNING_SCAN_QUERY, '--reason', reason, '--yes'),
);
}
/**
* Whether a specific workflow is still in the Running state. Re-querying this after
* a terminate verifies it actually took effect, rather than trusting the terminate
+14 -3
View File
@@ -94,6 +94,7 @@ function renderUsage(prefix: string, mode: Mode): string {
[`${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 (default: running or most recent)"],
[`${prefix} logs [<workspace>] --agent <name>`, "Tail one agent's log; --list-agents to list them"],
[
`${prefix} status [<workspace>] [--json]`,
'Live phase/agent progress of one scan (default: running or most recent)',
@@ -294,9 +295,19 @@ async function main(): Promise<void> {
break;
}
case 'logs': {
const { positionals } = parseArgs(rest, { maxPositionals: 1 });
const workspaceId = resolveViewingWorkspace(positionals[0], `Usage: ${commandPrefix()} logs [<workspace>]`);
logs(workspaceId);
const { flags, values, positionals } = parseArgs(rest, {
booleans: { listAgents: ['--list-agents'] },
values: { agent: ['--agent'] },
maxPositionals: 1,
});
const workspaceId = resolveViewingWorkspace(
positionals[0],
`Usage: ${commandPrefix()} logs [<workspace>] [--agent <name>] [--list-agents]`,
);
logs(workspaceId, {
...(values.agent !== undefined && { agent: values.agent }),
...(flags.listAgents && { listAgents: true }),
});
break;
}
case 'status': {
+8 -9
View File
@@ -16,6 +16,7 @@ import {
pipelineForState,
} from './pipeline.js';
import type { RenderInput } from './render.js';
import { safeFailureDetail, safeOperationKey, safeOperationLabel } from './safe-fields.js';
export type RunState = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
@@ -77,11 +78,9 @@ function agentState(name: string, state: PipelineState | null, running: Set<stri
*/
function agentError(name: string, state: PipelineState | null, byAgent: Map<string, RunningAgent>): string | undefined {
const failed = state?.failedPipelines.find((f) => f.vulnType === agentClass(name));
return (
failed?.error ??
byAgent.get(name)?.lastFailure ??
(state?.failedAgent === name ? (state.error ?? undefined) : undefined)
);
const hasFailure =
failed !== undefined || byAgent.get(name)?.lastFailure !== undefined || state?.failedAgent === name;
return safeFailureDetail(hasFailure);
}
/** Scan wall-clock elapsed ms: recorded duration for a closed scan, live elapsed for a running one. */
@@ -229,7 +228,7 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
label: runner.label,
status: 'running' as const,
...(runner.startedAt !== undefined && { startedAt: runner.startedAt }),
...(runner.lastFailure !== undefined && { error: runner.lastFailure }),
...(runner.lastFailure !== undefined && { error: safeFailureDetail(true) }),
}));
const operationalAgents: DerivedAgent[] = [...persistedOperations, ...unpersistedRunning].map((operation) => {
const runner = byAgent.get(operation.key);
@@ -237,15 +236,15 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
const persistedDurationMs = 'durationMs' in operation ? (operation.durationMs ?? null) : null;
const detail = operationState === 'running' ? stepByFamily.get(operationFamilyKey(operation.key)) : undefined;
return {
name: operation.key,
label: operation.label,
name: safeOperationKey(operation.key),
label: safeOperationLabel(operation.label),
state: operationState,
durationMs: operationState === 'completed' ? persistedDurationMs : null,
runningElapsedMs:
operationState === 'running' && operation.startedAt !== undefined ? now - operation.startedAt : null,
attempt: operationState === 'running' ? (runner?.attempt ?? null) : null,
...(detail !== undefined && { detail }),
...(operation.error !== undefined && { error: operation.error }),
...(operation.error !== undefined && { error: safeFailureDetail(true) }),
};
});
+9 -6
View File
@@ -12,6 +12,7 @@ import { commandPrefix } from '../mode.js';
import type { RunningAgent } from '../temporal-client.js';
import { derivePipeline, isTerminal, type RunState, scanElapsedMs } from './derive.js';
import type { PipelineState } from './pipeline.js';
import { safeAgenticSast, safeCliIdentifier, safePartialReasons, safeTerminalFailure } from './safe-fields.js';
export interface RenderInput {
readonly workspace: string;
@@ -67,7 +68,7 @@ function truncate(text: string, max: number): string {
/** Temporal Web UI, published by compose on 8233; deep-links to the workflow when its id is known. */
function temporalDashboardUrl(workflowId: string | undefined): string {
const base = 'http://localhost:8233';
return workflowId ? `${base}/namespaces/default/workflows/${workflowId}` : base;
return workflowId ? `${base}/namespaces/default/workflows/${safeCliIdentifier(workflowId)}` : base;
}
// === Glyphs & status ===
@@ -214,7 +215,7 @@ export function renderScan(input: RenderInput, opts: RenderOptions): string {
function headerLines(input: RenderInput, opts: RenderOptions): string[] {
const elapsedMs = scanElapsedMs(input, opts.now);
const meta = [statusBadge(input, opts), elapsedMs !== undefined ? formatDuration(elapsedMs) : '—'].join(' · ');
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${input.workspace.padEnd(22)} ${meta}`];
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${safeCliIdentifier(input.workspace).padEnd(22)} ${meta}`];
}
/** Aligned label column for the footer's Logs / Temporal rows. */
@@ -239,7 +240,7 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
// A partial scan names each durable degradation reason through its safe message,
// so the operator never has to guess why the badge is not "completed".
const reasons = input.state.partialReasons ?? [];
const reasons = safePartialReasons(input.state.partialReasons ?? []);
if (reasons.length > 0) {
lines.push('', ` ${paint('Why this scan is partial:', COLORS.yellow, opts.color)}`);
for (const reason of reasons) {
@@ -247,7 +248,7 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
}
// The safe message names what degraded; these three name the agentic-SAST failure
// behind it, under the same labels the scan log and worker output use.
const agenticSast = input.state.agenticSast;
const agenticSast = safeAgenticSast(input.state.agenticSast);
if (agenticSast?.status === 'failed') {
if (agenticSast.failedStageLabel !== undefined) {
lines.push(paint(` Agentic SAST stopped at: ${agenticSast.failedStageLabel}`, COLORS.dim, opts.color));
@@ -268,11 +269,13 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
return lines;
}
const logsValue = `${prefix} logs ${input.workspace}`;
const logsValue = `${prefix} logs ${safeCliIdentifier(input.workspace)}`;
const temporalValue = temporalDashboardUrl(input.workflowId);
if (isTerminal(input.temporalStatus)) {
const reason = input.failureMessage ?? input.state?.error ?? 'no result recorded';
const hasRecordedFailure =
input.failureMessage !== undefined || (input.state !== null && input.state.error !== null);
const reason = safeTerminalFailure(hasRecordedFailure) ?? 'no result recorded';
return [
footerDivider(opts),
paint(
+215
View File
@@ -0,0 +1,215 @@
/** Closed-field projection for Temporal values displayed by the CLI. */
import type { PartialReasonView, PipelineState } from './pipeline.js';
const CLASS_NAMES: Readonly<Record<string, string>> = Object.freeze({
injection: 'Injection',
xss: 'Cross-Site Scripting',
auth: 'Authentication',
authz: 'Authorization',
ssrf: 'Server-Side Request Forgery',
miscellaneous: 'Miscellaneous',
});
const STAGE_NAMES: Readonly<Record<string, string>> = Object.freeze({
architecture: 'architecture mapping',
'threat-model': 'threat modelling',
plan: 'review planning',
research: 'deep code research',
dedupe: 'duplicate merging',
review: 'independent review',
critic: 'viability critique',
confirm: 'static confirmation',
calibrate: 'risk calibration',
export: 'findings export',
workflow: 'orchestration',
});
const TERMINAL_STAGE_NAMES = new Set([
'architecture',
'threat model',
'planning',
'audit wave',
'deduplication',
'review',
'critic',
'confirmation',
'calibration',
'export',
'orchestration',
]);
const CAPELLA_FAILURE_MESSAGES = new Set([
'Provider authentication failed. Verify the configured credential.',
'Agentic SAST configuration is invalid.',
'Agentic SAST received invalid input.',
'An agentic SAST step returned an unusable result.',
'An agentic SAST step failed.',
'Agentic SAST infrastructure failed before producing a usable result.',
'Agentic SAST had not finished when the scan stopped.',
]);
const OPERATION_LABELS = new Set([
'Agentic SAST',
'Miscellaneous findings',
'Reconcile injection',
'Reconcile xss',
'Reconcile auth',
'Reconcile authz',
'Reconcile ssrf',
'Reconcile miscellaneous',
'Prepare reconciliation',
'Enrich observations',
'Form exploit tasks',
'Materialize exploit tasks',
'Publish reconciliation',
'Renumber injection',
'Renumber xss',
'Renumber auth',
'Renumber authz',
'Renumber ssrf',
'Renumber miscellaneous',
'Initialize report state',
'Assemble report inputs',
'Compact report findings',
'Saving report progress',
'Finalize report outputs',
'Finalize report without SARIF',
'Saving final report state',
'Surface customer report',
]);
function safeClassName(value: string | undefined): string | undefined {
return value === undefined ? undefined : CLASS_NAMES[value];
}
function safeStageName(value: string | undefined): string | undefined {
return value === undefined ? undefined : STAGE_NAMES[value];
}
function reasonMessage(reason: PartialReasonView): string | undefined {
const className = safeClassName(reason.vulnerabilityClass);
switch (reason.code) {
case 'agentic_sast_failed': {
const stageName = safeStageName(reason.stage);
return stageName === undefined
? 'Agentic SAST failed, so the pentest continued without its findings.'
: `Agentic SAST failed during ${stageName}, so the pentest continued without its findings.`;
}
case 'agentic_sast_reduced':
return 'Agentic SAST completed with reduced coverage.';
case 'class_pipeline_failed':
return className === undefined
? undefined
: `${className} could not be fully assessed. The other classes completed. Re-running this workspace retries only the part that failed.`;
case 'class_reconciliation_failed':
return className === undefined
? undefined
: `${className} findings could not be grouped into test cases, so that class was not exploited. Its analysis results are still in the report.`;
case 'report_renumber_failed':
return className === undefined
? undefined
: `${className} findings kept their working reference numbers, so numbering in the report may have gaps. The findings themselves are complete.`;
case 'report_compaction_failed':
return 'Finding reference numbers in the report may have gaps. Every finding is present; only the numbering is affected.';
case 'report_class_omitted':
return className === undefined
? undefined
: `${className} was assessed but could not be included in the final report.`;
case 'report_sarif_failed':
return 'Report SARIF could not be generated. JSON and Markdown remain available.';
default:
return undefined;
}
}
export function safePartialReasons(reasons: readonly PartialReasonView[]): readonly PartialReasonView[] {
return reasons.flatMap((reason) => {
const message = reasonMessage(reason);
if (message === undefined) return [];
const vulnerabilityClass =
safeClassName(reason.vulnerabilityClass) === undefined ? undefined : reason.vulnerabilityClass;
const stage = safeStageName(reason.stage) === undefined ? undefined : reason.stage;
return [
{
code: reason.code,
message,
...(vulnerabilityClass !== undefined && { vulnerabilityClass }),
...(stage !== undefined && { stage }),
},
];
});
}
export function safeAgenticSast(value: PipelineState['agenticSast']):
| {
readonly status: string;
readonly failedStageLabel?: string;
readonly error?: string;
readonly errorCode?: string;
}
| undefined {
if (value === undefined || !['disabled', 'running', 'succeeded', 'failed'].includes(value.status)) return undefined;
const failedStageLabel = TERMINAL_STAGE_NAMES.has(value.failedStageLabel ?? '') ? value.failedStageLabel : undefined;
let error: string | undefined;
if (value.error !== undefined && CAPELLA_FAILURE_MESSAGES.has(value.error)) {
error = value.error;
} else if (value.status === 'failed') {
error = 'An agentic SAST step failed.';
}
const errorCode =
value.errorCode !== undefined && /^[A-Z][A-Z0-9_]{0,63}$/u.test(value.errorCode) ? value.errorCode : undefined;
return {
status: value.status,
...(failedStageLabel !== undefined && { failedStageLabel }),
...(error !== undefined && { error }),
...(errorCode !== undefined && { errorCode }),
};
}
export function safeOperationLabel(value: string): string {
return OPERATION_LABELS.has(value) ? value : 'Background task';
}
export function safeOperationKey(value: string): string {
if (
/^(?:agentic-sast|miscellaneous-pipeline|report:(?:initialize|assemble|compact|checkpoint|finalize|finalize-degraded|terminal|surface))$/u.test(
value,
) ||
/^(?:reconciliation|report:renumber):(?:injection|xss|auth|authz|ssrf|miscellaneous)$/u.test(value)
) {
return value;
}
return 'background-task';
}
export function safeCliIdentifier(value: string): string {
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value) ? value : 'unknown';
}
export function safeTemporalStatus(value: string): string {
return [
'RUNNING',
'UNSPECIFIED',
'COMPLETED',
'FAILED',
'CANCELLED',
'CANCELED',
'TERMINATED',
'TIMED_OUT',
'CONTINUED_AS_NEW',
].includes(value)
? value
: 'UNKNOWN';
}
export function safeFailureDetail(hasFailure: true): string;
export function safeFailureDetail(hasFailure: false): undefined;
export function safeFailureDetail(hasFailure: boolean): string | undefined;
export function safeFailureDetail(hasFailure: boolean): string | undefined {
return hasFailure ? 'This scan step could not be completed.' : undefined;
}
export function safeTerminalFailure(hasFailure: boolean): string | undefined {
return hasFailure ? 'The scan could not be completed.' : undefined;
}
+14 -6
View File
@@ -10,6 +10,13 @@ import type { DerivedPhase } from './derive.js';
import { derivePipeline, isTerminal, scanElapsedMs } from './derive.js';
import type { PartialReasonView } from './pipeline.js';
import type { RenderInput } from './render.js';
import {
safeAgenticSast,
safeCliIdentifier,
safePartialReasons,
safeTemporalStatus,
safeTerminalFailure,
} from './safe-fields.js';
/** Coarse scan status token, mirroring the human status badge in machine-friendly form. */
export type ScanStatus = 'running' | 'completed' | 'partial' | 'failed' | 'stopped' | 'cancelled' | 'timed_out';
@@ -61,19 +68,20 @@ function deriveStatus(input: RenderInput): ScanStatus {
/** Build the JSON snapshot for a scan at instant `now`. */
export function toStatusJson(input: RenderInput, now: number): StatusJson {
const elapsedMs = scanElapsedMs(input, now);
const partialReasons = input.state?.partialReasons ?? [];
const agenticSast = input.state?.agenticSast;
const partialReasons = safePartialReasons(input.state?.partialReasons ?? []);
const agenticSast = safeAgenticSast(input.state?.agenticSast);
const usageAccountingComplete = input.state?.summary?.usageAccountingComplete;
const failureMessage = safeTerminalFailure(input.failureMessage !== undefined);
return {
workspace: input.workspace,
...(input.workflowId !== undefined && { workflowId: input.workflowId }),
workspace: safeCliIdentifier(input.workspace),
...(input.workflowId !== undefined && { workflowId: safeCliIdentifier(input.workflowId) }),
status: deriveStatus(input),
temporalStatus: input.temporalStatus,
temporalStatus: safeTemporalStatus(input.temporalStatus),
elapsedMs: elapsedMs ?? null,
...(input.startedAt !== undefined && { startedAt: new Date(input.startedAt).toISOString() }),
...(input.endedAt !== undefined && { endedAt: new Date(input.endedAt).toISOString() }),
...(input.failureMessage !== undefined && { failureMessage: input.failureMessage }),
...(failureMessage !== undefined && { failureMessage }),
...(partialReasons.length > 0 && { partialReasons }),
// Present only when agentic SAST actually ran; a disabled scan omits the key entirely.
...(agenticSast !== undefined &&