mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-22 09:40:53 +02:00
feat(worker): disclose scan coverage and make reporting auditable
Build on the retry-safe finalization foundation to preserve correct identities, source locations, scan dates, partial-coverage limitations, and consistent report JSON, Markdown, SARIF, and PDF output. Report Agentic SAST, reconciliation wall-clock time, stage usage, retry spend, and background work without duplicate or hardcoded totals. Keep report findings canonical, drop cross-class restatements, name enrichment losses, and render the executive-summary narrative in the PDF.
This commit is contained in:
@@ -248,19 +248,35 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
|
||||
};
|
||||
});
|
||||
|
||||
// The synthetic phase appears only when there is operational work to show, so a scan
|
||||
// The synthetic phase(s) appear only when there is operational work to show, so a scan
|
||||
// with no recorded operational stages keeps the plain agent tree.
|
||||
if (operationalAgents.length === 0) return agentPhases;
|
||||
return [
|
||||
...agentPhases,
|
||||
{
|
||||
|
||||
// Agentic SAST is a pluggable analysis engine — a peer to the pentest, not background plumbing —
|
||||
// so it stands in its own phase; reconciliation and report steps remain under "Background work".
|
||||
const engineAgents = operationalAgents.filter((agent) => operationFamilyKey(agent.name) === 'agentic-sast');
|
||||
const backgroundAgents = operationalAgents.filter((agent) => operationFamilyKey(agent.name) !== 'agentic-sast');
|
||||
|
||||
const syntheticPhases: DerivedPhase[] = [];
|
||||
if (engineAgents.length > 0) {
|
||||
syntheticPhases.push({
|
||||
key: 'analysis-engines',
|
||||
label: 'Analysis Engines',
|
||||
parallel: true,
|
||||
state: phaseGlyphState(engineAgents.map((operation) => operation.state)),
|
||||
agents: engineAgents,
|
||||
});
|
||||
}
|
||||
if (backgroundAgents.length > 0) {
|
||||
syntheticPhases.push({
|
||||
key: 'operational-work',
|
||||
label: 'Background work',
|
||||
parallel: true,
|
||||
state: phaseGlyphState(operationalAgents.map((operation) => operation.state)),
|
||||
agents: operationalAgents,
|
||||
},
|
||||
];
|
||||
state: phaseGlyphState(backgroundAgents.map((operation) => operation.state)),
|
||||
agents: backgroundAgents,
|
||||
});
|
||||
}
|
||||
return [...agentPhases, ...syntheticPhases];
|
||||
}
|
||||
|
||||
export { agentError };
|
||||
|
||||
@@ -344,6 +344,8 @@ export interface PipelineState {
|
||||
readonly failedStageLabel?: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
/** Usage-accounting warnings projected by the worker; empty when the ledger reconciled. */
|
||||
readonly warnings?: readonly string[];
|
||||
};
|
||||
readonly nonFatalFailures?: { readonly phase: string; readonly error: string }[];
|
||||
/** Ordered durable degradation reasons with safe messages; empty or absent for full success. */
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
/** Closed-field projection for Temporal values displayed by the CLI. */
|
||||
/**
|
||||
* Closed-field projection for Temporal values displayed by the CLI.
|
||||
*
|
||||
* PipelineState travels through Temporal from a worker container this process does not
|
||||
* control, so free-text fields are treated as unvetted: this module either matches a
|
||||
* value against a known closed set (safe to print as-is) or collapses it to a fixed,
|
||||
* bounded message. A value with no case here should fail closed to something generic,
|
||||
* never pass through untouched.
|
||||
*/
|
||||
|
||||
import type { PartialReasonView, PipelineState } from './pipeline.js';
|
||||
|
||||
@@ -49,6 +57,23 @@ const CAPELLA_FAILURE_MESSAGES = new Set([
|
||||
'Agentic SAST had not finished when the scan stopped.',
|
||||
]);
|
||||
|
||||
// Mirrors apps/worker/src/types/errors.ts. The CLI cannot import from the worker package,
|
||||
// so keep this exact closed set in sync with ProviderFailureCategory.
|
||||
const PROVIDER_FAILURE_CATEGORIES = new Set([
|
||||
'rate_limit',
|
||||
'overloaded',
|
||||
'transport',
|
||||
'context_limit',
|
||||
'quota',
|
||||
'authentication',
|
||||
'configuration',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function isProviderFailureCategory(value: unknown): value is string {
|
||||
return typeof value === 'string' && PROVIDER_FAILURE_CATEGORIES.has(value);
|
||||
}
|
||||
|
||||
const OPERATION_LABELS = new Set([
|
||||
'Agentic SAST',
|
||||
'Miscellaneous findings',
|
||||
@@ -141,12 +166,27 @@ export function safePartialReasons(reasons: readonly PartialReasonView[]): reado
|
||||
});
|
||||
}
|
||||
|
||||
/** Upper bounds on the warning array crossing into cli.status.json, so a malformed state cannot bloat it. */
|
||||
const MAX_AGENTIC_SAST_WARNINGS = 20;
|
||||
const MAX_AGENTIC_SAST_WARNING_LENGTH = 2_000;
|
||||
|
||||
/** Sanitize the worker's usage-accounting warnings: strings only, bounded count and length. */
|
||||
function safeAgenticSastWarnings(value: PipelineState['agenticSast']): readonly string[] {
|
||||
const warnings = value?.warnings;
|
||||
if (!Array.isArray(warnings)) return [];
|
||||
return warnings
|
||||
.filter((warning): warning is string => typeof warning === 'string')
|
||||
.slice(0, MAX_AGENTIC_SAST_WARNINGS)
|
||||
.map((warning) => warning.slice(0, MAX_AGENTIC_SAST_WARNING_LENGTH));
|
||||
}
|
||||
|
||||
export function safeAgenticSast(value: PipelineState['agenticSast']):
|
||||
| {
|
||||
readonly status: string;
|
||||
readonly failedStageLabel?: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
| undefined {
|
||||
if (value === undefined || !['disabled', 'running', 'succeeded', 'failed'].includes(value.status)) return undefined;
|
||||
@@ -158,12 +198,16 @@ export function safeAgenticSast(value: PipelineState['agenticSast']):
|
||||
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;
|
||||
value.errorCode !== undefined &&
|
||||
(/^[A-Z][A-Z0-9_]{0,63}$/u.test(value.errorCode) || isProviderFailureCategory(value.errorCode))
|
||||
? value.errorCode
|
||||
: undefined;
|
||||
return {
|
||||
status: value.status,
|
||||
...(failedStageLabel !== undefined && { failedStageLabel }),
|
||||
...(error !== undefined && { error }),
|
||||
...(errorCode !== undefined && { errorCode }),
|
||||
warnings: safeAgenticSastWarnings(value),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,6 +227,11 @@ export function safeOperationKey(value: string): string {
|
||||
return 'background-task';
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace or workflow id is printed straight into the progress display, so this
|
||||
* confines it to a plain identifier charset before that happens: no control or escape
|
||||
* characters survive to reach the terminal.
|
||||
*/
|
||||
export function safeCliIdentifier(value: string): string {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value) ? value : 'unknown';
|
||||
}
|
||||
@@ -210,6 +259,7 @@ export function safeFailureDetail(hasFailure: boolean): string | undefined {
|
||||
return hasFailure ? 'This scan step could not be completed.' : undefined;
|
||||
}
|
||||
|
||||
/** Same closed-set trade-off as safeFailureDetail, for the scan-level (not per-agent) failure. */
|
||||
export function safeTerminalFailure(hasFailure: boolean): string | undefined {
|
||||
return hasFailure ? 'The scan could not be completed.' : undefined;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,13 @@ export interface StatusJson {
|
||||
/** Ordered durable degradation reasons with safe messages; present only when non-empty. */
|
||||
readonly partialReasons?: readonly PartialReasonView[];
|
||||
/** Agentic SAST outcome, with the worker's sanitized failure sentence and bounded code. */
|
||||
readonly agenticSast?: { readonly status: string; readonly error?: string; readonly errorCode?: string };
|
||||
readonly agenticSast?: {
|
||||
readonly status: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
/** Usage-accounting warnings; always present (empty when the ledger reconciled) so it is never null. */
|
||||
readonly warnings: readonly string[];
|
||||
};
|
||||
/** False when operational (Capella/reconciliation) spend is known to be incomplete. */
|
||||
readonly usageAccountingComplete?: boolean;
|
||||
readonly phases: readonly DerivedPhase[];
|
||||
@@ -90,6 +96,7 @@ export function toStatusJson(input: RenderInput, now: number): StatusJson {
|
||||
status: agenticSast.status,
|
||||
...(agenticSast.error !== undefined && { error: agenticSast.error }),
|
||||
...(agenticSast.errorCode !== undefined && { errorCode: agenticSast.errorCode }),
|
||||
warnings: [...agenticSast.warnings],
|
||||
},
|
||||
}),
|
||||
...(usageAccountingComplete !== undefined && { usageAccountingComplete }),
|
||||
|
||||
Reference in New Issue
Block a user