mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-14 05:59:06 +02:00
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:
+170
-23
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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': {
|
||||
|
||||
@@ -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) }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -4,83 +4,69 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
// Null Object pattern for audit logging - callers never check for null
|
||||
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { isLoggableAgentName, type LoggableAgentName, type SafeErrorDetails } from '../audit/safe-fields.js';
|
||||
|
||||
/**
|
||||
* Per-agent-run error audit sink. `createAuditLogger` always returns one of these
|
||||
* (never null), so a caller can log unconditionally without checking whether
|
||||
* audit is actually wired up for this run.
|
||||
*/
|
||||
export interface AuditLogger {
|
||||
logLlmResponse(turn: number, content: string): Promise<void>;
|
||||
logToolStart(toolName: string, parameters: unknown): Promise<void>;
|
||||
logToolEnd(result: unknown): Promise<void>;
|
||||
logError(error: Error, duration: number, turns: number): Promise<void>;
|
||||
logNote(category: string, message: string): Promise<void>;
|
||||
logError(error: SafeErrorDetails, duration: number, turns: number): Promise<void>;
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
|
||||
class RealAuditLogger implements AuditLogger {
|
||||
private auditSession: AuditSession;
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(auditSession: AuditSession) {
|
||||
this.auditSession = auditSession;
|
||||
constructor(
|
||||
private readonly auditSession: AuditSession,
|
||||
private readonly agentName: LoggableAgentName,
|
||||
private readonly attemptNumber: number,
|
||||
) {}
|
||||
|
||||
// Serializes writes onto one chain so concurrent calls append in call order rather than racing
|
||||
// on the underlying audit session, and swallows failures so a broken audit write never surfaces
|
||||
// as the agent's own error: recording an error must not itself risk failing the run.
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
this.queue = this.queue.then(operation, operation).catch(() => undefined);
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
async logLlmResponse(turn: number, content: string): Promise<void> {
|
||||
await this.auditSession.logEvent('llm_response', {
|
||||
turn,
|
||||
content,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
logError(error: SafeErrorDetails, duration: number, turns: number): Promise<void> {
|
||||
return this.enqueue(() =>
|
||||
this.auditSession.logAgentError(this.agentName, error.code, error.category, this.attemptNumber, duration, turns),
|
||||
);
|
||||
}
|
||||
|
||||
async logToolStart(toolName: string, parameters: unknown): Promise<void> {
|
||||
await this.auditSession.logEvent('tool_start', {
|
||||
toolName,
|
||||
parameters,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
async logToolEnd(result: unknown): Promise<void> {
|
||||
await this.auditSession.logEvent('tool_end', {
|
||||
result,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
async logError(error: Error, duration: number, turns: number): Promise<void> {
|
||||
await this.auditSession.logEvent('error', {
|
||||
message: error.message,
|
||||
errorType: error.constructor.name,
|
||||
stack: error.stack,
|
||||
duration,
|
||||
turns,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
async logNote(category: string, message: string): Promise<void> {
|
||||
await this.auditSession.logWorkflowNote(category, message);
|
||||
async flush(): Promise<void> {
|
||||
await this.queue;
|
||||
}
|
||||
}
|
||||
|
||||
/** Null Object implementation - all methods are safe no-ops */
|
||||
/** No-op sink for a run with no audit session or an agent name unsafe to log. */
|
||||
class NullAuditLogger implements AuditLogger {
|
||||
async logLlmResponse(_turn: number, _content: string): Promise<void> {}
|
||||
async logError(_error: SafeErrorDetails, _duration: number, _turns: number): Promise<void> {}
|
||||
|
||||
async logToolStart(_toolName: string, _parameters: unknown): Promise<void> {}
|
||||
|
||||
async logToolEnd(_result: unknown): Promise<void> {}
|
||||
|
||||
async logError(_error: Error, _duration: number, _turns: number): Promise<void> {}
|
||||
|
||||
async logNote(_category: string, _message: string): Promise<void> {}
|
||||
async flush(): Promise<void> {}
|
||||
}
|
||||
|
||||
// Returns no-op when auditSession is null
|
||||
export function createAuditLogger(auditSession: AuditSession | null): AuditLogger {
|
||||
if (auditSession) {
|
||||
return new RealAuditLogger(auditSession);
|
||||
/**
|
||||
* Build the error-audit sink for one agent attempt.
|
||||
*
|
||||
* Falls back to the null sink whenever real logging can't be done safely: no
|
||||
* audit session for this run, no agent name, or a name that isn't in the closed
|
||||
* loggable set (`isLoggableAgentName`). An unrecognized name is never written
|
||||
* to the durable audit trail, even as a bare string.
|
||||
*/
|
||||
export function createAuditLogger(
|
||||
auditSession: AuditSession | null,
|
||||
agentName: string | null,
|
||||
attemptNumber: number,
|
||||
): AuditLogger {
|
||||
if (auditSession !== null && agentName !== null && isLoggableAgentName(agentName)) {
|
||||
return new RealAuditLogger(auditSession, agentName, attemptNumber);
|
||||
}
|
||||
|
||||
return new NullAuditLogger();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* a direct mapping.
|
||||
*/
|
||||
|
||||
import type { SafeErrorDetails } from '../audit/safe-fields.js';
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
import { extractAgentType, formatDuration } from '../utils/formatting.js';
|
||||
import type { ExecutionContext } from './types.js';
|
||||
@@ -27,7 +28,10 @@ interface ToolCallInput {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Agent prefix used to attribute output when parallel agents interleave on one stream. */
|
||||
// Agent prefix used to attribute output when parallel agents interleave on one stream. Tries the
|
||||
// registered agent's exact display name first, then falls back to a keyword match against the raw
|
||||
// description, so a caller passing an ad hoc description string still gets a reasonable tag
|
||||
// instead of the generic one.
|
||||
export function getAgentPrefix(description: string): string {
|
||||
const agentPrefixes: Record<string, string> = {
|
||||
'injection-vuln': '[Injection]',
|
||||
@@ -68,7 +72,9 @@ function extractDomain(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a playwright-cli command (run via the bash tool) into a clean progress indicator. */
|
||||
// Browser automation goes through the bash tool as a playwright-cli invocation, not a dedicated
|
||||
// tool call, so there is no structured event to read the action from. This parses the command line
|
||||
// back into a friendly one-liner instead of showing the raw shell command.
|
||||
function formatBrowserAction(command: string): string | null {
|
||||
const match = command.match(/playwright-cli\s+(?:-s=\S+\s+)?(\S+)(?:\s+(.*))?/);
|
||||
if (!match) return null;
|
||||
@@ -139,7 +145,9 @@ function formatBrowserAction(command: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Summarize a todo_write update into a clean progress indicator. */
|
||||
// todo_write replaces the whole list on every call, so there is no single "changed item" to
|
||||
// report. Surface the most recently completed item if one exists, otherwise the item now in
|
||||
// progress; a list with neither (all pending, or empty) has nothing worth printing.
|
||||
function summarizeTodoUpdate(input: ToolCallInput | undefined): string | null {
|
||||
if (!input?.todos || !Array.isArray(input.todos)) {
|
||||
return null;
|
||||
@@ -159,6 +167,15 @@ function summarizeTodoUpdate(input: ToolCallInput | undefined): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a phase's console output style from its human-readable description.
|
||||
*
|
||||
* `isParallelExecution` marks the five concurrent vuln/exploit agents, whose output
|
||||
* interleaves on one stream and so needs a per-line agent tag; `useCleanOutput` marks
|
||||
* every phase that gets the friendly spinner-and-summary treatment instead of the
|
||||
* verbose turn-by-turn fallback. Matching is on substrings of `description`, the same
|
||||
* strings the activity layer passes as the human-facing phase label.
|
||||
*/
|
||||
export function detectExecutionContext(description: string): ExecutionContext {
|
||||
const isParallelExecution = description.includes('vuln agent') || description.includes('exploit agent');
|
||||
|
||||
@@ -236,36 +253,28 @@ export function formatToolCall(
|
||||
}
|
||||
|
||||
export function formatErrorOutput(
|
||||
error: Error & { code?: string; status?: number },
|
||||
error: SafeErrorDetails,
|
||||
context: ExecutionContext,
|
||||
description: string,
|
||||
duration: number,
|
||||
sourceDir: string,
|
||||
turns: number,
|
||||
isRetryable: boolean,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (context.isParallelExecution) {
|
||||
lines.push(`${getAgentPrefix(description)} Failed (${formatDuration(duration)})`);
|
||||
lines.push(`Agent failed (${formatDuration(duration)})`);
|
||||
} else if (context.useCleanOutput) {
|
||||
lines.push(`${context.agentType} failed (${formatDuration(duration)})`);
|
||||
} else {
|
||||
lines.push(` pi agent failed: ${description} (${formatDuration(duration)})`);
|
||||
lines.push(` Agent failed (${formatDuration(duration)})`);
|
||||
}
|
||||
|
||||
lines.push(` Error Type: ${error.constructor.name}`);
|
||||
lines.push(` Error Code: ${error.code}`);
|
||||
lines.push(` Category: ${error.category}`);
|
||||
lines.push(` Message: ${error.message}`);
|
||||
lines.push(` Agent: ${description}`);
|
||||
lines.push(` Working Directory: ${sourceDir}`);
|
||||
lines.push(` Turns: ${turns}`);
|
||||
lines.push(` Retryable: ${isRetryable ? 'Yes' : 'No'}`);
|
||||
|
||||
if (error.code) {
|
||||
lines.push(` Error Code: ${error.code}`);
|
||||
}
|
||||
if (error.status) {
|
||||
lines.push(` HTTP Status: ${error.status}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import type { TSchema } from 'typebox';
|
||||
import { Value } from 'typebox/value';
|
||||
import { captureToolInvocation, decideToolOutcome } from '../../audit/trace.js';
|
||||
import type { ProviderFailureCategory } from '../../types/errors.js';
|
||||
import { type ModelHost, modelHost } from '../model-host.js';
|
||||
import type { ModelSelection } from '../models.js';
|
||||
@@ -433,13 +434,30 @@ class StandaloneCapellaAgentExecutor implements CapellaAgentExecutor {
|
||||
|
||||
let invalidSubmission = false;
|
||||
let pendingProviderError: unknown;
|
||||
// Per-session trace correlation lives here in the executor; the injected sink is a
|
||||
// stateless emitter, safe to share across the stage's sessions.
|
||||
const traceLog = request.log;
|
||||
const pendingTrace = new Map<string, { readonly tool: string; readonly startedAt: number }>();
|
||||
unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
||||
if (event.type === 'tool_execution_start') {
|
||||
operationCount += 1;
|
||||
if (traceLog !== undefined) {
|
||||
const invocation = captureToolInvocation(event.toolName, event.args);
|
||||
pendingTrace.set(event.toolCallId, { tool: event.toolName, startedAt: Date.now() });
|
||||
if (invocation !== undefined) traceLog.toolCall(invocation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === 'tool_execution_end') {
|
||||
if (event.toolName === 'submit_result' && event.isError) invalidSubmission = true;
|
||||
if (traceLog !== undefined) {
|
||||
const pending = pendingTrace.get(event.toolCallId);
|
||||
if (pending !== undefined) {
|
||||
pendingTrace.delete(event.toolCallId);
|
||||
const outcome = decideToolOutcome(pending.tool, event.isError, Date.now() - pending.startedAt, undefined);
|
||||
if (outcome !== undefined) traceLog.toolOutcome(outcome);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type !== 'turn_end') return;
|
||||
@@ -455,6 +473,7 @@ class StandaloneCapellaAgentExecutor implements CapellaAgentExecutor {
|
||||
}
|
||||
});
|
||||
|
||||
const runStartedAt = Date.now();
|
||||
let promptError: unknown;
|
||||
try {
|
||||
await raceWithAbort(session.prompt(request.userPrompt, { expandPromptTemplates: false }), controller.signal);
|
||||
@@ -472,6 +491,12 @@ class StandaloneCapellaAgentExecutor implements CapellaAgentExecutor {
|
||||
};
|
||||
const output = this.resolveOutcome<T>(request, outcome, termination, selection.model.contextWindow);
|
||||
|
||||
// Emitted only past resolveOutcome so a failed, cancelled, timed-out, or turn-capped
|
||||
// session (all of which throw above) never reports a truthful-looking completion.
|
||||
if (traceLog !== undefined) {
|
||||
traceLog.sessionComplete(Date.now() - runStartedAt, turnCount, operationCount);
|
||||
}
|
||||
|
||||
return { output, usage: outcome.usage };
|
||||
} catch (error) {
|
||||
const surfacedError = normalizeRunFailure(error, termination, request.signal, this.host);
|
||||
|
||||
@@ -6,12 +6,34 @@
|
||||
|
||||
import type { ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import type { TSchema } from 'typebox';
|
||||
import type { ToolInvocation, ToolOutcome } from '../../audit/trace.js';
|
||||
import type { ModelRole } from '../model-host.js';
|
||||
import type { CapellaStage, CapellaUsage } from '../sast/types.js';
|
||||
|
||||
/** A Capella-owned collector or repository tool installed in one confined session. */
|
||||
export type CapellaTool = ToolDefinition;
|
||||
|
||||
/**
|
||||
* A sink for one Capella session's technical trace. The executor owns `toolCallId`
|
||||
* correlation and synchronously snapshots complete tool arguments before handing the
|
||||
* immutable invocation to the sink.
|
||||
*/
|
||||
export interface CapellaTraceLog {
|
||||
toolCall(invocation: ToolInvocation): void;
|
||||
toolOutcome(outcome: ToolOutcome): void;
|
||||
sessionComplete(durationMs: number, turns: number, operations: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One stage's trace surface. `forSession` binds a per-session view (its label becomes the trace
|
||||
* prefix's session component); all views share one serialized queue that `drain` awaits, so no
|
||||
* session's lines can still be buffered when its activity returns.
|
||||
*/
|
||||
export interface CapellaStageTrace {
|
||||
forSession(sessionLabel: string | undefined): CapellaTraceLog;
|
||||
drain(): Promise<void>;
|
||||
}
|
||||
|
||||
/** One bounded multi-turn Capella model session. */
|
||||
export interface CapellaAgentRequest<_T> {
|
||||
readonly stage: CapellaStage;
|
||||
@@ -24,6 +46,12 @@ export interface CapellaAgentRequest<_T> {
|
||||
readonly tools: readonly CapellaTool[];
|
||||
readonly outputSchema?: TSchema;
|
||||
readonly signal: AbortSignal;
|
||||
readonly log?: CapellaTraceLog;
|
||||
/**
|
||||
* Display-only session name for the trace prefix. Never hashed into `workloadId`, a checkpoint
|
||||
* key, a usage record, or a prompt; a stage may repeat or omit it without changing execution.
|
||||
*/
|
||||
readonly sessionLabel?: string;
|
||||
}
|
||||
|
||||
/** Schema-valid output and measured usage from one completed Capella session. */
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
// Production agent execution on the pi harness, with git checkpoints and audit logging.
|
||||
// The checkpoint itself is created by the caller (AgentExecutionService) before and after
|
||||
// runPiPrompt runs; this module owns the session, its audit/error logging, and the trace it
|
||||
// produces, not the git commit around it.
|
||||
|
||||
import os from 'node:os';
|
||||
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { fs, path } from 'zx';
|
||||
import type { AuditSession } from '../../audit/index.js';
|
||||
import { isLoggableAgentName, type SafeErrorDetails, safeErrorFromUnknown } from '../../audit/safe-fields.js';
|
||||
import { BASH_TIMEOUT_EXTENSION_DIR, deliverablesDir } from '../../paths.js';
|
||||
import { isRetryableFailure, PentestError } from '../../services/error-handling.js';
|
||||
import { AGENT_VALIDATORS } from '../../session-manager.js';
|
||||
@@ -44,6 +48,7 @@ import { permissionSystemConfigExists, permissionSystemPackageDir } from './perm
|
||||
import { PI_RETRY_SETTINGS } from './retry-settings.js';
|
||||
import { createGlobTool, createTodoWriteTool } from './session-tools.js';
|
||||
import { createTaskTool } from './task-tool.js';
|
||||
import { TraceEmitter } from './trace-emitter.js';
|
||||
import { providerTurnError } from './turn-error.js';
|
||||
|
||||
declare global {
|
||||
@@ -142,7 +147,6 @@ export interface PiPromptResult {
|
||||
model?: string | undefined;
|
||||
error?: string | undefined;
|
||||
errorType?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
retryable?: boolean | undefined;
|
||||
structuredOutput?: unknown;
|
||||
}
|
||||
@@ -154,18 +158,20 @@ function outputLines(lines: string[]): void {
|
||||
}
|
||||
|
||||
async function writeErrorLog(
|
||||
err: Error & { code?: string; status?: number },
|
||||
sourceDir: string,
|
||||
fullPrompt: string,
|
||||
error: SafeErrorDetails,
|
||||
duration: number,
|
||||
turns: number,
|
||||
retryable: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const errorLog = {
|
||||
timestamp: formatTimestamp(),
|
||||
agent: 'pi-executor',
|
||||
error: { name: err.constructor.name, message: err.message, code: err.code, status: err.status, stack: err.stack },
|
||||
context: { sourceDir, prompt: `${fullPrompt.slice(0, 200)}...`, retryable: isRetryableFailure(err) },
|
||||
error: { code: error.code, category: error.category, message: error.message },
|
||||
duration,
|
||||
turns,
|
||||
retryable,
|
||||
};
|
||||
const logPath = path.join(deliverablesDir(sourceDir), 'error.log');
|
||||
await fs.appendFile(logPath, `${JSON.stringify(errorLog)}\n`);
|
||||
@@ -186,6 +192,9 @@ export async function validateAgentOutput(
|
||||
logger.error('Validation failed: Agent execution was unsuccessful');
|
||||
return false;
|
||||
}
|
||||
// Not every agent has a deliverable-structure validator registered. Absence is not treated as
|
||||
// a failure: the agent already reported success above, so an agent with no validator passes on
|
||||
// that alone rather than being held to a check that was never defined for it.
|
||||
const validator = agentName ? AGENT_VALIDATORS[agentName as keyof typeof AGENT_VALIDATORS] : undefined;
|
||||
if (!validator) {
|
||||
logger.warn(`No validator found for agent "${agentName}" - assuming success`);
|
||||
@@ -230,6 +239,7 @@ export async function runPiPrompt(
|
||||
deliverablesSubdir?: string,
|
||||
cancellationSignal?: AbortSignal,
|
||||
submitTool?: CapturedSubmitTool,
|
||||
attemptNumber: number = 1,
|
||||
): Promise<PiPromptResult> {
|
||||
// 1. Initialize timing and prompt. A submit tool appends its directive so the
|
||||
// instruction to call it lives with the tool, not in every prompt file.
|
||||
@@ -243,7 +253,7 @@ export async function runPiPrompt(
|
||||
{ description, useCleanOutput: execContext.useCleanOutput },
|
||||
global.SHANNON_DISABLE_LOADER ?? false,
|
||||
);
|
||||
const auditLogger = createAuditLogger(auditSession);
|
||||
const auditLogger = createAuditLogger(auditSession, agentName, attemptNumber);
|
||||
|
||||
logger.info(`Running pi agent: ${description}...`);
|
||||
|
||||
@@ -259,6 +269,14 @@ export async function runPiPrompt(
|
||||
// plus any caller-supplied collector/submit tools).
|
||||
const selection = await resolveModelSelection();
|
||||
const resourceLoader = await buildResourceLoader(sourceDir, logger, agentName);
|
||||
const agentNameCandidate = agentName ?? '';
|
||||
const parentAgentName = isLoggableAgentName(agentNameCandidate) ? agentNameCandidate : 'pre-recon';
|
||||
// The durable trace log is path-addressed, so parent, child, and Capella writers all
|
||||
// reach the same file without sharing a stream handle.
|
||||
const workflowLogPath = auditSession?.workflowLogPath;
|
||||
const traceEmitter = workflowLogPath
|
||||
? new TraceEmitter(workflowLogPath, { kind: 'agent', agent: parentAgentName })
|
||||
: undefined;
|
||||
// Accumulates usage from in-process `task` child sessions so the parent's reported
|
||||
// cost includes sub-agent spend (their getSessionStats is separate from ours).
|
||||
const childUsage: ChildUsage = { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
||||
@@ -267,6 +285,11 @@ export async function runPiPrompt(
|
||||
model: selection.model,
|
||||
modelRuntime: selection.modelRuntime,
|
||||
cwd: sourceDir,
|
||||
parentAgentName,
|
||||
...(workflowLogPath !== undefined && { workflowLogPath }),
|
||||
...(traceEmitter !== undefined && {
|
||||
onDelegationStart: (child: string) => traceEmitter.delegationStart(child),
|
||||
}),
|
||||
onUsage: (usage) => {
|
||||
childUsage.cost += usage.cost;
|
||||
childUsage.inputTokens += usage.inputTokens;
|
||||
@@ -277,7 +300,7 @@ export async function runPiPrompt(
|
||||
resourceLoader,
|
||||
...(cancellationSignal && { cancellationSignal }),
|
||||
}),
|
||||
createTodoWriteTool(auditLogger),
|
||||
createTodoWriteTool(),
|
||||
createGlobTool(sourceDir),
|
||||
...(callerTools ?? []),
|
||||
...(submitTool ? [submitTool.tool] : []),
|
||||
@@ -330,7 +353,6 @@ export async function runPiPrompt(
|
||||
const msg = event.message;
|
||||
const text = extractAssistantText(msg);
|
||||
if (text.trim()) {
|
||||
void auditLogger.logLlmResponse(turnCount, text);
|
||||
progress.stop();
|
||||
outputLines(formatAssistantOutput(text, execContext, turnCount, description));
|
||||
progress.start();
|
||||
@@ -341,7 +363,8 @@ export async function runPiPrompt(
|
||||
break;
|
||||
}
|
||||
case 'tool_execution_start': {
|
||||
void auditLogger.logToolStart(event.toolName, event.args);
|
||||
const count = submitTool?.tool.name === event.toolName ? submitTool.safeCount : undefined;
|
||||
traceEmitter?.toolStart(event.toolCallId, event.toolName, event.args, count);
|
||||
const toolLines = formatToolCall(
|
||||
event.toolName,
|
||||
event.args as Record<string, unknown>,
|
||||
@@ -355,9 +378,10 @@ export async function runPiPrompt(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_execution_end':
|
||||
void auditLogger.logToolEnd(event.result);
|
||||
case 'tool_execution_end': {
|
||||
traceEmitter?.toolEnd(event.toolCallId, event.isError);
|
||||
break;
|
||||
}
|
||||
case 'compaction_end':
|
||||
if (!event.aborted && !event.willRetry && event.errorMessage) {
|
||||
pendingError =
|
||||
@@ -387,6 +411,8 @@ export async function runPiPrompt(
|
||||
// Capture the submit tool's structured payload so callers read it off the
|
||||
// result instead of holding a reference to the tool.
|
||||
const structuredOutput = submitTool?.getCaptured();
|
||||
await auditLogger.flush();
|
||||
await traceEmitter?.flush();
|
||||
|
||||
return {
|
||||
result,
|
||||
@@ -402,13 +428,17 @@ export async function runPiPrompt(
|
||||
...(structuredOutput !== undefined && { structuredOutput }),
|
||||
};
|
||||
} catch (error) {
|
||||
// 10. Handle errors — log, write error file, return failure
|
||||
// 9. Handle errors: log, write error file, return failure
|
||||
const duration = timer.stop();
|
||||
const err = error as Error & { code?: string; status?: number };
|
||||
await auditLogger.logError(err, duration, turnCount);
|
||||
const safeError = safeErrorFromUnknown(err);
|
||||
const retryable = isRetryableFailure(err);
|
||||
await auditLogger.logError(safeError, duration, turnCount);
|
||||
await auditLogger.flush();
|
||||
await traceEmitter?.flush();
|
||||
progress.stop();
|
||||
outputLines(formatErrorOutput(err, execContext, description, duration, sourceDir, isRetryableFailure(err)));
|
||||
await writeErrorLog(err, sourceDir, fullPrompt, duration);
|
||||
outputLines(formatErrorOutput(safeError, execContext, duration, turnCount, retryable));
|
||||
await writeErrorLog(sourceDir, safeError, duration, turnCount, retryable);
|
||||
|
||||
// A failed agent still spent money — on its own turns and, since Shannon's
|
||||
// prompts delegate the heavy work, mostly on `task` sub-agents. Both count
|
||||
@@ -416,9 +446,8 @@ export async function runPiPrompt(
|
||||
const usage = totalUsage(session, childUsage);
|
||||
|
||||
return {
|
||||
error: err.message,
|
||||
errorType: err instanceof PentestError && err.code ? err.code : err.constructor.name,
|
||||
prompt: `${fullPrompt.slice(0, 100)}...`,
|
||||
error: safeError.message,
|
||||
errorType: safeError.code,
|
||||
success: false,
|
||||
duration,
|
||||
turns: turnCount,
|
||||
@@ -427,7 +456,7 @@ export async function runPiPrompt(
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
cacheWriteTokens: usage.cacheWriteTokens,
|
||||
retryable: isRetryableFailure(err),
|
||||
retryable,
|
||||
};
|
||||
} finally {
|
||||
cancellationSignal?.removeEventListener('abort', onCancellation);
|
||||
|
||||
@@ -8,32 +8,21 @@
|
||||
* Per-session custom tools registered for every agent: `todo_write` and `glob`.
|
||||
*
|
||||
* These replace harness built-ins that pi does not ship. `todo_write` is a
|
||||
* full-state-replace planning scratchpad mirrored to the workflow log; `glob` is
|
||||
* fast-glob file matching (pi has no `Glob` built-in).
|
||||
* full-state-replace planning scratchpad; `glob` is fast-glob file matching
|
||||
* (pi has no `Glob` built-in).
|
||||
*/
|
||||
|
||||
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import { fs, glob, path } from 'zx';
|
||||
|
||||
import type { AuditLogger } from '../audit-logger.js';
|
||||
|
||||
export interface TodoItem {
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
function renderTodos(todos: readonly TodoItem[]): string {
|
||||
const mark = (status: TodoItem['status']): string => {
|
||||
if (status === 'completed') return 'x';
|
||||
if (status === 'in_progress') return '~';
|
||||
return ' ';
|
||||
};
|
||||
return todos.map((todo) => `[${mark(todo.status)}] ${todo.content}`).join(' ');
|
||||
}
|
||||
|
||||
export function createTodoWriteTool(auditLogger: AuditLogger): ToolDefinition {
|
||||
export function createTodoWriteTool(): ToolDefinition {
|
||||
let current: TodoItem[] = [];
|
||||
|
||||
return defineTool({
|
||||
@@ -56,7 +45,6 @@ export function createTodoWriteTool(auditLogger: AuditLogger): ToolDefinition {
|
||||
async execute(_toolCallId, params) {
|
||||
current = params.todos as TodoItem[];
|
||||
const completed = current.filter((todo) => todo.status === 'completed').length;
|
||||
await auditLogger.logNote('todo', renderTodos(current));
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
||||
@@ -4,17 +4,7 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Generic `task` tool — pi.dev ships no built-in Task tool, so this supplies the
|
||||
* Task-delegation surface Shannon's prompts require.
|
||||
*
|
||||
* Shannon's prompts mandate Task delegation (recon source tracer; the vuln
|
||||
* agents delegate *every* code review; the exploit agents delegate automation),
|
||||
* so this tool is required for parity, not optional. It spawns a nested pi
|
||||
* session with the parent's resolved model object (never a tier string — that
|
||||
* would route sub-agents through hardcoded IDs and leak billing), the parent's
|
||||
* resource loader, and a fixed child tool surface.
|
||||
*/
|
||||
/** Generic child-session delegation for the pi harness. */
|
||||
|
||||
import { type AssistantMessage, type Model, Type } from '@earendil-works/pi-ai';
|
||||
import {
|
||||
@@ -27,39 +17,70 @@ import {
|
||||
SettingsManager,
|
||||
type ToolDefinition,
|
||||
} from '@earendil-works/pi-coding-agent';
|
||||
import { type LoggableAgentName, normalizeSemanticLabel } from '../../audit/safe-fields.js';
|
||||
import { PI_RETRY_SETTINGS } from './retry-settings.js';
|
||||
import { TraceEmitter } from './trace-emitter.js';
|
||||
|
||||
export interface TaskToolContext {
|
||||
cwd: string;
|
||||
readonly cwd: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: Model<any>;
|
||||
/** Parent's model/auth runtime, reused so sub-agents share its resolved credential. */
|
||||
modelRuntime: ModelRuntime;
|
||||
resourceLoader: ResourceLoader;
|
||||
cancellationSignal?: AbortSignal | undefined;
|
||||
/**
|
||||
* Reports the cost/tokens of each spawned sub-session back to the caller.
|
||||
* Sub-agents run in their own pi sessions that the parent has no reference to,
|
||||
* so without this their spend (the bulk of a whitebox run, since Shannon
|
||||
* prompts delegate the heavy work) is invisible to billing.
|
||||
*/
|
||||
onUsage?: (usage: {
|
||||
cost: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
readonly model: Model<any>;
|
||||
readonly modelRuntime: ModelRuntime;
|
||||
readonly resourceLoader: ResourceLoader;
|
||||
readonly parentAgentName: LoggableAgentName;
|
||||
readonly workflowLogPath?: string | undefined;
|
||||
readonly onDelegationStart?: ((child: string) => Promise<void>) | undefined;
|
||||
readonly cancellationSignal?: AbortSignal | undefined;
|
||||
readonly onUsage?: (usage: {
|
||||
readonly cost: number;
|
||||
readonly inputTokens: number;
|
||||
readonly outputTokens: number;
|
||||
readonly cacheReadTokens: number;
|
||||
readonly cacheWriteTokens: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// Deliberately excludes `task` (no recursive delegation, so a child cannot spawn further children)
|
||||
// and every collector/submit tool (structured output stays owned by the top-level agent session
|
||||
// that the workflow reads back). A child session gets only plain file and shell access.
|
||||
const CHILD_TOOLS = ['read', 'grep', 'find', 'ls', 'write', 'bash'];
|
||||
const CHILD_FAILURE_TEXT = '[Sub-agent task failed before completion]';
|
||||
const CHILD_CANCELLED_TEXT = '[Sub-agent task was cancelled]';
|
||||
|
||||
function textResult(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }], details: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns each child a stable, safe display identity from its description. A duplicate of a
|
||||
* live sibling's name gets a monotonic start-order suffix (`route mapper #2`); a missing or
|
||||
* unsafe description becomes `subagent N`. State is shared across one parent's task calls,
|
||||
* and the assignment block runs synchronously so parallel calls never race on it.
|
||||
*/
|
||||
// Keep the base short enough that a `#N` suffix still fits the identity validator's length
|
||||
// bound (48); a longer description falls back to `subagent N` rather than being dropped.
|
||||
const MAX_CHILD_BASE_LENGTH = 40;
|
||||
|
||||
function createChildNamer(): (description: unknown) => string {
|
||||
const namedCounts = new Map<string, number>();
|
||||
let anonymousCount = 0;
|
||||
return (description) => {
|
||||
const base = normalizeSemanticLabel(description);
|
||||
if (base === undefined || base.length > MAX_CHILD_BASE_LENGTH) {
|
||||
anonymousCount += 1;
|
||||
return `subagent ${anonymousCount}`;
|
||||
}
|
||||
const nextOrdinal = (namedCounts.get(base) ?? 0) + 1;
|
||||
namedCounts.set(base, nextOrdinal);
|
||||
return nextOrdinal === 1 ? base : `${base} #${nextOrdinal}`;
|
||||
};
|
||||
}
|
||||
|
||||
export function createTaskTool(config: TaskToolContext): ToolDefinition {
|
||||
const taskTool: ToolDefinition = defineTool({
|
||||
const nameChild = createChildNamer();
|
||||
const logPath = config.workflowLogPath;
|
||||
|
||||
return defineTool({
|
||||
name: 'task',
|
||||
label: 'Task',
|
||||
description:
|
||||
@@ -80,59 +101,82 @@ export function createTaskTool(config: TaskToolContext): ToolDefinition {
|
||||
description: Type.Optional(Type.String({ description: 'A short (3-5 word) description of the task.' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
// Assign the identity synchronously, before any await, so concurrent siblings can't race.
|
||||
const child = nameChild(params.description);
|
||||
const emitter = logPath
|
||||
? new TraceEmitter(logPath, { kind: 'child', parent: config.parentAgentName, child })
|
||||
: undefined;
|
||||
const startedAt = Date.now();
|
||||
|
||||
// The parent's emitter first writes the raw task invocation, then this delegation
|
||||
// record. Awaiting it prevents the child emitter from overtaking its lineage start.
|
||||
await config.onDelegationStart?.(child);
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const { session: subSession } = await createAgentSession({
|
||||
cwd: config.cwd,
|
||||
agentDir,
|
||||
resourceLoader: config.resourceLoader,
|
||||
model: config.model,
|
||||
tools: CHILD_TOOLS,
|
||||
modelRuntime: config.modelRuntime,
|
||||
sessionManager: SessionManager.inMemory(config.cwd),
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
retry: PI_RETRY_SETTINGS,
|
||||
compaction: { enabled: true },
|
||||
}),
|
||||
});
|
||||
let subSession: Awaited<ReturnType<typeof createAgentSession>>['session'] | undefined;
|
||||
let resultText = '';
|
||||
let subCost = 0;
|
||||
let turns = 0;
|
||||
let operations = 0;
|
||||
let failed = false;
|
||||
let fatalFailure = false;
|
||||
|
||||
const abortChildSession = (): void => {
|
||||
void subSession.abort().catch(() => {
|
||||
// Parent logger is not available inside the tool; dispose still tears
|
||||
// down the session if abort itself rejects.
|
||||
void subSession?.abort().catch(() => {
|
||||
// Dispose below still tears down the child session.
|
||||
});
|
||||
};
|
||||
const onCancellation = (): void => abortChildSession();
|
||||
if (config.cancellationSignal?.aborted) {
|
||||
abortChildSession();
|
||||
} else {
|
||||
config.cancellationSignal?.addEventListener('abort', onCancellation, { once: true });
|
||||
}
|
||||
|
||||
let resultText = '';
|
||||
let subCost = 0;
|
||||
subSession.subscribe((event) => {
|
||||
if (event.type === 'turn_end') {
|
||||
const msg = event.message as AssistantMessage | undefined;
|
||||
for (const block of msg?.content ?? []) {
|
||||
try {
|
||||
({ session: subSession } = await createAgentSession({
|
||||
cwd: config.cwd,
|
||||
agentDir,
|
||||
resourceLoader: config.resourceLoader,
|
||||
model: config.model,
|
||||
tools: CHILD_TOOLS,
|
||||
modelRuntime: config.modelRuntime,
|
||||
sessionManager: SessionManager.inMemory(config.cwd),
|
||||
settingsManager: SettingsManager.inMemory({
|
||||
retry: PI_RETRY_SETTINGS,
|
||||
compaction: { enabled: true },
|
||||
}),
|
||||
}));
|
||||
|
||||
if (config.cancellationSignal?.aborted) {
|
||||
abortChildSession();
|
||||
} else {
|
||||
config.cancellationSignal?.addEventListener('abort', onCancellation, { once: true });
|
||||
}
|
||||
|
||||
subSession.subscribe((event) => {
|
||||
if (event.type === 'tool_execution_start') {
|
||||
operations += 1;
|
||||
emitter?.toolStart(event.toolCallId, event.toolName, event.args);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'tool_execution_end') {
|
||||
emitter?.toolEnd(event.toolCallId, event.isError);
|
||||
return;
|
||||
}
|
||||
if (event.type !== 'turn_end') return;
|
||||
turns += 1;
|
||||
const message = event.message as AssistantMessage | undefined;
|
||||
for (const block of message?.content ?? []) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
resultText += (resultText ? '\n' : '') + block.text;
|
||||
}
|
||||
}
|
||||
if (msg?.usage?.cost?.total != null) subCost += msg.usage.cost.total;
|
||||
}
|
||||
});
|
||||
if (message?.usage?.cost?.total != null) subCost += message.usage.cost.total;
|
||||
});
|
||||
|
||||
let swallowedError: string | undefined;
|
||||
try {
|
||||
try {
|
||||
await subSession.prompt(params.prompt);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
resultText += `\n[Sub-agent error: ${errorMsg}]`;
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
if (subSession.state.errorMessage !== undefined) failed = true;
|
||||
|
||||
swallowedError = subSession.state.errorMessage;
|
||||
// Read stats before dispose; reconcile cost the same way the parent does.
|
||||
const subStats = subSession.getSessionStats();
|
||||
if (subStats.cost > subCost) subCost = subStats.cost;
|
||||
config.onUsage?.({
|
||||
@@ -142,18 +186,37 @@ export function createTaskTool(config: TaskToolContext): ToolDefinition {
|
||||
cacheReadTokens: subStats.tokens.cacheRead,
|
||||
cacheWriteTokens: subStats.tokens.cacheWrite,
|
||||
});
|
||||
} catch {
|
||||
fatalFailure = true;
|
||||
} finally {
|
||||
config.cancellationSignal?.removeEventListener('abort', onCancellation);
|
||||
subSession.dispose();
|
||||
subSession?.dispose();
|
||||
}
|
||||
|
||||
if (swallowedError && !resultText.includes(swallowedError)) {
|
||||
resultText += `\n[Sub-agent error: ${swallowedError}]`;
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (config.cancellationSignal?.aborted) {
|
||||
emitter?.sessionFailure('CANCELLED', durationMs);
|
||||
await emitter?.flush();
|
||||
return textResult(CHILD_CANCELLED_TEXT);
|
||||
}
|
||||
// `fatalFailure` means the child session itself never came up (createAgentSession threw), so
|
||||
// there is no session result to hand back, and this rethrows, which pi surfaces to the parent
|
||||
// as a failed tool call. `failed` means the session ran but ended in error; that gets a normal
|
||||
// text result instead, so the parent model sees the failure and can decide how to proceed.
|
||||
if (fatalFailure) {
|
||||
emitter?.sessionFailure('CHILD_TASK_FAILED', durationMs);
|
||||
await emitter?.flush();
|
||||
throw new Error(CHILD_FAILURE_TEXT);
|
||||
}
|
||||
if (failed) {
|
||||
emitter?.sessionFailure('CHILD_TASK_FAILED', durationMs);
|
||||
await emitter?.flush();
|
||||
return textResult(CHILD_FAILURE_TEXT);
|
||||
}
|
||||
|
||||
emitter?.sessionComplete(durationMs, turns, operations);
|
||||
await emitter?.flush();
|
||||
return textResult(resultText || '[Sub-agent produced no output]');
|
||||
},
|
||||
});
|
||||
|
||||
return taskTool;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (C) 2026 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Per-session trace emitter. Owns the PI `toolCallId` correlation and the ordering
|
||||
* of one agent or subagent's trace lines, then writes them through the stateless
|
||||
* `WorkflowLogger` formatter. One instance per parent agent run or per delegated
|
||||
* child session, so parallel calls never cross.
|
||||
*/
|
||||
|
||||
import { captureToolInvocation, decideToolOutcome } from '../../audit/trace.js';
|
||||
import { type ChildTaskFailureCode, type TraceActor, WorkflowLogger } from '../../audit/workflow-logger.js';
|
||||
|
||||
interface PendingCall {
|
||||
readonly tool: string;
|
||||
readonly startedAt: number;
|
||||
readonly count?: (() => number | undefined) | undefined;
|
||||
}
|
||||
|
||||
export class TraceEmitter {
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private readonly pending = new Map<string, PendingCall>();
|
||||
|
||||
constructor(
|
||||
private readonly logPath: string,
|
||||
private readonly actor: TraceActor,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Snapshot and log a tool call's complete arguments. `count`, when supplied, is an
|
||||
* accessor for that specific collector's existing submitted-array count outcome.
|
||||
*/
|
||||
toolStart(toolCallId: string, toolName: string, args: unknown, count?: () => number | undefined): void {
|
||||
const invocation = captureToolInvocation(toolName, args);
|
||||
this.pending.set(toolCallId, { tool: toolName, startedAt: this.now(), count });
|
||||
if (invocation !== undefined) this.enqueue(() => WorkflowLogger.logToolCall(this.logPath, this.actor, invocation));
|
||||
}
|
||||
|
||||
toolEnd(toolCallId: string, isError: boolean): void {
|
||||
const call = this.pending.get(toolCallId);
|
||||
if (call === undefined) return;
|
||||
this.pending.delete(toolCallId);
|
||||
const outcome = decideToolOutcome(call.tool, isError, this.now() - call.startedAt, call.count?.());
|
||||
if (outcome !== undefined) this.enqueue(() => WorkflowLogger.logToolOutcome(this.logPath, this.actor, outcome));
|
||||
}
|
||||
|
||||
/** Queue and await delegation on the parent emitter before a child session can start. */
|
||||
delegationStart(child: string): Promise<void> {
|
||||
const actor = this.actor;
|
||||
if (actor.kind !== 'agent') return Promise.resolve();
|
||||
return this.enqueue(() => WorkflowLogger.logDelegationStart(this.logPath, actor.agent, child));
|
||||
}
|
||||
|
||||
sessionComplete(durationMs: number, turns: number, operations: number): void {
|
||||
this.enqueue(() => WorkflowLogger.logSessionComplete(this.logPath, this.actor, durationMs, turns, operations));
|
||||
}
|
||||
|
||||
sessionFailure(code: ChildTaskFailureCode, durationMs: number): void {
|
||||
this.enqueue(() => WorkflowLogger.logSessionFailure(this.logPath, this.actor, code, durationMs));
|
||||
}
|
||||
|
||||
// Chained regardless of outcome (`then(operation, operation)`) so one write's rejection cannot
|
||||
// stall the ones queued after it, and the trailing catch swallows the failure entirely: a trace
|
||||
// line is diagnostic only, so losing one must never surface as, or block, the agent's own result.
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
this.queue = this.queue.then(operation, operation).catch(() => undefined);
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
/** Await all queued writes so a caller can order a terminal line after them. */
|
||||
async flush(): Promise<void> {
|
||||
await this.queue;
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,10 @@ export function createQueueSubmitTool(agentName: AgentName, exploit = true): Cap
|
||||
},
|
||||
}),
|
||||
getCaptured: () => captured,
|
||||
safeCount: () => {
|
||||
const vulnerabilities = (captured as { vulnerabilities?: unknown } | undefined)?.vulnerabilities;
|
||||
return Array.isArray(vulnerabilities) ? vulnerabilities.length : undefined;
|
||||
},
|
||||
directive:
|
||||
'\n\nYou MUST call the submit_exploitation_queue tool exactly once as your final action ' +
|
||||
'to deliver your structured exploitation queue. Do not output JSON as text. Fill every required parameter.',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (C) 2026 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/** Display-only session labels for a Capella stage's concurrent sessions. */
|
||||
|
||||
import { normalizeSemanticLabel } from '../../../audit/safe-fields.js';
|
||||
|
||||
// Keep the base short enough that a ` #N` suffix still fits the identity validator's 48-char
|
||||
// bound; a longer title falls back to `<fallback> N` rather than producing an unsafe label.
|
||||
const MAX_SESSION_BASE_LENGTH = 40;
|
||||
|
||||
/**
|
||||
* Build a per-stage session labeler. It normalizes a free-text title to a safe display label and
|
||||
* disambiguates same-title siblings with `#2`/`#3`, exactly as the subagent namer does; a title
|
||||
* that cannot be normalized falls back to `<fallback> N`. Call it synchronously at dispatch, before
|
||||
* any await, so concurrent siblings never race on the ordinal. Labels are not stable across a
|
||||
* resume, which is acceptable for a human-facing log.
|
||||
*/
|
||||
export function createCapellaSessionNamer(fallback: string): (title: unknown) => string {
|
||||
const namedCounts = new Map<string, number>();
|
||||
let anonymousCount = 0;
|
||||
return (title) => {
|
||||
const base = normalizeSemanticLabel(title);
|
||||
if (base === undefined || base.length > MAX_SESSION_BASE_LENGTH) {
|
||||
anonymousCount += 1;
|
||||
return `${fallback} ${anonymousCount}`;
|
||||
}
|
||||
const nextOrdinal = (namedCounts.get(base) ?? 0) + 1;
|
||||
namedCounts.set(base, nextOrdinal);
|
||||
return nextOrdinal === 1 ? base : `${base} #${nextOrdinal}`;
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type { CapellaFinding } from '../finding-types.js';
|
||||
import { buildCodePathScopeSnippet, buildResearchAssignment, RESEARCH_TOOLS, TRIAGE_TOOLS } from '../prompt-context.js';
|
||||
import { createCapellaPromptLoader } from '../prompt-loader.js';
|
||||
import { type Investigation, TRIAGE_SCHEMA, type TriageResult } from '../schemas.js';
|
||||
import { createCapellaSessionNamer } from '../session-label.js';
|
||||
import {
|
||||
CAPELLA_AUDIT_CONCURRENCY,
|
||||
CAPELLA_TRIAGE_CONCURRENCY,
|
||||
@@ -325,7 +326,10 @@ export async function runResearchStage(
|
||||
batchId: buildFingerprint({ files }).slice(0, 20),
|
||||
}));
|
||||
|
||||
const triageOutcomes = await runSettledPool(batches, CAPELLA_TRIAGE_CONCURRENCY, async (batch) => {
|
||||
const triageOutcomes = await runSettledPool(batches, CAPELLA_TRIAGE_CONCURRENCY, async (batch, index) => {
|
||||
// The label is display-only; it is derived from the dispatch index and kept out of the batch
|
||||
// and the checkpoint fingerprint, which must stay keyed on the batch content alone.
|
||||
const sessionLabel = `triage ${index + 1}`;
|
||||
const checkpointPath = resolve(input.artifactRoot, 'research', 'triage', `${batch.batchId}.json`);
|
||||
const checkpointFingerprint = buildFingerprint({ researchFingerprint: fingerprint, wave: 'triage', ...batch });
|
||||
const cached = await loadCompletedArtifact(
|
||||
@@ -349,6 +353,7 @@ export async function runResearchStage(
|
||||
tools: runtime.repositoryTools,
|
||||
outputSchema: Type.Unsafe(TRIAGE_SCHEMA),
|
||||
signal: runtime.signal,
|
||||
sessionLabel,
|
||||
});
|
||||
let usage = primaryResponse.usage;
|
||||
const primaryIsValid = isTriageResult(primaryResponse.output);
|
||||
@@ -373,6 +378,7 @@ export async function runResearchStage(
|
||||
tools: runtime.repositoryTools,
|
||||
outputSchema: Type.Unsafe(TRIAGE_SCHEMA),
|
||||
signal: runtime.signal,
|
||||
sessionLabel: `${sessionLabel} repair`,
|
||||
});
|
||||
if (isTriageResult(repairResponse.output)) {
|
||||
const repaired = usableClassifications(missingFiles, repairResponse.output.classifications);
|
||||
@@ -422,7 +428,10 @@ export async function runResearchStage(
|
||||
}))
|
||||
.filter((audit) => audit.flaggedFiles.length > 0);
|
||||
|
||||
const nameAuditSession = createCapellaSessionNamer('audit');
|
||||
const auditOutcomes = await runSettledPool(audits, CAPELLA_AUDIT_CONCURRENCY, async (audit) => {
|
||||
// Assign the label synchronously, before any await, so concurrent siblings cannot race.
|
||||
const sessionLabel = nameAuditSession(audit.investigation.title);
|
||||
const checkpointPath = resolve(input.artifactRoot, 'research', 'audit', `${audit.investigationId}.json`);
|
||||
const checkpointFingerprint = buildFingerprint({
|
||||
researchFingerprint: fingerprint,
|
||||
@@ -460,6 +469,7 @@ export async function runResearchStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel,
|
||||
}),
|
||||
() => collector.getFindings().length,
|
||||
);
|
||||
|
||||
@@ -203,6 +203,7 @@ export async function runDedupeStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getDuplicates().length,
|
||||
);
|
||||
@@ -260,6 +261,7 @@ export async function runReviewStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
@@ -279,6 +281,7 @@ export async function runReviewStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'repair',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
@@ -363,6 +366,7 @@ export async function runCriticStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
@@ -388,6 +392,7 @@ export async function runCriticStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'repair',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
@@ -456,6 +461,7 @@ export async function runConfirmStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
@@ -475,6 +481,7 @@ export async function runConfirmStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'repair',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
@@ -552,6 +559,7 @@ export async function runCalibrateStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'primary',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
@@ -577,6 +585,7 @@ export async function runCalibrateStage(
|
||||
timeoutMs: input.timeoutMs,
|
||||
tools: [...runtime.repositoryTools, ...collector.tools],
|
||||
signal: runtime.signal,
|
||||
sessionLabel: 'repair',
|
||||
}),
|
||||
() => collector.getAcceptedIds().length,
|
||||
);
|
||||
|
||||
@@ -9,11 +9,15 @@ import type { Dirent } from 'node:fs';
|
||||
import { mkdir, open, readdir, readFile, realpath } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { ApplicationFailure, CancelledFailure, Context, heartbeat } from '@temporalio/activity';
|
||||
import type { LogStream } from '../../../../audit/log-stream.js';
|
||||
import { WorkflowLogger } from '../../../../audit/workflow-logger.js';
|
||||
import { CapellaAgentError, capellaAgentExecutor } from '../../../pi/capella-agent-executor.js';
|
||||
import type {
|
||||
CapellaAgentExecutor,
|
||||
CapellaAgentRequest,
|
||||
CapellaAgentResponse,
|
||||
CapellaStageTrace,
|
||||
CapellaTraceLog,
|
||||
} from '../../../pi/capella-agent-types.js';
|
||||
import type { CapellaStage, CapellaUsage } from '../../types.js';
|
||||
import {
|
||||
@@ -67,6 +71,7 @@ import {
|
||||
type CapellaThreatModelActivityInput,
|
||||
type CapellaThreatModelActivityResult,
|
||||
} from './activity-types.js';
|
||||
import { createCapellaStageTrace } from './stage-trace.js';
|
||||
|
||||
// Must stay well under the smallest policy heartbeatTimeoutMs (one minute, for export).
|
||||
const HEARTBEAT_INTERVAL_MS = 2_000;
|
||||
@@ -360,9 +365,12 @@ class UsageRecordingExecutor implements CapellaAgentExecutor {
|
||||
private readonly delegate: CapellaAgentExecutor,
|
||||
private readonly artifactRoot: string,
|
||||
private readonly baseIdentity: Omit<UsageRecordIdentity, 'workloadId' | 'sessionNumber'>,
|
||||
private readonly stageTrace?: CapellaStageTrace,
|
||||
) {}
|
||||
|
||||
async run<T>(request: CapellaAgentRequest<T>): Promise<CapellaAgentResponse<T>> {
|
||||
// The label is display-only and is deliberately excluded from this hash: two sessions that
|
||||
// differ only by label are the same logical workload.
|
||||
const workloadId = sha256Parts(request.stage, request.role, request.systemPrompt, request.userPrompt).slice(0, 32);
|
||||
const sessionNumber = (this.sessionCounts.get(workloadId) ?? 0) + 1;
|
||||
this.sessionCounts.set(workloadId, sessionNumber);
|
||||
@@ -374,12 +382,23 @@ class UsageRecordingExecutor implements CapellaAgentExecutor {
|
||||
};
|
||||
await writeImmutableUsageRecord(this.artifactRoot, identity, started);
|
||||
|
||||
const sessionLog: CapellaTraceLog | undefined = this.stageTrace?.forSession(request.sessionLabel);
|
||||
let response: CapellaAgentResponse<T> | undefined;
|
||||
let caught: unknown;
|
||||
try {
|
||||
response = await this.delegate.run(request);
|
||||
const correlatedRequest = {
|
||||
...request,
|
||||
executionKey: this.baseIdentity.executionKey,
|
||||
attempt: this.baseIdentity.attempt,
|
||||
...(sessionLog !== undefined && { log: sessionLog }),
|
||||
} as CapellaAgentRequest<T>;
|
||||
response = await this.delegate.run(correlatedRequest);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
} finally {
|
||||
// Drain this session's trace writes before the run returns, so the activity cannot complete
|
||||
// with lines still buffered in memory.
|
||||
await this.stageTrace?.drain();
|
||||
}
|
||||
|
||||
const errorUsage = usageFromError(caught);
|
||||
@@ -535,8 +554,17 @@ async function runStageActivity<T, V>(
|
||||
let heartbeatInterval: NodeJS.Timeout | undefined;
|
||||
let inputFingerprint: string | undefined;
|
||||
let completedStageReturned = false;
|
||||
let stageTrace: CapellaStageTrace | undefined;
|
||||
|
||||
// Hold the stage's per-agent file open for the life of the activity so its concurrent sessions'
|
||||
// trace lines ride one reference count. openStageAgentLog never throws (it returns null on
|
||||
// failure); everything after it runs inside the try so the finally always releases the lease.
|
||||
const stageAgentLog: LogStream | null = await WorkflowLogger.openStageAgentLog(input.workflowLogPath, stage);
|
||||
|
||||
try {
|
||||
// Log the start line after opening the lease so the per-agent file header leads.
|
||||
await WorkflowLogger.logAgenticSastStart(input.workflowLogPath, stage, attempt, maximumAttempts);
|
||||
|
||||
// A missing policy row heartbeats too; only an explicit null opts a stage out.
|
||||
if (policy?.heartbeatTimeoutMs !== null) {
|
||||
heartbeat({ stage, attempt, elapsedSeconds: 0 });
|
||||
@@ -562,12 +590,13 @@ async function runStageActivity<T, V>(
|
||||
executionKey,
|
||||
attempt,
|
||||
});
|
||||
const executor = new UsageRecordingExecutor(capellaAgentExecutor, input.artifactRoot, {
|
||||
inputFingerprint,
|
||||
stage,
|
||||
executionKey,
|
||||
attempt,
|
||||
});
|
||||
stageTrace = createCapellaStageTrace(input.workflowLogPath, stage);
|
||||
const executor = new UsageRecordingExecutor(
|
||||
capellaAgentExecutor,
|
||||
input.artifactRoot,
|
||||
{ inputFingerprint, stage, executionKey, attempt },
|
||||
stageTrace,
|
||||
);
|
||||
const repositoryTools = await createCapellaRepositoryTools({
|
||||
repositoryRoot: input.repoPath,
|
||||
deniedPaths: [...input.codePathAvoids, ...CONFINEMENT_ONLY_DENIED_PATHS],
|
||||
@@ -587,6 +616,14 @@ async function runStageActivity<T, V>(
|
||||
// ledger aggregate that also counts any failed attempts of this stage.
|
||||
await recordStageUsageAccounting(input, inputFingerprint, stage, summary);
|
||||
const compactValue = compact(result.value);
|
||||
const researchValue = stage === 'research' ? (compactValue as Record<string, unknown>) : undefined;
|
||||
const dispatchedCount = researchValue?.dispatchedCount;
|
||||
const resumedCount = researchValue?.resumedCount;
|
||||
const counts =
|
||||
Number.isSafeInteger(dispatchedCount) && Number.isSafeInteger(resumedCount)
|
||||
? { dispatchedCount: Number(dispatchedCount), resumedCount: Number(resumedCount) }
|
||||
: undefined;
|
||||
await WorkflowLogger.logAgenticSastComplete(input.workflowLogPath, stage, result.durationMs, result.reused, counts);
|
||||
return {
|
||||
status: 'completed',
|
||||
durationMs: result.durationMs,
|
||||
@@ -600,6 +637,7 @@ async function runStageActivity<T, V>(
|
||||
} catch (error) {
|
||||
const cancellation = activityCancellation(error, signal);
|
||||
if (cancellation) {
|
||||
await WorkflowLogger.logAgenticSastCancelled(input.workflowLogPath, stage, attempt, maximumAttempts);
|
||||
throw cancellation;
|
||||
}
|
||||
|
||||
@@ -647,6 +685,15 @@ async function runStageActivity<T, V>(
|
||||
usageComplete: stageComplete,
|
||||
warnings: stageComplete ? [] : [usageAccountingWarning(stage)],
|
||||
};
|
||||
const retrying = classified.retryable && attempt < maximumAttempts;
|
||||
await WorkflowLogger.logAgenticSastFailure(
|
||||
input.workflowLogPath,
|
||||
stage,
|
||||
attempt,
|
||||
maximumAttempts,
|
||||
classified.code,
|
||||
retrying,
|
||||
);
|
||||
// The message crossing the Temporal boundary comes from the fixed safe-message
|
||||
// table; raw provider and filesystem text never enters workflow history.
|
||||
throw ApplicationFailure.create({
|
||||
@@ -657,6 +704,10 @@ async function runStageActivity<T, V>(
|
||||
});
|
||||
} finally {
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
// Drain any trailing trace writes, then release the stage's file lease, before the activity
|
||||
// returns — so no line is still buffered and the stream closes with the stage.
|
||||
if (stageTrace) await stageTrace.drain();
|
||||
await WorkflowLogger.closeStageAgentLog(stageAgentLog);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (C) 2026 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/** The per-stage trace surface that fans Capella session lines into the scan log. */
|
||||
|
||||
import { type TraceActor, WorkflowLogger } from '../../../../audit/workflow-logger.js';
|
||||
import type { CapellaStageTrace, CapellaTraceLog } from '../../../pi/capella-agent-types.js';
|
||||
import type { CapellaStage } from '../../types.js';
|
||||
|
||||
/**
|
||||
* A raw trace surface for one stage's PI sessions. It holds no per-`toolCallId` state — the
|
||||
* executor owns correlation — so it is safe to share across a stage's concurrent sessions. One
|
||||
* serialization queue keeps every line intact and lets `drain` guarantee no line is still buffered
|
||||
* when the activity returns; a failed write cannot fail the stage. Each `forSession` view carries
|
||||
* its display label into the trace prefix's session component.
|
||||
*/
|
||||
export function createCapellaStageTrace(workflowLogPath: string, stage: CapellaStage): CapellaStageTrace {
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
const enqueue = (operation: () => Promise<void>): void => {
|
||||
queue = queue.then(operation, operation).catch(() => undefined);
|
||||
};
|
||||
const forSession = (sessionLabel: string | undefined): CapellaTraceLog => {
|
||||
const actor: TraceActor =
|
||||
sessionLabel !== undefined ? { kind: 'sast', stage, session: sessionLabel } : { kind: 'sast', stage };
|
||||
return {
|
||||
toolCall: (invocation) => enqueue(() => WorkflowLogger.logToolCall(workflowLogPath, actor, invocation)),
|
||||
toolOutcome: (outcome) => enqueue(() => WorkflowLogger.logToolOutcome(workflowLogPath, actor, outcome)),
|
||||
sessionComplete: (durationMs, turns, operations) =>
|
||||
enqueue(() => WorkflowLogger.logSessionComplete(workflowLogPath, actor, durationMs, turns, operations)),
|
||||
};
|
||||
};
|
||||
return {
|
||||
forSession,
|
||||
drain: async () => {
|
||||
await queue;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,17 +11,26 @@ export interface SarifRef {
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export type CapellaStage =
|
||||
| 'architecture'
|
||||
| 'threat-model'
|
||||
| 'plan'
|
||||
| 'research'
|
||||
| 'dedupe'
|
||||
| 'review'
|
||||
| 'critic'
|
||||
| 'confirm'
|
||||
| 'calibrate'
|
||||
| 'export';
|
||||
export const CAPELLA_STAGES = [
|
||||
'architecture',
|
||||
'threat-model',
|
||||
'plan',
|
||||
'research',
|
||||
'dedupe',
|
||||
'review',
|
||||
'critic',
|
||||
'confirm',
|
||||
'calibrate',
|
||||
'export',
|
||||
] as const;
|
||||
|
||||
export type CapellaStage = (typeof CAPELLA_STAGES)[number];
|
||||
|
||||
const CAPELLA_STAGE_SET = new Set<string>(CAPELLA_STAGES);
|
||||
|
||||
export function isCapellaStage(value: string): value is CapellaStage {
|
||||
return CAPELLA_STAGE_SET.has(value);
|
||||
}
|
||||
|
||||
export type CapellaFailurePoint = CapellaStage | 'workflow';
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ import { Type } from 'typebox';
|
||||
export interface CapturedSubmitTool {
|
||||
readonly tool: ToolDefinition;
|
||||
readonly getCaptured: () => unknown | undefined;
|
||||
/**
|
||||
* A closed, safe result count for trace logging: the length of this tool's known
|
||||
* submitted array. Omitted when the payload has no such array to count. Never derived
|
||||
* from parsing an arbitrary result body.
|
||||
*/
|
||||
readonly safeCount?: () => number | undefined;
|
||||
readonly directive?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (C) 2026 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* The single projection boundary from a trace actor to its rendered forms: the combined-log
|
||||
* prefix, and the per-agent file it also fans out to. All actor validation and filename mapping
|
||||
* lives here, so no caller ever parses identity back out of a formatted line, and a slug can only
|
||||
* be built from closed actor fields.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { isCapellaStage } from '../ai/sast/types.js';
|
||||
import { containsControlCharacter, isLoggableAgentName, type LoggableAgentName } from './safe-fields.js';
|
||||
|
||||
/**
|
||||
* The actor a trace line is attributed to, rendered as its `[...]` prefix: a top-level agent, a
|
||||
* delegated subagent under its parent, or an Agentic SAST stage that may name one of its concurrent
|
||||
* sessions.
|
||||
*/
|
||||
export type TraceActor =
|
||||
| { readonly kind: 'agent'; readonly agent: LoggableAgentName }
|
||||
| { readonly kind: 'child'; readonly parent: LoggableAgentName; readonly child: string }
|
||||
| { readonly kind: 'sast'; readonly stage: string; readonly session?: string };
|
||||
|
||||
/**
|
||||
* The rendered forms of one actor. `combinedPrefix` is absent only when the actor itself is
|
||||
* unsafe, which drops the whole line (the pre-existing fail-closed behavior). `agentFileSlug` is
|
||||
* absent when no safe owning file can be named; that skips the per-agent fan-out only and never
|
||||
* affects the combined line.
|
||||
*/
|
||||
export interface ActorProjection {
|
||||
readonly combinedPrefix?: string;
|
||||
readonly agentFileSlug?: string;
|
||||
}
|
||||
|
||||
/** A subagent or Capella session identity: normalized words plus an optional `#N` ordinal. */
|
||||
export function safeIdentityLabel(value: string): string | undefined {
|
||||
if (containsControlCharacter(value)) return undefined;
|
||||
return /^[a-z0-9][a-z0-9 '#-]{0,47}$/u.test(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/** A per-agent log filename stem, drawn only from closed actor fields, safe as a path basename. */
|
||||
export function safeAgentFileSlug(value: string): string | undefined {
|
||||
return /^[a-z0-9][a-z0-9-]{0,63}$/u.test(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/** Render an actor's `[...]` prefix content, or `undefined` when any structural part is unsafe. */
|
||||
export function formatActor(actor: TraceActor): string | undefined {
|
||||
if (actor.kind === 'agent') {
|
||||
return isLoggableAgentName(actor.agent) ? actor.agent : undefined;
|
||||
}
|
||||
if (actor.kind === 'child') {
|
||||
if (!isLoggableAgentName(actor.parent)) return undefined;
|
||||
const child = safeIdentityLabel(actor.child);
|
||||
return child !== undefined ? `${actor.parent} > ${child}` : undefined;
|
||||
}
|
||||
if (!isCapellaStage(actor.stage)) return undefined;
|
||||
const base = `agentic-sast > ${actor.stage}`;
|
||||
// A missing or unsafe session label degrades to the stage-only prefix; it never drops the line.
|
||||
if (actor.session === undefined) return base;
|
||||
const session = safeIdentityLabel(actor.session);
|
||||
return session !== undefined ? `${base} > ${session}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stem of the per-agent file this actor's lines belong to, or `undefined` when none is safe.
|
||||
* The stem is gated on the actor's closed field first (a known agent name or Capella stage), then
|
||||
* re-checked for path safety, so an unknown name never spawns a stray file.
|
||||
*/
|
||||
export function agentFileSlug(actor: TraceActor): string | undefined {
|
||||
if (actor.kind === 'agent') return isLoggableAgentName(actor.agent) ? safeAgentFileSlug(actor.agent) : undefined;
|
||||
// A delegated subagent folds into its parent's file to keep the delegation narrative intact.
|
||||
if (actor.kind === 'child') return isLoggableAgentName(actor.parent) ? safeAgentFileSlug(actor.parent) : undefined;
|
||||
return isCapellaStage(actor.stage) ? safeAgentFileSlug(`agentic-sast-${actor.stage}`) : undefined;
|
||||
}
|
||||
|
||||
/** Project an actor into its combined-log prefix and its owning per-agent file stem. */
|
||||
export function projectActor(actor: TraceActor): ActorProjection {
|
||||
const combinedPrefix = formatActor(actor);
|
||||
const slug = agentFileSlug(actor);
|
||||
return {
|
||||
...(combinedPrefix !== undefined && { combinedPrefix }),
|
||||
...(slug !== undefined && { agentFileSlug: slug }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The `agents/` directory that holds a scan's per-agent logs, a sibling of the combined log. */
|
||||
export function agentsDir(workflowLogPath: string): string {
|
||||
return path.join(path.dirname(workflowLogPath), 'agents');
|
||||
}
|
||||
|
||||
/** The absolute path of a per-agent log, a sibling `agents/<slug>.log` of the combined log. */
|
||||
export function agentLogPath(workflowLogPath: string, slug: string): string {
|
||||
return path.join(agentsDir(workflowLogPath), `${slug}.log`);
|
||||
}
|
||||
@@ -26,10 +26,14 @@ import {
|
||||
} from '../types/run-state.js';
|
||||
import { SessionMutex } from '../utils/concurrency.js';
|
||||
import { fileExists } from '../utils/file-io.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { AgentLogger } from './logger.js';
|
||||
import { MetricsTracker } from './metrics-tracker.js';
|
||||
import { generateSessionJsonPath, initializeAuditStructure, type SessionMetadata } from './utils.js';
|
||||
import type { LoggableAgentName, WorkflowPhase } from './safe-fields.js';
|
||||
import {
|
||||
generateSessionJsonPath,
|
||||
generateWorkflowLogPath,
|
||||
initializeAuditStructure,
|
||||
type SessionMetadata,
|
||||
} from './utils.js';
|
||||
import { type AgentLogDetails, WorkflowLogger, type WorkflowSummary } from './workflow-logger.js';
|
||||
|
||||
// Global mutex instance
|
||||
@@ -37,14 +41,17 @@ const sessionMutex = new SessionMutex();
|
||||
|
||||
/**
|
||||
* AuditSession - Main audit system facade
|
||||
*
|
||||
* Construct a fresh instance per agent execution rather than sharing one across concurrent
|
||||
* agents. `WorkflowLogger.close()` (called after every logged unit of work) releases every
|
||||
* per-agent lease the instance currently holds, not just the caller's; a shared instance would
|
||||
* let one agent's completion sever another agent's still-open log file mid-write.
|
||||
*/
|
||||
export class AuditSession {
|
||||
readonly sessionMetadata: SessionMetadata;
|
||||
private sessionId: string;
|
||||
private metricsTracker: MetricsTracker;
|
||||
private workflowLogger: WorkflowLogger;
|
||||
private currentLogger: AgentLogger | null = null;
|
||||
private currentAgentName: string | null = null;
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(sessionMetadata: SessionMetadata) {
|
||||
@@ -93,8 +100,9 @@ export class AuditSession {
|
||||
// Initialize metrics tracker (loads or creates session.json)
|
||||
await this.metricsTracker.initialize(workflowId);
|
||||
|
||||
// Initialize workflow logger with actual Temporal workflow ID
|
||||
await this.workflowLogger.initialize(workflowId);
|
||||
if (workflowId !== undefined) {
|
||||
this.workflowLogger.setWorkflowId(workflowId);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
@@ -111,76 +119,41 @@ export class AuditSession {
|
||||
/**
|
||||
* Start agent execution
|
||||
*/
|
||||
async startAgent(agentName: string, promptContent: string, attemptNumber: number = 1): Promise<void> {
|
||||
async startAgent(agentName: LoggableAgentName, attemptNumber: number = 1): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
// 1. Save prompt snapshot (only on first attempt)
|
||||
if (attemptNumber === 1) {
|
||||
await AgentLogger.savePrompt(this.sessionMetadata, agentName, promptContent);
|
||||
}
|
||||
|
||||
// 2. Create and initialize the per-agent logger
|
||||
this.currentAgentName = agentName;
|
||||
this.currentLogger = new AgentLogger(this.sessionMetadata, agentName, attemptNumber);
|
||||
await this.currentLogger.initialize();
|
||||
|
||||
// 3. Start metrics timer
|
||||
this.metricsTracker.startAgent(agentName, attemptNumber);
|
||||
|
||||
// 4. Log start event to both agent log and workflow log
|
||||
await this.currentLogger.logEvent('agent_start', {
|
||||
agentName,
|
||||
attemptNumber,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
|
||||
await this.workflowLogger.logAgent(agentName, 'start', { attemptNumber });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log event during agent execution
|
||||
*/
|
||||
async logEvent(eventType: string, eventData: unknown): Promise<void> {
|
||||
if (!this.currentLogger) {
|
||||
throw new PentestError(
|
||||
'No active logger. Call startAgent() first.',
|
||||
'validation',
|
||||
false,
|
||||
{},
|
||||
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
/** Absolute path to this scan's human-readable log, for path-based trace writers. */
|
||||
get workflowLogPath(): string {
|
||||
return generateWorkflowLogPath(this.sessionMetadata);
|
||||
}
|
||||
|
||||
// Log to agent-specific log file (JSON format)
|
||||
await this.currentLogger.logEvent(eventType, eventData);
|
||||
|
||||
// Also log to unified workflow log (human-readable format)
|
||||
const data = eventData as Record<string, unknown>;
|
||||
const agentName = this.currentAgentName || 'unknown';
|
||||
switch (eventType) {
|
||||
case 'tool_start':
|
||||
await this.workflowLogger.logToolStart(agentName, String(data.toolName || ''), data.parameters);
|
||||
break;
|
||||
case 'llm_response':
|
||||
await this.workflowLogger.logLlmResponse(agentName, Number(data.turn || 0), String(data.content || ''));
|
||||
break;
|
||||
// tool_end and error events are intentionally not logged to workflow log
|
||||
// to reduce noise - the agent completion message captures the outcome
|
||||
}
|
||||
/** Record an agent attempt's closed-vocabulary error to the workflow log. */
|
||||
async logAgentError(
|
||||
agentName: LoggableAgentName,
|
||||
code: ErrorCode,
|
||||
category: string,
|
||||
attempt: number,
|
||||
durationMs: number,
|
||||
turns: number,
|
||||
): Promise<void> {
|
||||
await this.workflowLogger.logAgentError(agentName, code, category, attempt, durationMs, turns);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a human-readable note to the unified workflow log (e.g. a model
|
||||
* refusal fallback). Independent of agent event logging.
|
||||
* Release an agent's open per-agent log lease without recording an end. A backstop for an
|
||||
* abnormal abort where {@link endAgent} never ran; idempotent, so a normal end makes it a no-op.
|
||||
*/
|
||||
async logWorkflowNote(category: string, message: string): Promise<void> {
|
||||
await this.workflowLogger.logEvent(category, message);
|
||||
async releaseAgentLog(agentName: LoggableAgentName): Promise<void> {
|
||||
await this.workflowLogger.releaseAgentLog(agentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* End agent execution (mutex-protected)
|
||||
*/
|
||||
async endAgent(agentName: string, result: AgentEndResult): Promise<void> {
|
||||
async endAgent(agentName: LoggableAgentName, result: AgentEndResult): Promise<void> {
|
||||
await this.finishAgentLogs(agentName, result);
|
||||
|
||||
// 3. Acquire mutex before touching session.json
|
||||
@@ -207,32 +180,17 @@ export class AuditSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async finishAgentLogs(agentName: string, result: AgentEndResult): Promise<void> {
|
||||
// 1. Finalize agent log and close the stream
|
||||
if (this.currentLogger) {
|
||||
await this.currentLogger.logEvent('agent_end', {
|
||||
agentName,
|
||||
success: result.success,
|
||||
duration_ms: result.duration_ms,
|
||||
cost_usd: result.cost_usd,
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
|
||||
await this.currentLogger.close();
|
||||
this.currentLogger = null;
|
||||
}
|
||||
|
||||
// 2. Log completion to the unified workflow log
|
||||
this.currentAgentName = null;
|
||||
|
||||
/** Write the agent's end line and close this instance's logger before touching session.json. */
|
||||
private async finishAgentLogs(agentName: LoggableAgentName, result: AgentEndResult): Promise<void> {
|
||||
const agentLogDetails: AgentLogDetails = {
|
||||
attemptNumber: result.attemptNumber,
|
||||
duration_ms: result.duration_ms,
|
||||
cost_usd: result.cost_usd,
|
||||
success: result.success,
|
||||
...(result.error !== undefined && { error: result.error }),
|
||||
...(result.errorCode !== undefined && { errorCode: result.errorCode }),
|
||||
};
|
||||
await this.workflowLogger.logAgent(agentName, 'end', agentLogDetails);
|
||||
await this.workflowLogger.close();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,6 +210,8 @@ export class AuditSession {
|
||||
throw new RunStateError('IncompatibleWorkspaceError', 'session-json-missing-on-resume');
|
||||
}
|
||||
await this.initialize(workflowId);
|
||||
await this.workflowLogger.initialize(workflowId);
|
||||
await this.workflowLogger.close();
|
||||
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
try {
|
||||
@@ -386,17 +346,25 @@ export class AuditSession {
|
||||
/**
|
||||
* Log phase start to unified workflow log
|
||||
*/
|
||||
async logPhaseStart(phase: string): Promise<void> {
|
||||
async logPhaseStart(phase: WorkflowPhase): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.workflowLogger.logPhase(phase, 'start');
|
||||
try {
|
||||
await this.workflowLogger.logPhase(phase, 'start');
|
||||
} finally {
|
||||
await this.workflowLogger.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log phase completion to unified workflow log
|
||||
*/
|
||||
async logPhaseComplete(phase: string): Promise<void> {
|
||||
async logPhaseComplete(phase: WorkflowPhase): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.workflowLogger.logPhase(phase, 'complete');
|
||||
try {
|
||||
await this.workflowLogger.logPhase(phase, 'complete');
|
||||
} finally {
|
||||
await this.workflowLogger.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,7 +372,11 @@ export class AuditSession {
|
||||
*/
|
||||
async logWorkflowComplete(summary: WorkflowSummary): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.workflowLogger.logWorkflowComplete(summary);
|
||||
try {
|
||||
await this.workflowLogger.logWorkflowComplete(summary);
|
||||
} finally {
|
||||
await this.workflowLogger.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -427,17 +399,28 @@ export class AuditSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log resume header to workflow.log
|
||||
* Call this when a workflow is resuming to add a visual separator
|
||||
*/
|
||||
async logResumeHeader(resumeInfo: {
|
||||
/** Write and flush the new execution boundary before publishing its durable resume record. */
|
||||
async logResumeBoundary(workflowId: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
try {
|
||||
await this.workflowLogger.logResumeBoundary(workflowId);
|
||||
} finally {
|
||||
await this.workflowLogger.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Add checkpoint details beneath the already-durable resume boundary. */
|
||||
async logResumeDetails(resumeInfo: {
|
||||
previousWorkflowId: string;
|
||||
newWorkflowId: string;
|
||||
checkpointHash: string;
|
||||
completedAgents: string[];
|
||||
}): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.workflowLogger.logResumeHeader(resumeInfo);
|
||||
try {
|
||||
await this.workflowLogger.logResumeDetails(resumeInfo);
|
||||
} finally {
|
||||
await this.workflowLogger.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,124 +4,212 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* LogStream - Stream composition utility for append-only logging
|
||||
*
|
||||
* Encapsulates the common stream management pattern used by AgentLogger
|
||||
* and WorkflowLogger: opening streams in append mode, handling backpressure,
|
||||
* and proper cleanup.
|
||||
*/
|
||||
/** Process-wide serialized append handles for durable human-readable logging. */
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fs, { promises as fsPromises } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ensureDirectory } from '../utils/file-io.js';
|
||||
|
||||
export type AppendSearchScope = 'whole-file' | 'current-execution';
|
||||
export type AppendMarkerMatch = 'exact-line' | 'line-suffix';
|
||||
|
||||
export interface AppendIfAbsentOptions {
|
||||
readonly marker: string;
|
||||
readonly scope: AppendSearchScope;
|
||||
readonly match: AppendMarkerMatch;
|
||||
readonly flush?: boolean;
|
||||
}
|
||||
|
||||
interface SharedLogEntry {
|
||||
readonly filePath: string;
|
||||
readonly stream: fs.WriteStream;
|
||||
readonly ready: Promise<void>;
|
||||
queue: Promise<void>;
|
||||
references: number;
|
||||
closing: boolean;
|
||||
}
|
||||
|
||||
const sharedLogs = new Map<string, SharedLogEntry>();
|
||||
let warned = false;
|
||||
let agentLogWarned = false;
|
||||
|
||||
export function warnLoggingFailure(): void {
|
||||
if (warned) return;
|
||||
warned = true;
|
||||
console.error('Shannon could not write scan progress to workflow.log.');
|
||||
}
|
||||
|
||||
/**
|
||||
* LogStream - Manages a single append-only log file stream
|
||||
* A per-agent projection is best-effort: its failure must never disturb the canonical
|
||||
* workflow.log, so it is warned about separately and never surfaced as a workflow.log fault.
|
||||
*/
|
||||
export function warnAgentLoggingFailure(): void {
|
||||
if (agentLogWarned) return;
|
||||
agentLogWarned = true;
|
||||
console.error('Shannon could not write a per-agent log projection; the combined workflow.log is unaffected.');
|
||||
}
|
||||
|
||||
/** Open the append stream and track when it is safe to write, so an early `write()` waits on `open` instead of racing it. */
|
||||
function createSharedEntry(filePath: string): SharedLogEntry {
|
||||
const stream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8', autoClose: true });
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
const onOpen = (): void => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (): void => {
|
||||
cleanup();
|
||||
reject(new Error('workflow log stream could not be opened'));
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
stream.removeListener('open', onOpen);
|
||||
stream.removeListener('error', onError);
|
||||
};
|
||||
stream.once('open', onOpen);
|
||||
stream.once('error', onError);
|
||||
});
|
||||
stream.on('error', warnLoggingFailure);
|
||||
return { filePath, stream, ready, queue: Promise.resolve(), references: 0, closing: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain one more operation onto an entry's serial queue, so writes from any number of concurrent
|
||||
* `LogStream` handles to the same file still land in the order they were issued. The queue is
|
||||
* reset to a settled promise regardless of outcome, so one failed write cannot wedge every
|
||||
* write after it.
|
||||
*/
|
||||
function enqueue<T>(entry: SharedLogEntry, operation: () => Promise<T>): Promise<T> {
|
||||
const result = entry.queue.then(operation, operation);
|
||||
entry.queue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeToStream(stream: fs.WriteStream, text: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.write(text, 'utf8', (error) => {
|
||||
if (error) reject(new Error('workflow log write failed'));
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncStream(stream: fs.WriteStream): Promise<void> {
|
||||
const descriptor = (stream as fs.WriteStream & { readonly fd: number | null }).fd;
|
||||
if (descriptor === null) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.fsync(descriptor, (error) => {
|
||||
if (error) reject(new Error('workflow log flush failed'));
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a marker search to the text written since the most recent resume boundary. A resumed
|
||||
* run reopens the same log file, so without this a `current-execution` marker check would also
|
||||
* match a line written by a previous, already-finished execution.
|
||||
*/
|
||||
function currentExecution(content: string): string {
|
||||
const matches = [...content.matchAll(/^RESUMED\r?$/gmu)];
|
||||
const last = matches.at(-1);
|
||||
return last?.index === undefined ? content : content.slice(last.index);
|
||||
}
|
||||
|
||||
function markerExists(content: string, options: AppendIfAbsentOptions): boolean {
|
||||
const searched = options.scope === 'current-execution' ? currentExecution(content) : content;
|
||||
const lines = searched.split(/\r?\n/u);
|
||||
if (options.match === 'exact-line') return lines.includes(options.marker);
|
||||
return lines.some((line) => line.endsWith(options.marker));
|
||||
}
|
||||
|
||||
/** A reference-counted handle to one process-wide append stream. */
|
||||
export class LogStream {
|
||||
private readonly filePath: string;
|
||||
private stream: fs.WriteStream | null = null;
|
||||
private _isOpen: boolean = false;
|
||||
private released = false;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath;
|
||||
}
|
||||
private constructor(private readonly entry: SharedLogEntry) {}
|
||||
|
||||
/**
|
||||
* Open the stream for writing (creates parent directories, opens in append mode)
|
||||
* Take a reference on the shared entry for `filePath`, opening it if this is the first
|
||||
* reference. If a prior lease is mid-{@link release} when this call arrives, wait for that
|
||||
* drain to finish rather than reusing an entry that is about to be removed from the map;
|
||||
* the loop re-reads the map afterward because the entry may have been deleted, or replaced
|
||||
* by a new opener, while this call was waiting.
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
if (this._isOpen) {
|
||||
return;
|
||||
static async acquire(filePath: string): Promise<LogStream> {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
await ensureDirectory(path.dirname(absolutePath));
|
||||
let entry = sharedLogs.get(absolutePath);
|
||||
while (entry?.closing === true) {
|
||||
await entry.queue;
|
||||
entry = sharedLogs.get(absolutePath);
|
||||
}
|
||||
if (entry === undefined) {
|
||||
entry = createSharedEntry(absolutePath);
|
||||
sharedLogs.set(absolutePath, entry);
|
||||
}
|
||||
entry.references += 1;
|
||||
try {
|
||||
await entry.ready;
|
||||
} catch (error) {
|
||||
entry.references -= 1;
|
||||
if (entry.references === 0) sharedLogs.delete(absolutePath);
|
||||
warnLoggingFailure();
|
||||
throw error;
|
||||
}
|
||||
return new LogStream(entry);
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await ensureDirectory(path.dirname(this.filePath));
|
||||
|
||||
// Create write stream in append mode
|
||||
this.stream = fs.createWriteStream(this.filePath, {
|
||||
flags: 'a',
|
||||
encoding: 'utf8',
|
||||
autoClose: true,
|
||||
/** Queue an append; `flush` fsyncs before resolving, for the low-frequency structural lines that must be durable. */
|
||||
write(text: string, flush = false): Promise<void> {
|
||||
if (this.released) return Promise.reject(new Error('workflow log handle was released'));
|
||||
return enqueue(this.entry, async () => {
|
||||
await writeToStream(this.entry.stream, text);
|
||||
if (flush) await syncStream(this.entry.stream);
|
||||
});
|
||||
|
||||
// Handle stream errors to prevent crashes (log and mark closed)
|
||||
this.stream.on('error', (err) => {
|
||||
console.error(`LogStream error for ${this.filePath}:`, err.message);
|
||||
this._isOpen = false;
|
||||
});
|
||||
|
||||
this._isOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write text to the stream with backpressure handling
|
||||
* Append `text` only if its marker is not already present, so a structural line (a header, a
|
||||
* resume boundary) survives a Temporal activity retry without being written twice. The check
|
||||
* and the write share the same queued operation, so a concurrent writer on this entry cannot
|
||||
* observe the marker as absent and duplicate it.
|
||||
*/
|
||||
async write(text: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this._isOpen || !this.stream) {
|
||||
reject(new Error('LogStream not open'));
|
||||
return;
|
||||
}
|
||||
appendIfAbsent(text: string, options: AppendIfAbsentOptions): Promise<boolean> {
|
||||
if (this.released) return Promise.reject(new Error('workflow log handle was released'));
|
||||
return enqueue(this.entry, async () => {
|
||||
const content = await fsPromises.readFile(this.entry.filePath, 'utf8').catch(() => '');
|
||||
if (markerExists(content, options)) return false;
|
||||
await writeToStream(this.entry.stream, text);
|
||||
if (options.flush === true) await syncStream(this.entry.stream);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const stream = this.stream;
|
||||
let drainHandler: (() => void) | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (drainHandler) {
|
||||
stream.removeListener('drain', drainHandler);
|
||||
drainHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
const needsDrain = !stream.write(text, 'utf8', (error) => {
|
||||
cleanup();
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else if (!needsDrain) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
if (needsDrain) {
|
||||
drainHandler = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
stream.once('drain', drainHandler);
|
||||
/**
|
||||
* Drop this handle's reference. Only the last outstanding reference actually closes the
|
||||
* underlying file descriptor; every earlier release just decrements the count so other
|
||||
* concurrent leaseholders (an agent still mid-write, a stage still draining) are unaffected.
|
||||
* The close itself is queued behind any writes already pending on this entry, and `closing`
|
||||
* gates a new {@link acquire} until it finishes, so no writer ever sees a half-closed stream.
|
||||
*/
|
||||
async release(): Promise<void> {
|
||||
if (this.released) return;
|
||||
this.released = true;
|
||||
this.entry.references -= 1;
|
||||
await enqueue(this.entry, async () => {
|
||||
if (this.entry.references > 0 || this.entry.closing) return;
|
||||
this.entry.closing = true;
|
||||
await new Promise<void>((resolve) => this.entry.stream.end(resolve));
|
||||
if (this.entry.references === 0 && sharedLogs.get(this.entry.filePath) === this.entry) {
|
||||
sharedLogs.delete(this.entry.filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the stream (flush and close)
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (!this._isOpen || !this.stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.stream?.end(() => {
|
||||
this._isOpen = false;
|
||||
this.stream = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the stream is currently open
|
||||
*/
|
||||
get isOpen(): boolean {
|
||||
return this._isOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path this stream writes to
|
||||
*/
|
||||
get path(): string {
|
||||
return this.filePath;
|
||||
return this.entry.filePath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Append-Only Agent Logger
|
||||
*
|
||||
* Provides crash-safe, append-only logging for agent execution.
|
||||
* Uses LogStream for stream management with backpressure handling.
|
||||
*/
|
||||
|
||||
import { atomicWrite } from '../utils/file-io.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { LogStream } from './log-stream.js';
|
||||
import { generateLogPath, generatePromptPath, type SessionMetadata } from './utils.js';
|
||||
|
||||
interface LogEvent {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentLogger - Manages append-only logging for a single agent execution
|
||||
*/
|
||||
export class AgentLogger {
|
||||
private readonly sessionMetadata: SessionMetadata;
|
||||
private readonly agentName: string;
|
||||
private readonly attemptNumber: number;
|
||||
private readonly timestamp: number;
|
||||
private readonly logStream: LogStream;
|
||||
|
||||
constructor(sessionMetadata: SessionMetadata, agentName: string, attemptNumber: number) {
|
||||
this.sessionMetadata = sessionMetadata;
|
||||
this.agentName = agentName;
|
||||
this.attemptNumber = attemptNumber;
|
||||
this.timestamp = Date.now();
|
||||
|
||||
const logPath = generateLogPath(sessionMetadata, agentName, this.timestamp, attemptNumber);
|
||||
this.logStream = new LogStream(logPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the log stream (creates file and opens stream)
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.logStream.isOpen) {
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
await this.logStream.open();
|
||||
|
||||
// Write header
|
||||
await this.writeHeader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write header to log file
|
||||
*/
|
||||
private async writeHeader(): Promise<void> {
|
||||
const header = [
|
||||
`========================================`,
|
||||
`Agent: ${this.agentName}`,
|
||||
`Attempt: ${this.attemptNumber}`,
|
||||
`Started: ${formatTimestamp(this.timestamp)}`,
|
||||
`Session: ${this.sessionMetadata.id}`,
|
||||
`Web URL: ${this.sessionMetadata.webUrl}`,
|
||||
`========================================\n`,
|
||||
].join('\n');
|
||||
|
||||
return this.logStream.write(header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an event (tool_start, tool_end, llm_response, etc.)
|
||||
* Events are logged as JSON for parseability
|
||||
*/
|
||||
async logEvent(eventType: string, eventData: unknown): Promise<void> {
|
||||
const event: LogEvent = {
|
||||
type: eventType,
|
||||
timestamp: formatTimestamp(),
|
||||
data: eventData,
|
||||
};
|
||||
|
||||
const eventLine = `${JSON.stringify(event)}\n`;
|
||||
return this.logStream.write(eventLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the log stream
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
return this.logStream.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save prompt snapshot to prompts directory
|
||||
* Static method - doesn't require logger instance
|
||||
*/
|
||||
static async savePrompt(sessionMetadata: SessionMetadata, agentName: string, promptContent: string): Promise<void> {
|
||||
const promptPath = generatePromptPath(sessionMetadata, agentName);
|
||||
|
||||
// Create header with metadata
|
||||
const header = [
|
||||
`# Prompt Snapshot: ${agentName}`,
|
||||
``,
|
||||
`**Session:** ${sessionMetadata.id}`,
|
||||
`**Web URL:** ${sessionMetadata.webUrl}`,
|
||||
`**Saved:** ${formatTimestamp()}`,
|
||||
``,
|
||||
`---`,
|
||||
``,
|
||||
].join('\n');
|
||||
|
||||
const fullContent = header + promptContent;
|
||||
|
||||
// Use atomic write for safety
|
||||
await atomicWrite(promptPath, fullContent);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../types/run-state.js';
|
||||
import { atomicWrite, fileExists, readJson } from '../utils/file-io.js';
|
||||
import { calculatePercentage, formatTimestamp } from '../utils/formatting.js';
|
||||
import { safeErrorFromCode } from './safe-fields.js';
|
||||
import { generateSessionJsonPath, type SessionMetadata } from './utils.js';
|
||||
|
||||
interface AttemptData {
|
||||
@@ -47,6 +48,7 @@ interface AttemptData {
|
||||
timestamp: string;
|
||||
model?: string | undefined;
|
||||
error?: string | undefined;
|
||||
error_code?: ErrorCode | undefined;
|
||||
}
|
||||
|
||||
interface AgentAuditMetrics {
|
||||
@@ -733,6 +735,7 @@ export class MetricsTracker {
|
||||
};
|
||||
data.metrics.agents[agentName] = agent;
|
||||
|
||||
const safeError = result.errorCode === undefined ? undefined : safeErrorFromCode(result.errorCode);
|
||||
const attempt: AttemptData = {
|
||||
attempt_number: result.attemptNumber,
|
||||
duration_ms: result.duration_ms,
|
||||
@@ -745,7 +748,7 @@ export class MetricsTracker {
|
||||
...(result.cache_write_tokens !== undefined && { cache_write_tokens: result.cache_write_tokens }),
|
||||
...(result.turns !== undefined && { turns: result.turns }),
|
||||
...(result.model !== undefined && { model: result.model }),
|
||||
...(result.error !== undefined && { error: result.error }),
|
||||
...(safeError !== undefined && { error: safeError.message, error_code: safeError.code }),
|
||||
};
|
||||
agent.attempts.push(attempt);
|
||||
agent.total_cost_usd = agent.attempts.reduce((sum, entry) => sum + entry.cost_usd, 0);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright (C) 2026 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { ALL_AGENTS } from '../types/agents.js';
|
||||
import { ErrorCode, type PentestErrorType } from '../types/errors.js';
|
||||
|
||||
export const WORKFLOW_PHASES = ['pre-recon', 'recon', 'vulnerability-exploitation', 'reporting'] as const;
|
||||
export type WorkflowPhase = (typeof WORKFLOW_PHASES)[number];
|
||||
|
||||
export const LOGGABLE_AGENT_NAMES = [...ALL_AGENTS, 'validate-authentication'] as const;
|
||||
export type LoggableAgentName = (typeof LOGGABLE_AGENT_NAMES)[number];
|
||||
|
||||
/** A log-safe error rendering: a known code paired with one of the fixed, generic messages below. */
|
||||
export interface SafeErrorDetails {
|
||||
readonly code: ErrorCode;
|
||||
readonly category: PentestErrorType;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
const SAFE_ERROR_MESSAGES: Readonly<Record<ErrorCode, string>> = {
|
||||
[ErrorCode.CONFIG_NOT_FOUND]: 'The requested configuration could not be loaded.',
|
||||
[ErrorCode.CONFIG_VALIDATION_FAILED]: 'The scan configuration is invalid.',
|
||||
[ErrorCode.CONFIG_PARSE_ERROR]: 'The scan configuration could not be parsed.',
|
||||
[ErrorCode.AGENT_EXECUTION_FAILED]: 'The agent could not complete its work.',
|
||||
[ErrorCode.OUTPUT_VALIDATION_FAILED]: 'The agent did not produce valid output.',
|
||||
[ErrorCode.GIT_CHECKPOINT_FAILED]: 'The scan checkpoint could not be saved.',
|
||||
[ErrorCode.GIT_ROLLBACK_FAILED]: 'The scan workspace could not be restored after a failed attempt.',
|
||||
[ErrorCode.PROMPT_LOAD_FAILED]: 'The agent instructions could not be loaded.',
|
||||
[ErrorCode.DELIVERABLE_NOT_FOUND]: 'The agent did not produce the required result.',
|
||||
[ErrorCode.REPO_NOT_FOUND]: 'The repository could not be opened.',
|
||||
[ErrorCode.TARGET_UNREACHABLE]: 'The target could not be reached.',
|
||||
[ErrorCode.AUTH_FAILED]: 'Authentication validation failed.',
|
||||
[ErrorCode.AUTH_LOGIN_FAILED]: 'The configured login could not be completed.',
|
||||
};
|
||||
|
||||
const ERROR_CATEGORIES = new Set<PentestErrorType>([
|
||||
'config',
|
||||
'network',
|
||||
'prompt',
|
||||
'filesystem',
|
||||
'validation',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
const AGENT_NAME_SET = new Set<string>(LOGGABLE_AGENT_NAMES);
|
||||
const WORKFLOW_PHASE_SET = new Set<string>(WORKFLOW_PHASES);
|
||||
const ERROR_CODE_SET = new Set<string>(Object.values(ErrorCode));
|
||||
|
||||
export function isWorkflowPhase(value: string): value is WorkflowPhase {
|
||||
return WORKFLOW_PHASE_SET.has(value);
|
||||
}
|
||||
|
||||
export function isLoggableAgentName(value: string): value is LoggableAgentName {
|
||||
return AGENT_NAME_SET.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A workflow id safe to print in a log header or interpolate into a marker line. Falls back to
|
||||
* a fixed placeholder rather than throwing, since an unparseable id must not stop the log from
|
||||
* being written at all.
|
||||
*/
|
||||
export function safeWorkflowIdentifier(value: string): string {
|
||||
if (/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function containsControlCharacter(value: string): boolean {
|
||||
// Indexed scan, not a spread or regex: allocation-free over large tool arguments, and a
|
||||
// control-character regex literal is disallowed by lint.
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 31 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a token looks like a credential, hash, or key rather than an identifier or word:
|
||||
* a long unbroken alphanumeric run, a long digit-bearing token, or a long hex string. Used to
|
||||
* fail-closed on secret-shaped search patterns and labels the agent may have just discovered.
|
||||
*/
|
||||
export function looksSecretShaped(value: string): boolean {
|
||||
if (/[A-Za-z0-9]{20,}/u.test(value)) return true;
|
||||
const alphanumericLength = value.replace(/[^A-Za-z0-9]/gu, '').length;
|
||||
if (/[0-9]/u.test(value) && alphanumericLength >= 12) return true;
|
||||
if (/^[0-9a-fA-F]{12,}$/u.test(value)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin of a target URL, safe to print in a log header. Only `http`/`https` are accepted so
|
||||
* an exotic scheme (or credentials embedded in the URL) never reaches the log; anything else, or
|
||||
* anything unparseable, degrades to a placeholder instead of leaking the raw input.
|
||||
*/
|
||||
export function safeTargetUrl(value: string): string {
|
||||
if (containsControlCharacter(value)) return 'unavailable';
|
||||
try {
|
||||
const parsedUrl = new URL(value);
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
return 'unavailable';
|
||||
}
|
||||
return parsedUrl.origin;
|
||||
} catch {
|
||||
return 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an error code to its fixed, pre-approved log message rather than logging the error's own
|
||||
* message text. The underlying error can carry a file path, a stack frame, or other repo-specific
|
||||
* detail; only the closed `SAFE_ERROR_MESSAGES` table is allowed into a log line. An unrecognized
|
||||
* code or category falls back to a generic entry instead of being dropped, so a fault always gets
|
||||
* a log line, just not one repeating unvetted text.
|
||||
*/
|
||||
export function safeErrorFromCode(code: ErrorCode, category: PentestErrorType = 'unknown'): SafeErrorDetails {
|
||||
const safeCode = ERROR_CODE_SET.has(code) ? code : ErrorCode.AGENT_EXECUTION_FAILED;
|
||||
return {
|
||||
code: safeCode,
|
||||
category: ERROR_CATEGORIES.has(category) ? category : 'unknown',
|
||||
message: SAFE_ERROR_MESSAGES[safeCode],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover a code and category from an error of unknown shape, then defer to
|
||||
* {@link safeErrorFromCode} for the actual safe rendering. The duck-typed field reads only ever
|
||||
* pick out values that are already in the closed code/category sets, so a caught error's message
|
||||
* or other properties can never flow through into the log.
|
||||
*/
|
||||
export function safeErrorFromUnknown(
|
||||
error: unknown,
|
||||
fallbackCode: ErrorCode = ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
): SafeErrorDetails {
|
||||
let code = fallbackCode;
|
||||
let category: PentestErrorType = 'unknown';
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const candidate = error as { readonly code?: unknown; readonly type?: unknown };
|
||||
if (typeof candidate.code === 'string' && ERROR_CODE_SET.has(candidate.code)) {
|
||||
code = candidate.code as ErrorCode;
|
||||
}
|
||||
if (typeof candidate.type === 'string' && ERROR_CATEGORIES.has(candidate.type as PentestErrorType)) {
|
||||
category = candidate.type as PentestErrorType;
|
||||
}
|
||||
}
|
||||
return safeErrorFromCode(code, category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a free-text human description (child-task description, active todo label) to a
|
||||
* short, safe semantic label, or `undefined` when it is structurally unsafe.
|
||||
*
|
||||
* Ordinary security vocabulary — `authorization`, `password`, `token` — is allowed; the
|
||||
* rejection is structural, not a word blocklist. Fail-closed: anything carrying a URL,
|
||||
* path, domain, assignment, colon, secret-shaped token, control character, or excessive
|
||||
* length is rejected rather than partially sanitized.
|
||||
*/
|
||||
export function normalizeSemanticLabel(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || containsControlCharacter(value)) return undefined;
|
||||
const collapsed = value.trim().replace(/\s+/gu, ' ');
|
||||
if (collapsed.length === 0 || collapsed.length > 48) return undefined;
|
||||
// Paths, domains/filenames, assignments, colons, and addresses are structurally unsafe.
|
||||
if (/[./\\=:@]/u.test(collapsed)) return undefined;
|
||||
// A long unbroken alphanumeric run is secret/hash/base64-shaped, never a real word.
|
||||
if (/[A-Za-z0-9_-]{20,}/u.test(collapsed)) return undefined;
|
||||
const words = collapsed.toLowerCase().split(' ');
|
||||
if (words.length > 6) return undefined;
|
||||
if (!words.every((word) => /^[a-z0-9][a-z0-9'-]{0,19}$/u.test(word))) return undefined;
|
||||
if (words.some(looksSecretShaped)) return undefined;
|
||||
return words.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright (C) 2026 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/** Lossless tool-invocation capture shared by every workflow.log producer. */
|
||||
|
||||
import { warnLoggingFailure } from './log-stream.js';
|
||||
|
||||
/** One immutable tool invocation, serialized synchronously from the PI event. */
|
||||
export interface ToolInvocation {
|
||||
readonly tool: string;
|
||||
readonly argumentsJson: string;
|
||||
}
|
||||
|
||||
/** The optional second line a tool call earns on completion. */
|
||||
/** The one conditional second line a tool call may earn, chosen by {@link decideToolOutcome}. */
|
||||
export type ToolOutcome =
|
||||
| { readonly kind: 'failed'; readonly tool: string; readonly durationMs: number }
|
||||
| { readonly kind: 'slow'; readonly tool: string; readonly durationMs: number }
|
||||
| { readonly kind: 'count'; readonly tool: string; readonly count: number };
|
||||
|
||||
const COLLECTOR_PREFIXES = ['submit_', 'set_', 'add_', 'record_', 'report_'] as const;
|
||||
|
||||
/** A successful bash call is worth a slow line past 5s; any other tool past 10s. */
|
||||
const SLOW_BASH_MS = 5_000;
|
||||
const SLOW_OTHER_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Walk a value and throw on the first thing that cannot round-trip through `JSON.stringify`
|
||||
* unchanged: a cycle, a sparse or extended array, a non-plain object, or an accessor or symbol
|
||||
* property. `JSON.stringify` would otherwise silently drop or reshape these rather than fail, and
|
||||
* a silently-altered tool-call argument would break the log's claim to being a lossless capture.
|
||||
*/
|
||||
function assertJsonValue(value: unknown, activeObjects: WeakSet<object>): void {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) throw new TypeError('tool arguments contain a non-finite number');
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'object') throw new TypeError('tool arguments contain a non-JSON value');
|
||||
if (activeObjects.has(value)) throw new TypeError('tool arguments contain a cycle');
|
||||
|
||||
activeObjects.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
const enumerableKeys = Object.keys(value);
|
||||
if (enumerableKeys.length !== value.length) throw new TypeError('tool arguments contain a sparse array');
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (enumerableKeys[index] !== String(index)) throw new TypeError('tool arguments contain an extended array');
|
||||
assertJsonValue(value[index], activeObjects);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError('tool arguments contain a non-plain object');
|
||||
}
|
||||
const enumerableKeys = Object.keys(value);
|
||||
if (Reflect.ownKeys(value).length !== enumerableKeys.length) {
|
||||
throw new TypeError('tool arguments contain a non-enumerable or symbol field');
|
||||
}
|
||||
for (const key of enumerableKeys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (descriptor === undefined || !('value' in descriptor)) {
|
||||
throw new TypeError('tool arguments contain an accessor field');
|
||||
}
|
||||
assertJsonValue(descriptor.value, activeObjects);
|
||||
}
|
||||
} finally {
|
||||
activeObjects.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot a PI argument payload as compact JSON. PI arguments are parsed JSON; the
|
||||
* validation prevents future in-process callers from silently losing non-JSON values.
|
||||
*/
|
||||
export function serializeToolArguments(args: unknown): string | undefined {
|
||||
if (args === undefined) return '{}';
|
||||
try {
|
||||
assertJsonValue(args, new WeakSet<object>());
|
||||
const serialized = JSON.stringify(args);
|
||||
if (serialized === undefined) throw new TypeError('tool arguments could not be serialized');
|
||||
return serialized;
|
||||
} catch {
|
||||
warnLoggingFailure();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture the literal tool name and complete serialized arguments in the event callback. */
|
||||
export function captureToolInvocation(tool: string, args: unknown): ToolInvocation | undefined {
|
||||
const argumentsJson = serializeToolArguments(args);
|
||||
return argumentsJson === undefined ? undefined : { tool, argumentsJson };
|
||||
}
|
||||
|
||||
function isCollectorName(tool: string): boolean {
|
||||
return COLLECTOR_PREFIXES.some((prefix) => tool.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a completed tool call earns a second line. Task calls use their
|
||||
* delegated-session lifecycle instead of duplicate generic failure or slow records.
|
||||
*/
|
||||
export function decideToolOutcome(
|
||||
tool: string,
|
||||
isError: boolean,
|
||||
durationMs: number,
|
||||
collectorCount: number | undefined,
|
||||
): ToolOutcome | undefined {
|
||||
if (tool === 'task') return undefined;
|
||||
if (isError) return { kind: 'failed', tool, durationMs };
|
||||
if (isCollectorName(tool)) {
|
||||
if (typeof collectorCount === 'number' && Number.isSafeInteger(collectorCount) && collectorCount >= 0) {
|
||||
return { kind: 'count', tool, count: collectorCount };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const threshold = tool === 'bash' ? SLOW_BASH_MS : SLOW_OTHER_MS;
|
||||
return durationMs > threshold ? { kind: 'slow', tool, durationMs } : undefined;
|
||||
}
|
||||
@@ -53,28 +53,6 @@ export function generateInternalPath(sessionMetadata: SessionMetadata): string {
|
||||
return path.join(generateAuditPath(sessionMetadata), INTERNAL_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to agent log file
|
||||
*/
|
||||
export function generateLogPath(
|
||||
sessionMetadata: SessionMetadata,
|
||||
agentName: string,
|
||||
timestamp: number,
|
||||
attemptNumber: number,
|
||||
): string {
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
const filename = `${timestamp}_${agentName}_attempt-${attemptNumber}.log`;
|
||||
return path.join(internalPath, 'agents', filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to prompt snapshot file
|
||||
*/
|
||||
export function generatePromptPath(sessionMetadata: SessionMetadata, agentName: string): string {
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
return path.join(internalPath, 'prompts', `${agentName}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to session.json file
|
||||
*/
|
||||
@@ -86,6 +64,7 @@ export function generateSessionJsonPath(sessionMetadata: SessionMetadata): strin
|
||||
/**
|
||||
* Path to the shared authenticated browser session saved by the preflight
|
||||
* validator and consumed by downstream agents via `_shared-session.txt`.
|
||||
* Deleted at workflow end, so an authenticated session never outlives the scan it was created for.
|
||||
*/
|
||||
export function authStateFile(sessionMetadata: SessionMetadata): string {
|
||||
return path.join(generateInternalPath(sessionMetadata), 'auth-state.json');
|
||||
@@ -101,15 +80,10 @@ export function generateWorkflowLogPath(sessionMetadata: SessionMetadata): strin
|
||||
|
||||
/**
|
||||
* Initialize audit directory structure for a session.
|
||||
* Creates: workspaces/{sessionId}/.shannon/{agents,prompts}. The deliverables,
|
||||
* scratchpad, and browser dirs are created host-side and bind-mounted in.
|
||||
* Creates the hidden internals directory. The deliverables, scratchpad, and
|
||||
* browser directories are created host-side and bind-mounted in.
|
||||
*/
|
||||
export async function initializeAuditStructure(sessionMetadata: SessionMetadata): Promise<void> {
|
||||
const internalPath = generateInternalPath(sessionMetadata);
|
||||
const agentsPath = path.join(internalPath, 'agents');
|
||||
const promptsPath = path.join(internalPath, 'prompts');
|
||||
|
||||
await ensureDirectory(internalPath);
|
||||
await ensureDirectory(agentsPath);
|
||||
await ensureDirectory(promptsPath);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ import { fs, path } from 'zx';
|
||||
import { type PiPromptResult, runPiPrompt, validateAgentOutput } from '../ai/pi/pi-executor.js';
|
||||
import { createQueueSubmitTool, getQueueFilename } from '../ai/queue-schemas.js';
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { safeErrorFromCode } from '../audit/safe-fields.js';
|
||||
import { authStateFile } from '../audit/utils.js';
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
@@ -234,141 +235,152 @@ export class AgentExecutionService {
|
||||
}
|
||||
|
||||
// 4. Start audit logging
|
||||
await auditSession.startAgent(agentName, prompt, attemptNumber);
|
||||
await auditSession.startAgent(agentName, attemptNumber);
|
||||
|
||||
// 5. Execute agent. Vuln agents get a submit tool that captures the structured
|
||||
// exploitation queue (pi has no JSON-schema output format).
|
||||
const submitTool = createQueueSubmitTool(agentName, distributedConfig?.exploit ?? true);
|
||||
const result: PiPromptResult = await runPiPrompt(
|
||||
prompt,
|
||||
repoPath,
|
||||
'', // context
|
||||
agentName, // description
|
||||
agentName,
|
||||
auditSession,
|
||||
logger,
|
||||
customTools,
|
||||
path.relative(repoPath, deliverablesPath),
|
||||
cancellationSignal,
|
||||
submitTool,
|
||||
);
|
||||
|
||||
// 6. Handle execution failure
|
||||
if (!result.success) {
|
||||
const errorCode = errorCodeFromResult(result);
|
||||
return this.failAgent(agentName, deliverablesPath, auditSession, logger, {
|
||||
// startAgent opens this agent's per-agent log lease. Run the rest under try/finally so an
|
||||
// unexpected throw between here and the agent's end still releases that lease.
|
||||
try {
|
||||
// 5. Execute agent. Vuln agents get a submit tool that captures the structured
|
||||
// exploitation queue (pi has no JSON-schema output format).
|
||||
const submitTool = createQueueSubmitTool(agentName, distributedConfig?.exploit ?? true);
|
||||
const result: PiPromptResult = await runPiPrompt(
|
||||
prompt,
|
||||
repoPath,
|
||||
'', // context
|
||||
agentName, // description
|
||||
agentName,
|
||||
auditSession,
|
||||
logger,
|
||||
customTools,
|
||||
path.relative(repoPath, deliverablesPath),
|
||||
cancellationSignal,
|
||||
submitTool,
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason: 'execution failure',
|
||||
errorMessage: result.error || 'Agent execution failed',
|
||||
errorCode,
|
||||
category: categoryForErrorCode(errorCode),
|
||||
retryable: result.retryable ?? true,
|
||||
context: { agentName, originalError: result.error },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// 8-11. Write structured output, validate, render, and commit under one repo lock so
|
||||
// the write→validate→commit sequence is atomic against concurrent sibling agents.
|
||||
let commitHash: string | undefined;
|
||||
const finalizationError = await withGitRepoLock(async (): Promise<PentestError | null> => {
|
||||
// Every step below must surface as a returned error rather than a throw: only the
|
||||
// returned path rolls the workspace back and records the failed attempt.
|
||||
try {
|
||||
// 8. Write structured output to disk (vuln agents only) from the executor's capture
|
||||
const queueFilename = getQueueFilename(agentName);
|
||||
if (submitTool && queueFilename && result.structuredOutput !== undefined) {
|
||||
await fs.ensureDir(deliverablesPath);
|
||||
const queuePath = path.join(deliverablesPath, queueFilename);
|
||||
await fs.writeFile(queuePath, JSON.stringify(result.structuredOutput, null, 2), 'utf8');
|
||||
logger.info(`Wrote structured output queue to ${queueFilename}`);
|
||||
}
|
||||
// 6. Handle execution failure
|
||||
if (!result.success) {
|
||||
const errorCode = errorCodeFromResult(result);
|
||||
return this.failAgent(agentName, deliverablesPath, auditSession, logger, {
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason: 'execution failure',
|
||||
errorMessage: result.error || 'Agent execution failed',
|
||||
errorCode,
|
||||
category: categoryForErrorCode(errorCode),
|
||||
retryable: result.retryable ?? true,
|
||||
context: { agentName, originalError: result.error },
|
||||
});
|
||||
}
|
||||
|
||||
// 9. Validate output
|
||||
const validationPassed = await validateAgentOutput(result, agentName, deliverablesPath, logger);
|
||||
if (!validationPassed) {
|
||||
// 8-11. Write structured output, validate, render, and commit under one repo lock so
|
||||
// the write→validate→commit sequence is atomic against concurrent sibling agents.
|
||||
let commitHash: string | undefined;
|
||||
const finalizationError = await withGitRepoLock(async (): Promise<PentestError | null> => {
|
||||
// Every step below must surface as a returned error rather than a throw: only the
|
||||
// returned path rolls the workspace back and records the failed attempt.
|
||||
try {
|
||||
// 8. Write structured output to disk (vuln agents only) from the executor's capture
|
||||
const queueFilename = getQueueFilename(agentName);
|
||||
if (submitTool && queueFilename && result.structuredOutput !== undefined) {
|
||||
await fs.ensureDir(deliverablesPath);
|
||||
const queuePath = path.join(deliverablesPath, queueFilename);
|
||||
await fs.writeFile(queuePath, JSON.stringify(result.structuredOutput, null, 2), 'utf8');
|
||||
logger.info(`Wrote structured output queue to ${queueFilename}`);
|
||||
}
|
||||
|
||||
// 9. Validate output
|
||||
const validationPassed = await validateAgentOutput(result, agentName, deliverablesPath, logger);
|
||||
if (!validationPassed) {
|
||||
return new PentestError(
|
||||
`Agent ${agentName} failed output validation`,
|
||||
'validation',
|
||||
true,
|
||||
{ agentName, deliverableFilename: AGENTS[agentName].deliverableFilename },
|
||||
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
// 10. Render the deliverable to disk so the success commit below stages it
|
||||
if (writeDeliverable) {
|
||||
await writeDeliverable(deliverablesPath, {
|
||||
...(result.model !== undefined && { model: result.model }),
|
||||
});
|
||||
}
|
||||
|
||||
// 11. Success - commit deliverables (scoped) and capture the checkpoint hash
|
||||
const commitResult = await commitGitSuccess(deliverablesPath, agentName, logger, gitPaths);
|
||||
if (!commitResult.success) {
|
||||
return gitFailureForAgent(agentName, 'commit successful results', commitResult.error);
|
||||
}
|
||||
commitHash = commitResult.commitHash;
|
||||
// recordReportDraft requires a checkpoint hash to persist the draft durably; without one
|
||||
// a resumed workflow would have nothing to reconcile the draft against.
|
||||
if (successDisposition === 'report-draft' && commitHash === undefined) {
|
||||
return new PentestError(
|
||||
'The report was written but could not be saved. Re-running this workspace retries the reporting phase without repeating the analysis.',
|
||||
'filesystem',
|
||||
false,
|
||||
{ agentName },
|
||||
ErrorCode.GIT_CHECKPOINT_FAILED,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof PentestError) return error;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return new PentestError(
|
||||
`Agent ${agentName} failed output validation`,
|
||||
`Agent ${agentName} post-processing failed: ${errorMessage}`,
|
||||
'validation',
|
||||
true,
|
||||
{ agentName, deliverableFilename: AGENTS[agentName].deliverableFilename },
|
||||
{ agentName, originalError: errorMessage },
|
||||
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
// 10. Render the deliverable to disk so the success commit below stages it
|
||||
if (writeDeliverable) {
|
||||
await writeDeliverable(deliverablesPath, {
|
||||
...(result.model !== undefined && { model: result.model }),
|
||||
});
|
||||
}
|
||||
|
||||
// 11. Success - commit deliverables (scoped) and capture the checkpoint hash
|
||||
const commitResult = await commitGitSuccess(deliverablesPath, agentName, logger, gitPaths);
|
||||
if (!commitResult.success) {
|
||||
return gitFailureForAgent(agentName, 'commit successful results', commitResult.error);
|
||||
}
|
||||
commitHash = commitResult.commitHash;
|
||||
if (successDisposition === 'report-draft' && commitHash === undefined) {
|
||||
return new PentestError(
|
||||
'The report was written but could not be saved. Re-running this workspace retries the reporting phase without repeating the analysis.',
|
||||
'filesystem',
|
||||
false,
|
||||
{ agentName },
|
||||
ErrorCode.GIT_CHECKPOINT_FAILED,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof PentestError) return error;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return new PentestError(
|
||||
`Agent ${agentName} post-processing failed: ${errorMessage}`,
|
||||
'validation',
|
||||
true,
|
||||
{ agentName, originalError: errorMessage },
|
||||
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (finalizationError) {
|
||||
const rollbackReason =
|
||||
finalizationError.code === ErrorCode.OUTPUT_VALIDATION_FAILED
|
||||
? 'validation failure'
|
||||
: 'post-processing failure';
|
||||
return this.failAgent(agentName, deliverablesPath, auditSession, logger, {
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason,
|
||||
errorMessage: finalizationError.message,
|
||||
errorCode: finalizationError.code ?? ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
category: finalizationError.type,
|
||||
retryable: finalizationError.retryable,
|
||||
context: { agentName, ...finalizationError.context },
|
||||
});
|
||||
}
|
||||
|
||||
const endResult: AgentEndResult = {
|
||||
attemptNumber,
|
||||
duration_ms: result.duration,
|
||||
cost_usd: result.cost || 0,
|
||||
input_tokens: result.inputTokens,
|
||||
output_tokens: result.outputTokens,
|
||||
cache_read_tokens: result.cacheReadTokens,
|
||||
cache_write_tokens: result.cacheWriteTokens,
|
||||
turns: result.turns,
|
||||
success: true,
|
||||
model: result.model,
|
||||
...(commitHash && { checkpoint: commitHash }),
|
||||
};
|
||||
if (successDisposition === 'report-draft') {
|
||||
await auditSession.endReportDraft(endResult);
|
||||
} else {
|
||||
await auditSession.endAgent(agentName, endResult);
|
||||
}
|
||||
if (finalizationError) {
|
||||
const rollbackReason =
|
||||
finalizationError.code === ErrorCode.OUTPUT_VALIDATION_FAILED
|
||||
? 'validation failure'
|
||||
: 'post-processing failure';
|
||||
return this.failAgent(agentName, deliverablesPath, auditSession, logger, {
|
||||
attemptNumber,
|
||||
result,
|
||||
rollbackReason,
|
||||
errorMessage: finalizationError.message,
|
||||
errorCode: finalizationError.code ?? ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
category: finalizationError.type,
|
||||
retryable: finalizationError.retryable,
|
||||
context: { agentName, ...finalizationError.context },
|
||||
});
|
||||
}
|
||||
|
||||
return ok(endResult);
|
||||
const endResult: AgentEndResult = {
|
||||
attemptNumber,
|
||||
duration_ms: result.duration,
|
||||
cost_usd: result.cost || 0,
|
||||
input_tokens: result.inputTokens,
|
||||
output_tokens: result.outputTokens,
|
||||
cache_read_tokens: result.cacheReadTokens,
|
||||
cache_write_tokens: result.cacheWriteTokens,
|
||||
turns: result.turns,
|
||||
success: true,
|
||||
model: result.model,
|
||||
...(commitHash && { checkpoint: commitHash }),
|
||||
};
|
||||
if (successDisposition === 'report-draft') {
|
||||
await auditSession.endReportDraft(endResult);
|
||||
} else {
|
||||
await auditSession.endAgent(agentName, endResult);
|
||||
}
|
||||
|
||||
return ok(endResult);
|
||||
} finally {
|
||||
// Normal completion already released this agent's log lease (endAgent → close()); this is the
|
||||
// backstop for an unexpected throw between start and end. Idempotent and best-effort.
|
||||
await auditSession.releaseAgentLog(agentName);
|
||||
}
|
||||
}
|
||||
|
||||
private async failAgent(
|
||||
@@ -385,6 +397,7 @@ export class AgentExecutionService {
|
||||
getAgentGitPaths(agentName),
|
||||
);
|
||||
|
||||
const safeError = safeErrorFromCode(opts.errorCode, opts.category);
|
||||
const endResult: AgentEndResult = {
|
||||
attemptNumber: opts.attemptNumber,
|
||||
duration_ms: opts.result.duration,
|
||||
@@ -396,7 +409,8 @@ export class AgentExecutionService {
|
||||
turns: opts.result.turns,
|
||||
success: false,
|
||||
model: opts.result.model,
|
||||
error: opts.errorMessage,
|
||||
error: safeError.message,
|
||||
errorCode: safeError.code,
|
||||
};
|
||||
await auditSession.endAgent(agentName, endResult);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Type } from 'typebox';
|
||||
import { runPiPrompt } from '../ai/pi/pi-executor.js';
|
||||
import type { CapturedSubmitTool } from '../ai/submit-tool.js';
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { safeErrorFromUnknown } from '../audit/safe-fields.js';
|
||||
import { authStateFile } from '../audit/utils.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import type { AgentEndResult } from '../types/audit.js';
|
||||
@@ -136,7 +137,7 @@ export async function validateAuthentication(
|
||||
promptDir,
|
||||
);
|
||||
|
||||
await auditSession.startAgent(AGENT_NAME, prompt, attemptNumber);
|
||||
await auditSession.startAgent(AGENT_NAME, attemptNumber);
|
||||
const startTime = Date.now();
|
||||
|
||||
const submitTool = createAuthSubmitTool();
|
||||
@@ -152,6 +153,7 @@ export async function validateAuthentication(
|
||||
deliverablesSubdir,
|
||||
cancellationSignal,
|
||||
submitTool,
|
||||
attemptNumber,
|
||||
);
|
||||
|
||||
let classification = classifyResult(result, authentication);
|
||||
@@ -164,13 +166,14 @@ export async function validateAuthentication(
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
const safeError = classification.ok ? undefined : safeErrorFromUnknown(classification.error);
|
||||
const endResult: AgentEndResult = {
|
||||
attemptNumber,
|
||||
duration_ms: durationMs,
|
||||
cost_usd: result.cost || 0,
|
||||
success: classification.ok,
|
||||
...(result.model !== undefined && { model: result.model }),
|
||||
...(!classification.ok && { error: classification.error.message }),
|
||||
...(safeError !== undefined && { error: safeError.message, errorCode: safeError.code }),
|
||||
};
|
||||
await auditSession.endAgent(AGENT_NAME, endResult);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { syncPermissionSystemConfig } from '../ai/pi/permission-system.js';
|
||||
import { writePlaywrightStealthConfig } from '../ai/playwright-config-writer.js';
|
||||
import { AuditSession } from '../audit/index.js';
|
||||
import type { ResumeAttempt } from '../audit/metrics-tracker.js';
|
||||
import type { WorkflowPhase } from '../audit/safe-fields.js';
|
||||
import { authStateFile, generateAuditPath, type SessionMetadata } from '../audit/utils.js';
|
||||
import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
|
||||
@@ -1803,7 +1804,8 @@ export async function restoreGitCheckpoint(
|
||||
export async function registerResumeAttempt(input: ActivityInput, terminatedWorkflows: string[]): Promise<void> {
|
||||
const sessionMetadata = buildSessionMetadata(input);
|
||||
const auditSession = new AuditSession(sessionMetadata);
|
||||
await auditSession.initialize();
|
||||
await auditSession.initialize(input.workflowId);
|
||||
await auditSession.logResumeBoundary(input.workflowId);
|
||||
await auditSession.addResumeAttempt(input.workflowId, terminatedWorkflows);
|
||||
}
|
||||
|
||||
@@ -1817,8 +1819,8 @@ export async function recordResumeAttempt(
|
||||
const auditSession = new AuditSession(sessionMetadata);
|
||||
await auditSession.initialize();
|
||||
|
||||
// session.json entry already added by registerResumeAttempt; here we only write the workflow.log header.
|
||||
await auditSession.logResumeHeader({
|
||||
// The execution boundary was flushed by registerResumeAttempt before session.json publication.
|
||||
await auditSession.logResumeDetails({
|
||||
previousWorkflowId,
|
||||
newWorkflowId: input.workflowId,
|
||||
checkpointHash,
|
||||
@@ -1831,7 +1833,7 @@ export async function recordResumeAttempt(
|
||||
*/
|
||||
export async function logPhaseTransition(
|
||||
input: ActivityInput,
|
||||
phase: string,
|
||||
phase: WorkflowPhase,
|
||||
event: 'start' | 'complete',
|
||||
): Promise<void> {
|
||||
const sessionMetadata = buildSessionMetadata(input);
|
||||
|
||||
@@ -52,6 +52,6 @@ export function toWorkflowSummary(
|
||||
...(agenticSastFailedStage !== undefined && { agenticSastFailedStage }),
|
||||
...(agenticSastFailureMessage !== undefined && { agenticSastFailureMessage }),
|
||||
...(agenticSastErrorCode !== undefined && { agenticSastErrorCode }),
|
||||
...(state.error && { error: state.error }),
|
||||
...(state.errorCode !== undefined && { errorCode: state.errorCode }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,12 +33,19 @@ import { Client, Connection, type WorkflowHandle, WorkflowNotFoundError } from '
|
||||
import { bundleWorkflowCode, NativeConnection, Worker } from '@temporalio/worker';
|
||||
import dotenv from 'dotenv';
|
||||
import { DEFAULT_MODEL_SPEC } from '../ai/models.js';
|
||||
import { capellaTerminalStageLabel, isCapellaSafeFailureMessage } from '../ai/sast/capella/safe-failures.js';
|
||||
import { capellaActivities, mergeActivityRegistries } from '../ai/sast/capella/temporal/registry.js';
|
||||
import { CAPELLA_FORMAT_VERSION, CAPELLA_PROMPT_SET_VERSION } from '../ai/sast/capella/types.js';
|
||||
import { sanitizeHostname } from '../audit/utils.js';
|
||||
import { distributeConfig, parseConfig } from '../config-parser.js';
|
||||
import { deliverablesDir, resolveSessionJsonPath } from '../paths.js';
|
||||
import { SAFE_RUN_STATE_MESSAGES, workspaceExploitMismatchMessage } from '../types/run-state.js';
|
||||
import {
|
||||
ACCEPTED_CAPELLA_FAILURE_STAGES,
|
||||
isPartialReason,
|
||||
projectPartialReasons,
|
||||
SAFE_RUN_STATE_MESSAGES,
|
||||
workspaceExploitMismatchMessage,
|
||||
} from '../types/run-state.js';
|
||||
import { fileExists, readJson } from '../utils/file-io.js';
|
||||
import {
|
||||
assembleReportActivity,
|
||||
@@ -88,6 +95,27 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PROGRESS_QUERY = 'getProgress';
|
||||
|
||||
function safeFailureCode(value: string | undefined): string | undefined {
|
||||
if (value !== undefined && /^[A-Z][A-Z0-9_]{0,63}$/u.test(value)) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function safePartialReasonMessage(reason: PipelineState['partialReasons'][number]): string | undefined {
|
||||
if (reason.code === 'agentic_sast_reduced') return 'Agentic SAST completed with reduced coverage.';
|
||||
const candidate = {
|
||||
code: reason.code,
|
||||
...(reason.vulnerabilityClass !== undefined && { vulnerabilityClass: reason.vulnerabilityClass }),
|
||||
...(reason.stage !== undefined && { stage: reason.stage }),
|
||||
...(reason.reductionReason !== undefined && { reductionReason: reason.reductionReason }),
|
||||
...(reason.omittedCount !== undefined && { omittedCount: reason.omittedCount }),
|
||||
...(reason.consideredCount !== undefined && { consideredCount: reason.consideredCount }),
|
||||
...(reason.classifiedCount !== undefined && { classifiedCount: reason.classifiedCount }),
|
||||
...(reason.affectedBatchCount !== undefined && { affectedBatchCount: reason.affectedBatchCount }),
|
||||
};
|
||||
if (!isPartialReason(candidate)) return undefined;
|
||||
return projectPartialReasons([candidate])[0]?.message;
|
||||
}
|
||||
|
||||
// The ordinary activity names. This frozen list is one of three that together form the
|
||||
// registered activity set the CLI status reader mirrors: the Capella names in
|
||||
// ai/sast/capella/temporal/activity-types.ts and the reconciliation names in
|
||||
@@ -417,7 +445,7 @@ async function resolveWorkspace(client: Client, args: CliArgs, expectedExploit:
|
||||
}
|
||||
|
||||
if (!isValidWorkspaceName(workspace)) {
|
||||
console.error(`ERROR: Invalid workspace name: "${workspace}"`);
|
||||
console.error('ERROR: Invalid workspace name.');
|
||||
console.error(' Must be 1-128 characters, alphanumeric/hyphens/underscores, starting with alphanumeric');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -467,8 +495,7 @@ async function loadOrchestrationConfig(configPath: string | undefined): Promise<
|
||||
} catch (error) {
|
||||
// A broken config must fail the run, not silently fall back to empty
|
||||
// defaults that quietly change scope (vuln classes, exploit, retries).
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Failed to parse config ${configPath}: ${message}`);
|
||||
console.error('Worker configuration could not be loaded. Reference code: CONFIG_VALIDATION_FAILED');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -523,15 +550,23 @@ async function waitForWorkflowResult(
|
||||
if (result.status === 'partial') {
|
||||
console.log('\nScan completed with gaps (partial). The reasons are listed below.');
|
||||
for (const reason of result.partialReasons) {
|
||||
console.log(` - ${reason.message}`);
|
||||
const message = safePartialReasonMessage(reason);
|
||||
if (message !== undefined) console.log(` - ${message}`);
|
||||
}
|
||||
// The reason above says a class of coverage degraded; these three name the sanitized
|
||||
// agentic-SAST failure behind it, under the same labels every other surface uses.
|
||||
if (result.agenticSast.status === 'failed') {
|
||||
console.log(` Agentic SAST stopped at: ${result.agenticSast.failedStageLabel}`);
|
||||
console.log(` What happened: ${result.agenticSast.error}`);
|
||||
if (result.agenticSast.errorCode !== undefined) {
|
||||
console.log(` Reference code (for a bug report): ${result.agenticSast.errorCode}`);
|
||||
const stage = ACCEPTED_CAPELLA_FAILURE_STAGES.includes(result.agenticSast.failedStage)
|
||||
? capellaTerminalStageLabel(result.agenticSast.failedStage)
|
||||
: 'orchestration';
|
||||
const message = isCapellaSafeFailureMessage(result.agenticSast.error)
|
||||
? result.agenticSast.error
|
||||
: 'An agentic SAST step failed.';
|
||||
console.log(` Agentic SAST stopped at: ${stage}`);
|
||||
console.log(` What happened: ${message}`);
|
||||
const code = safeFailureCode(result.agenticSast.errorCode);
|
||||
if (code !== undefined) {
|
||||
console.log(` Reference code (for a bug report): ${code}`);
|
||||
}
|
||||
}
|
||||
} else if (result.status === 'cancelled') {
|
||||
@@ -559,9 +594,9 @@ async function waitForWorkflowResult(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
clearInterval(progressInterval);
|
||||
console.error('\nPipeline failed:', error);
|
||||
console.error('\nScan failed. Reference code: WORKFLOW_FAILED');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -638,8 +673,8 @@ async function run(): Promise<void> {
|
||||
|
||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : undefined;
|
||||
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||
run().catch((err) => {
|
||||
console.error('Worker failed:', err);
|
||||
run().catch(() => {
|
||||
console.error('Worker failed. Reference code: WORKER_FAILED');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* Pure functions with no side effects — safe for Temporal workflow sandbox.
|
||||
*/
|
||||
|
||||
import { WORKFLOW_PHASES } from '../audit/safe-fields.js';
|
||||
import { ALL_AGENTS } from '../types/agents.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,12 @@ const ERROR_TYPE_TO_CODE: Record<string, ErrorCode> = {
|
||||
AgentExecutionError: ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
GitError: ErrorCode.GIT_CHECKPOINT_FAILED,
|
||||
InvalidTargetError: ErrorCode.TARGET_UNREACHABLE,
|
||||
AuthLoginFailedError: ErrorCode.AUTH_LOGIN_FAILED,
|
||||
PipelineFailedError: ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
ReportDraftError: ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
ReportSarifRenderError: ErrorCode.OUTPUT_VALIDATION_FAILED,
|
||||
IncompatibleWorkspaceError: ErrorCode.CONFIG_VALIDATION_FAILED,
|
||||
WorkspaceNotFoundError: ErrorCode.CONFIG_NOT_FOUND,
|
||||
};
|
||||
|
||||
export function classifyErrorCode(error: unknown): ErrorCode | undefined {
|
||||
@@ -54,41 +62,47 @@ const REMEDIATION_HINTS: Record<string, string> = {
|
||||
PipelineFailedError: 're-run the same -w to retry from the last checkpoint.',
|
||||
};
|
||||
|
||||
const SAFE_WORKFLOW_FAILURE_MESSAGES: Readonly<Record<string, string>> = {
|
||||
AuthenticationError: 'Provider authentication failed.',
|
||||
ConfigurationError: 'The scan configuration is invalid.',
|
||||
OutputValidationError: 'A scan step returned an unusable result.',
|
||||
AgentExecutionError: 'An agent could not complete its work.',
|
||||
GitError: 'The scan checkpoint could not be updated.',
|
||||
InvalidTargetError: 'The target could not be reached.',
|
||||
AuthLoginFailedError: 'The configured login could not be completed.',
|
||||
PipelineFailedError: 'The vulnerability analysis phase could not be completed.',
|
||||
ReportDraftError: 'The report could not be saved.',
|
||||
ReportSarifRenderError: 'The report SARIF output could not be rendered.',
|
||||
IncompatibleWorkspaceError: 'This workspace cannot be resumed.',
|
||||
WorkspaceNotFoundError: 'The requested workspace was not found.',
|
||||
};
|
||||
|
||||
const WORKFLOW_PHASE_SET = new Set<string>(WORKFLOW_PHASES);
|
||||
const AGENT_NAME_SET = new Set<string>(ALL_AGENTS);
|
||||
|
||||
/**
|
||||
* Walk the .cause chain to find the innermost error with a .type property.
|
||||
* Temporal wraps ApplicationFailure in ActivityFailure — the useful info is inside.
|
||||
* Walk the .cause chain to find the innermost approved failure type.
|
||||
* Temporal wraps ApplicationFailure in ActivityFailure, so classification must inspect causes.
|
||||
*
|
||||
* Uses duck-typing because workflow code cannot import @temporalio/activity types.
|
||||
*/
|
||||
function unwrapActivityError(error: unknown): {
|
||||
message: string;
|
||||
type: string | null;
|
||||
} {
|
||||
function unwrapActivityError(error: unknown): { type: string | null } {
|
||||
let current: unknown = error;
|
||||
let typed: { message: string; type: string } | null = null;
|
||||
let type: string | null = null;
|
||||
|
||||
while (current instanceof Error) {
|
||||
if ('type' in current && typeof (current as { type: unknown }).type === 'string') {
|
||||
typed = {
|
||||
message: current.message,
|
||||
type: (current as { type: string }).type,
|
||||
};
|
||||
const candidate = (current as { type: string }).type;
|
||||
if (candidate in SAFE_WORKFLOW_FAILURE_MESSAGES) type = candidate;
|
||||
}
|
||||
current = (current as { cause?: unknown }).cause;
|
||||
}
|
||||
|
||||
if (typed) {
|
||||
return typed;
|
||||
}
|
||||
|
||||
return {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
type: null,
|
||||
};
|
||||
return { type };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a structured error string from workflow catch context.
|
||||
* Format a structured, closed-field error string from workflow catch context.
|
||||
* Segments are delimited by | for multi-line rendering by WorkflowLogger.
|
||||
*/
|
||||
export function formatWorkflowError(error: unknown, currentPhase: string | null, currentAgent: string | null): string {
|
||||
@@ -96,10 +110,12 @@ export function formatWorkflowError(error: unknown, currentPhase: string | null,
|
||||
|
||||
// Phase context (first segment)
|
||||
let phaseContext = 'Pipeline failed';
|
||||
if (currentPhase && currentAgent && currentPhase !== currentAgent) {
|
||||
phaseContext = `${currentPhase} failed (agent: ${currentAgent})`;
|
||||
} else if (currentPhase) {
|
||||
phaseContext = `${currentPhase} failed`;
|
||||
const safePhase = currentPhase !== null && WORKFLOW_PHASE_SET.has(currentPhase) ? currentPhase : null;
|
||||
const safeAgent = currentAgent !== null && AGENT_NAME_SET.has(currentAgent) ? currentAgent : null;
|
||||
if (safePhase && safeAgent && safePhase !== safeAgent) {
|
||||
phaseContext = `${safePhase} failed (agent: ${safeAgent})`;
|
||||
} else if (safePhase) {
|
||||
phaseContext = `${safePhase} failed`;
|
||||
}
|
||||
|
||||
const segments: string[] = [phaseContext];
|
||||
@@ -108,8 +124,11 @@ export function formatWorkflowError(error: unknown, currentPhase: string | null,
|
||||
segments.push(unwrapped.type);
|
||||
}
|
||||
|
||||
// Sanitize pipe characters from message to preserve delimiter format
|
||||
segments.push(unwrapped.message.replaceAll('|', '/'));
|
||||
segments.push(
|
||||
unwrapped.type === null
|
||||
? 'The scan could not be completed.'
|
||||
: (SAFE_WORKFLOW_FAILURE_MESSAGES[unwrapped.type] ?? 'The scan could not be completed.'),
|
||||
);
|
||||
|
||||
if (unwrapped.type) {
|
||||
const hint = REMEDIATION_HINTS[unwrapped.type];
|
||||
|
||||
@@ -28,16 +28,17 @@ import {
|
||||
workflowInfo,
|
||||
} from '@temporalio/workflow';
|
||||
import type { StageMetrics } from '../ai/reconciliation/stage-contracts.js';
|
||||
import { capellaTerminalStageLabel, isCapellaSafeFailureMessage } from '../ai/sast/capella/safe-failures.js';
|
||||
import type { CapellaWorkflowInput } from '../ai/sast/capella/temporal/activity-types.js';
|
||||
import { CAPELLA_CHILD_WORKFLOW_OPTIONS, capellaWorkflow } from '../ai/sast/capella/temporal/workflow.js';
|
||||
import type { CapellaRunResult, SarifRef } from '../ai/sast/types.js';
|
||||
import type { WorkflowPhase } from '../audit/safe-fields.js';
|
||||
import type { AgentName, VulnType } from '../types/agents.js';
|
||||
import { ALL_AGENTS } from '../types/agents.js';
|
||||
import { ALL_VULN_CLASSES, type VulnClass } from '../types/config.js';
|
||||
import type { ReconciliationClass } from '../types/reconciliation.js';
|
||||
import {
|
||||
appendPartialReasons,
|
||||
capellaStageDisplayName,
|
||||
type MiscellaneousOutcome,
|
||||
miscellaneousLaneIsSettled,
|
||||
type PartialReason,
|
||||
@@ -172,13 +173,18 @@ const seedMiscellaneousActs = proxyActivities<Pick<ReconciliationActivityRegistr
|
||||
});
|
||||
|
||||
const MAX_CONCURRENT_PIPELINES = 5;
|
||||
const MAX_PIPELINE_ERROR_MESSAGE_LENGTH = 2000;
|
||||
const MAX_NON_FATAL_FAILURES = 32;
|
||||
|
||||
const CAPELLA_OPERATION_KEY = 'agentic-sast';
|
||||
const CAPELLA_OPERATION_LABEL = 'Agentic SAST';
|
||||
const CAPELLA_INFRASTRUCTURE_FAILURE = 'Agentic SAST infrastructure failed before producing a usable result.';
|
||||
const CAPELLA_UNFINISHED = 'Agentic SAST had not finished when the scan stopped.';
|
||||
const OPERATION_FAILURE = 'This scan step could not be completed.';
|
||||
const CLASS_PIPELINE_FAILURE = 'A vulnerability analysis lane could not be completed.';
|
||||
const CLASS_RECONCILIATION_FAILURE = 'Findings reconciliation could not be completed.';
|
||||
const MISCELLANEOUS_PIPELINE_FAILURE = 'The additional findings lane could not be completed.';
|
||||
const REPORT_RENUMBER_FAILURE = 'Report finding identifiers could not be refreshed for this class.';
|
||||
const REPORT_COMPACTION_FAILURE = 'Report findings could not be compacted.';
|
||||
|
||||
/**
|
||||
* The single Capella outcome every vulnerability class joins on. Agentic SAST overlaps the
|
||||
@@ -189,11 +195,6 @@ type CapellaSettlement =
|
||||
| { readonly outcome: 'settled'; readonly sarif?: SarifRef }
|
||||
| { readonly outcome: 'cancelled'; readonly error: unknown };
|
||||
|
||||
function truncatePipelineErrorMessage(message: string): string {
|
||||
if (message.length <= MAX_PIPELINE_ERROR_MESSAGE_LENGTH) return message;
|
||||
return `${message.slice(0, MAX_PIPELINE_ERROR_MESSAGE_LENGTH - 20)}\n[truncated]`;
|
||||
}
|
||||
|
||||
/** Walk a rejection's `.cause` chain into an array, deduped and depth-bounded against a cycle. */
|
||||
function failureChain(error: unknown): unknown[] {
|
||||
const chain: unknown[] = [];
|
||||
@@ -478,10 +479,7 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
// limit; an entry past the cap is dropped silently rather than turned into a failure of its own.
|
||||
function addNonFatal(failure: NonFatalFailure): void {
|
||||
if (state.nonFatalFailures.length >= MAX_NON_FATAL_FAILURES) return;
|
||||
state.nonFatalFailures.push({
|
||||
phase: failure.phase,
|
||||
error: truncatePipelineErrorMessage(failure.error),
|
||||
});
|
||||
state.nonFatalFailures.push(failure);
|
||||
}
|
||||
|
||||
function startOperation(key: string, label: string): number {
|
||||
@@ -500,8 +498,7 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
};
|
||||
}
|
||||
|
||||
function failOperation(key: string, label: string, startedAt: number, error: unknown): void {
|
||||
const message = truncatePipelineErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
function failOperation(key: string, label: string, startedAt: number, message: string = OPERATION_FAILURE): void {
|
||||
state.operationalStages[key] = {
|
||||
key,
|
||||
label,
|
||||
@@ -524,7 +521,7 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
completeOperation(key, label, startedAt);
|
||||
return result;
|
||||
} catch (error) {
|
||||
failOperation(key, label, startedAt, error);
|
||||
failOperation(key, label, startedAt);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -538,7 +535,7 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
}
|
||||
|
||||
async function runSequentialPhase(
|
||||
phaseName: string,
|
||||
phaseName: WorkflowPhase,
|
||||
agentName: AgentName,
|
||||
runAgent: (input: ActivityInput) => Promise<AgentMetrics>,
|
||||
): Promise<void> {
|
||||
@@ -736,7 +733,8 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
};
|
||||
} catch (error) {
|
||||
if (hasCancellationInCauseChain(error)) throw error;
|
||||
const message = truncatePipelineErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
const message =
|
||||
reconciliationStarted && !reconciliationCompleted ? CLASS_RECONCILIATION_FAILURE : CLASS_PIPELINE_FAILURE;
|
||||
if (reconciliationStarted && !reconciliationCompleted) {
|
||||
state.failedReconciliations.push({ vulnerabilityClass: vulnType, error: message });
|
||||
addPartialReason({ code: 'class_reconciliation_failed', vulnerabilityClass: vulnType });
|
||||
@@ -791,9 +789,7 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
if (result.status === 'fulfilled') {
|
||||
if (result.value.error !== null) failed.push({ vulnType: result.value.vulnType, error: result.value.error });
|
||||
} else {
|
||||
unattributable.push(
|
||||
truncatePipelineErrorMessage(result.reason instanceof Error ? result.reason.message : String(result.reason)),
|
||||
);
|
||||
unattributable.push(CLASS_PIPELINE_FAILURE);
|
||||
}
|
||||
}
|
||||
if (failed.length === 0 && unattributable.length === 0) return;
|
||||
@@ -823,12 +819,12 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
state.agenticSast = {
|
||||
status: 'failed',
|
||||
failedStage: 'workflow',
|
||||
failedStageLabel: capellaStageDisplayName('workflow'),
|
||||
failedStageLabel: capellaTerminalStageLabel('workflow'),
|
||||
error: message,
|
||||
completedStages: [],
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
failOperation(CAPELLA_OPERATION_KEY, CAPELLA_OPERATION_LABEL, startedAt, new Error(message));
|
||||
failOperation(CAPELLA_OPERATION_KEY, CAPELLA_OPERATION_LABEL, startedAt, message);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -902,20 +898,23 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
return result.sarif;
|
||||
}
|
||||
|
||||
const safeFailureMessage = isCapellaSafeFailureMessage(result.error)
|
||||
? result.error
|
||||
: 'An agentic SAST step failed.';
|
||||
state.agenticSast = {
|
||||
status: 'failed',
|
||||
failedStage: result.failedStage,
|
||||
failedStageLabel: capellaStageDisplayName(result.failedStage),
|
||||
error: result.error,
|
||||
failedStageLabel: capellaTerminalStageLabel(result.failedStage),
|
||||
error: safeFailureMessage,
|
||||
...(result.errorCode !== undefined && { errorCode: result.errorCode }),
|
||||
completedStages: [...result.completedStages],
|
||||
durationMs: result.durationMs,
|
||||
};
|
||||
addPartialReason({ code: 'agentic_sast_failed', stage: result.failedStage });
|
||||
failOperation(CAPELLA_OPERATION_KEY, CAPELLA_OPERATION_LABEL, startedAt, new Error(result.error));
|
||||
failOperation(CAPELLA_OPERATION_KEY, CAPELLA_OPERATION_LABEL, startedAt, safeFailureMessage);
|
||||
addNonFatal({
|
||||
phase: 'agentic-sast',
|
||||
error: result.errorCode === undefined ? result.error : `${result.error} [${result.errorCode}]`,
|
||||
error: safeFailureMessage,
|
||||
});
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
@@ -945,11 +944,9 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
const running = state.agenticSast;
|
||||
try {
|
||||
if (running.status === 'running') projectCapellaInfrastructureFailure(running.startedAt);
|
||||
} catch (projectionError) {
|
||||
} catch {
|
||||
try {
|
||||
log.warn('Capella failure projection did not complete', {
|
||||
error: projectionError instanceof Error ? projectionError.message : String(projectionError),
|
||||
});
|
||||
log.warn('Capella failure projection did not complete', { code: 'CAPELLA_PROJECTION_FAILED' });
|
||||
} catch {
|
||||
// Even the warning is best-effort. A log that cannot be written must not turn the
|
||||
// settlement every class joins into a rejected promise.
|
||||
@@ -967,9 +964,9 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
async function awaitCapellaSettlement(settlement: Promise<CapellaSettlement>): Promise<void> {
|
||||
try {
|
||||
await settlement;
|
||||
} catch (waitError) {
|
||||
} catch {
|
||||
log.warn('Capella settlement did not resolve while the scan was stopping', {
|
||||
error: waitError instanceof Error ? waitError.message : String(waitError),
|
||||
code: 'CAPELLA_SETTLEMENT_FAILED',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1026,8 +1023,8 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
completeOperation(key, label, startedAt);
|
||||
} catch (error) {
|
||||
if (hasCancellationInCauseChain(error)) throw error;
|
||||
failOperation(key, label, startedAt, error);
|
||||
const message = truncatePipelineErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
const message = reconciliationCompleted ? MISCELLANEOUS_PIPELINE_FAILURE : CLASS_RECONCILIATION_FAILURE;
|
||||
failOperation(key, label, startedAt, message);
|
||||
if (!reconciliationCompleted) {
|
||||
state.failedReconciliations.push({ vulnerabilityClass: 'miscellaneous', error: message });
|
||||
addPartialReason({ code: 'class_reconciliation_failed', vulnerabilityClass: 'miscellaneous' });
|
||||
@@ -1092,7 +1089,7 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
if (hasCancellationInCauseChain(error)) throw error;
|
||||
renumberFailed.push(vulnerabilityClass);
|
||||
addPartialReason({ code: 'report_renumber_failed', vulnerabilityClass });
|
||||
addNonFatal({ phase: key, error: error instanceof Error ? error.message : String(error) });
|
||||
addNonFatal({ phase: key, error: REPORT_RENUMBER_FAILURE });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1134,7 +1131,7 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
} catch (error) {
|
||||
if (hasCancellationInCauseChain(error)) throw error;
|
||||
addPartialReason({ code: 'report_compaction_failed' });
|
||||
addNonFatal({ phase: 'report:compact', error: error instanceof Error ? error.message : String(error) });
|
||||
addNonFatal({ phase: 'report:compact', error: REPORT_COMPACTION_FAILURE });
|
||||
}
|
||||
}
|
||||
state.reportProgress = await runOperation('report:checkpoint', 'Saving report progress', () =>
|
||||
@@ -1321,10 +1318,8 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
await CancellationScope.nonCancellable(async () => {
|
||||
try {
|
||||
await a.logWorkflowComplete(activityInput, toWorkflowSummary(state, 'cancelled'));
|
||||
} catch (completionError) {
|
||||
log.warn('Failed to finalize cancelled workflow', {
|
||||
error: completionError instanceof Error ? completionError.message : String(completionError),
|
||||
});
|
||||
} catch {
|
||||
log.warn('Failed to finalize cancelled workflow', { code: 'WORKFLOW_LOG_WRITE_FAILED' });
|
||||
}
|
||||
});
|
||||
return state;
|
||||
@@ -1340,10 +1335,8 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
state.summary = computeSummary(state, usageAccountingComplete());
|
||||
try {
|
||||
await a.logWorkflowComplete(activityInput, toWorkflowSummary(state, 'failed'));
|
||||
} catch (completionError) {
|
||||
log.warn('Failed to finalize failed workflow', {
|
||||
error: completionError instanceof Error ? completionError.message : String(completionError),
|
||||
});
|
||||
} catch {
|
||||
log.warn('Failed to finalize failed workflow', { code: 'WORKFLOW_LOG_WRITE_FAILED' });
|
||||
}
|
||||
// Terminate the workflow in Temporal's FAILED state. WARNING: this must be an
|
||||
// ApplicationFailure — any other thrown type becomes an unhandled workflow-task failure
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface AgentEndResult {
|
||||
success: boolean;
|
||||
model?: string | undefined;
|
||||
error?: string | undefined;
|
||||
errorCode?: import('./errors.js').ErrorCode | undefined;
|
||||
checkpoint?: string | undefined;
|
||||
isFinalAttempt?: boolean | undefined;
|
||||
}
|
||||
|
||||
+11
-3
@@ -66,12 +66,20 @@ npx @keygraph/shannon version
|
||||
Source-build equivalents:
|
||||
|
||||
```bash
|
||||
./shannon logs <workspace>
|
||||
./shannon status <workspace>
|
||||
./shannon logs [<workspace>] # the combined live log (unchanged default)
|
||||
./shannon logs [<workspace>] --agent <name> # tail one agent's own log
|
||||
./shannon logs [<workspace>] --list-agents # list the agents with their own log
|
||||
./shannon status [<workspace>]
|
||||
./shannon scans
|
||||
./shannon version
|
||||
```
|
||||
|
||||
Every scan writes one combined `.shannon/workflow.log` and a per-agent projection of it under
|
||||
`.shannon/agents/`: one file per pipeline agent (`recon.log`, `xss-vuln.log`, …) and one per Capella
|
||||
stage (`agentic-sast-research.log`, …). Delegated subagents fold into their parent's file, and a
|
||||
Capella stage's concurrent sessions share its file with an inline session label. The combined log
|
||||
stays canonical; the per-agent files are best-effort projections.
|
||||
|
||||
Open the Temporal Web UI for detailed monitoring:
|
||||
|
||||
```bash
|
||||
@@ -147,7 +155,7 @@ workspaces/{hostname}_{sessionId}/
|
||||
|-- Security-Assessment-Report.md # the final report (Markdown)
|
||||
`-- .shannon/ # internals
|
||||
|-- deliverables/ # report source, per-phase analysis, queues
|
||||
|-- agents/ # per-agent logs
|
||||
|-- agents/ # per-agent log projections, one file per agent/Capella stage
|
||||
|-- prompts/ # rendered prompts
|
||||
|-- scratchpad/ # screenshots, scripts
|
||||
|-- session.json # resume state
|
||||
|
||||
Reference in New Issue
Block a user